├── .gitignore ├── LICENSE ├── README.md ├── liteshort ├── __init__.py ├── config.py ├── config.template.yml ├── main.py ├── static │ ├── GitHub.svg │ ├── favicon.ico │ └── styles.css ├── templates │ └── main.html ├── util.py └── wsgi.py ├── setup.py └── setup ├── liteshort.ini └── liteshort.service /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # PyCharm 107 | .idea 108 | 109 | # Databases 110 | *.db 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Steven Spangler 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## This project has moved 2 | 3 | You can now find liteshort on my [Gitea instance](https://git.ikl.sh/132ikl/liteshort). 4 | 5 | # liteshort 6 | liteshort is a link shortener designed with lightweightness, user and sysadmin-friendliness, privacy, and configurability in mind. 7 | 8 | Click [here](https://ls.ikl.sh) for a live demo. 9 | 10 | *Why liteshort over other URL shorteners?* 11 | 12 | liteshort is designed with the main goal of being lightweight. It does away with all the frills of other link shorteners and allows the best of the basics at a small resource price. liteshort uses under 20 MB of memory idle, per worker. liteshort has an easy-to-use API and web interface. liteshort doesn't store any more information than necessary: just the long and short URLs. It does not log the date of creation, the remote IP, or any other information. 13 | 14 | liteshort focuses on being configurable. There are over 15 config options and most updates will add more. Configuration is done through the easy-to-use YAML format. 15 | 16 | 17 | ![liteshort screenshot](https://fs.ikl.sh/selif/4cgndb6e.png) 18 | 19 | ## Installation 20 | 21 | Liteshort's installation process is dead-simple. Check out the [installation page](https://github.com/132ikl/liteshort/wiki/How-to-Install) for info on how to install. 22 | -------------------------------------------------------------------------------- /liteshort/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/132ikl/liteshort/5a162d8ebd5e1f3d8e3cab62e6e2782bb2153cd6/liteshort/__init__.py -------------------------------------------------------------------------------- /liteshort/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from shutil import copyfile 4 | 5 | from appdirs import site_config_dir, user_config_dir 6 | from pkg_resources import resource_filename 7 | from yaml import safe_load 8 | 9 | logging.basicConfig(level=logging.INFO) 10 | LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | def get_config(): 14 | APP = "liteshort" 15 | AUTHOR = "132ikl" 16 | 17 | paths = [ 18 | Path("/etc/liteshort"), 19 | Path(site_config_dir(APP, AUTHOR)), 20 | Path(user_config_dir(APP, AUTHOR)), 21 | Path(), 22 | ] 23 | 24 | for path in paths: 25 | f = path / "config.yml" 26 | if f.exists(): 27 | LOGGER.info(f"Selecting config file {f}") 28 | return open(f, "r") 29 | 30 | for path in paths: 31 | try: 32 | path.mkdir(exist_ok=True) 33 | template = resource_filename(__name__, "config.template.yml") 34 | copyfile(template, (path / "config.template.yml")) 35 | copyfile(template, (path / "config.yml")) 36 | return open(path / "config.yml", "r") 37 | except (PermissionError, OSError) as e: 38 | LOGGER.warn(f"Failed to create config in {path}") 39 | LOGGER.debug("", exc_info=True) 40 | 41 | raise FileNotFoundError("Cannot find config.yml, and failed to create it") 42 | 43 | 44 | # TODO: yikes 45 | def load_config(): 46 | with get_config() as config: 47 | configYaml = safe_load(config) 48 | config = { 49 | k.lower(): v for k, v in configYaml.items() 50 | } # Make config keys case insensitive 51 | 52 | req_options = { 53 | "admin_username": "admin", 54 | "database_name": "urls", 55 | "random_length": 4, 56 | "allowed_chars": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", 57 | "random_gen_timeout": 5, 58 | "site_name": "liteshort", 59 | "site_domain": None, 60 | "show_github_link": True, 61 | "secret_key": None, 62 | "disable_api": False, 63 | "subdomain": "", 64 | "latest": "l", 65 | "selflinks": False, 66 | "blocklist": [], 67 | } 68 | 69 | config_types = { 70 | "admin_username": str, 71 | "database_name": str, 72 | "random_length": int, 73 | "allowed_chars": str, 74 | "random_gen_timeout": int, 75 | "site_name": str, 76 | "site_domain": (str, type(None)), 77 | "show_github_link": bool, 78 | "secret_key": str, 79 | "disable_api": bool, 80 | "subdomain": (str, type(None)), 81 | "latest": (str, type(None)), 82 | "selflinks": bool, 83 | "blocklist": list, 84 | } 85 | 86 | for option in req_options.keys(): 87 | if ( 88 | option not in config.keys() 89 | ): # Make sure everything in req_options is set in config 90 | config[option] = req_options[option] 91 | 92 | for option in config.keys(): 93 | if option in config_types: 94 | matches = False 95 | if type(config_types[option]) is not tuple: 96 | config_types[option] = ( 97 | config_types[option], 98 | ) # Automatically creates tuple for non-tuple types 99 | for req_type in config_types[ 100 | option 101 | ]: # Iterates through tuple to allow multiple types for config options 102 | if type(config[option]) is req_type: 103 | matches = True 104 | if not matches: 105 | raise TypeError(option + " is incorrect type") 106 | if not config["disable_api"]: 107 | if "admin_hashed_password" in config.keys() and config["admin_hashed_password"]: 108 | config["password_hashed"] = True 109 | elif "admin_password" in config.keys() and config["admin_password"]: 110 | config["password_hashed"] = False 111 | else: 112 | raise TypeError( 113 | "admin_password or admin_hashed_password must be set in config.yml" 114 | ) 115 | return config 116 | -------------------------------------------------------------------------------- /liteshort/config.template.yml: -------------------------------------------------------------------------------- 1 | # String: Username to make admin API requests 2 | # Default: 'admin' 3 | admin_username: 'admin' 4 | 5 | # String: Plaintext password to make admin API requests 6 | # Safe to remove if admin_hashed_password is set 7 | # Default: unset 8 | admin_password: CHANGE_ME 9 | 10 | # String: Hashed password (bcrypt) to make admin API requests - Preferred over plaintext, use lshash to generate 11 | # Please note that authentication takes noticeably longer than using plaintext password 12 | # Don't include the : segment, just the hash 13 | # Default: unset (required to start application) 14 | #admin_hashed_password: 15 | 16 | # Boolean: Disables API. If set to true, admin_password/admin_hashed_password do not need to be set. 17 | # Default: false 18 | disable_api: false 19 | 20 | # String: Secret key used for cookies (used for storage of messages) 21 | # This should be a 12-16 character randomized string with letters, numbers, and symbols 22 | # Default: unset (required to start application) 23 | secret_key: CHANGE_ME 24 | 25 | # String: Filename of the URL database without extension 26 | # Default: 'urls' 27 | database_name: 'urls' 28 | 29 | # Integer: Length of random short URLs by default 30 | # Default: 4 31 | random_length: 4 32 | 33 | # String: Allowed URL characters 34 | # Default: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ 35 | allowed_chars: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' 36 | 37 | # Amount of time in seconds to spend generating random short URLs until timeout 38 | # Default: 5 39 | random_gen_timeout: 5 40 | 41 | # String: Name shown on tab while on site and on page header 42 | # Default: 'liteshort' 43 | site_name: 'liteshort' 44 | 45 | # String: Domain where the shortlinks will be served from. Useful if using the web interface on a subdomain. 46 | # If not set, it is automatically taken from the URL the shorten request is sent to. 47 | # If you don't know, leave unset 48 | # Default: unset 49 | site_domain: 50 | 51 | # String: Subdomain to host the web interface on. 52 | # Useful if you want the shorturls on the short domain but the web interface on a subdomain. 53 | # If you don't know, leave unset 54 | # Default: unset 55 | subdomain: 56 | 57 | # String: URL which takes you to the most recent short URL's destination 58 | # Short URLs cannot be created with this string if set 59 | # Unset to disable 60 | # Default: l 61 | latest: 'l' 62 | 63 | # Boolean: Show link to project repository on GitHub at bottom right corner of page 64 | # Default: true 65 | show_github_link: true 66 | 67 | # Boolean: Allow short URLs linking to your site_domain URL 68 | # Default: false 69 | selflinks: false 70 | 71 | # List: Prevent creation of URLs linking to domains in the blocklist 72 | # Example of list formatting in yaml: 73 | # blocklist: 74 | # - blocklisted.com 75 | # - subdomain.blocklisted.net 76 | # Default: [] 77 | blocklist: [] 78 | -------------------------------------------------------------------------------- /liteshort/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import random 4 | import sqlite3 5 | import time 6 | import urllib 7 | from pathlib import Path 8 | 9 | import flask 10 | from appdirs import user_data_dir 11 | from bcrypt import checkpw 12 | from flask import current_app, g, redirect, render_template, request, url_for 13 | 14 | from .config import load_config 15 | 16 | logging.basicConfig(level=logging.INFO) 17 | LOGGER = logging.getLogger(__name__) 18 | 19 | app = flask.Flask(__name__) 20 | 21 | 22 | def authenticate(username, password): 23 | return username == current_app.config["admin_username"] and check_password( 24 | password, current_app.config 25 | ) 26 | 27 | 28 | def check_long_exist(long): 29 | query = query_db("SELECT short FROM urls WHERE long = ?", (long,)) 30 | for i in query: 31 | if ( 32 | i 33 | and (len(i["short"]) <= current_app.config["random_length"]) 34 | and i["short"] != current_app.config["latest"] 35 | ): # Checks if query if pre-existing URL is same as random length URL 36 | return i["short"] 37 | return False 38 | 39 | 40 | def check_short_exist(short): # Allow to also check against a long link 41 | if get_long(short): 42 | return True 43 | return False 44 | 45 | 46 | def linking_to_blocklist(long): 47 | # Removes protocol and other parts of the URL to extract the domain name 48 | long = long.split("//")[-1].split("/")[0] 49 | if long in current_app.config["blocklist"]: 50 | return True 51 | if not current_app.config["selflinks"]: 52 | return long in get_baseUrl() 53 | return False 54 | 55 | 56 | def check_password(password, pass_config): 57 | if pass_config["password_hashed"]: 58 | return checkpw( 59 | password.encode("utf-8"), 60 | pass_config["admin_hashed_password"].encode("utf-8"), 61 | ) 62 | elif not pass_config["password_hashed"]: 63 | return password == pass_config["admin_password"] 64 | else: 65 | raise RuntimeError("This should never occur! Bailing...") 66 | 67 | 68 | def delete_short(deletion): 69 | result = query_db( 70 | "SELECT * FROM urls WHERE short = ?", (deletion,), False, None 71 | ) # Return as tuple instead of row 72 | get_db().cursor().execute("DELETE FROM urls WHERE short = ?", (deletion,)) 73 | get_db().commit() 74 | return len(result) 75 | 76 | 77 | def delete_long(long): 78 | if "//" in long: 79 | long = long.split("//")[-1] 80 | long = "%" + long + "%" 81 | result = query_db( 82 | "SELECT * FROM urls WHERE long LIKE ?", (long,), False, None 83 | ) # Return as tuple instead of row 84 | get_db().cursor().execute("DELETE FROM urls WHERE long LIKE ?", (long,)) 85 | get_db().commit() 86 | return len(result) 87 | 88 | 89 | def dict_factory(cursor, row): 90 | d = {} 91 | for idx, col in enumerate(cursor.description): 92 | d[col[0]] = row[idx] 93 | return d 94 | 95 | 96 | def generate_short(rq): 97 | timeout = time.time() + current_app.config["random_gen_timeout"] 98 | while True: 99 | if time.time() >= timeout: 100 | return response(rq, None, "Timeout while generating random short URL") 101 | short = "".join( 102 | random.choice(current_app.config["allowed_chars"]) 103 | for i in range(current_app.config["random_length"]) 104 | ) 105 | if not check_short_exist(short) and short != app.config["latest"]: 106 | return short 107 | 108 | 109 | def get_long(short): 110 | row = query_db("SELECT long FROM urls WHERE short = ?", (short,), True) 111 | if row and row["long"]: 112 | return row["long"] 113 | return None 114 | 115 | 116 | def get_baseUrl(): 117 | if current_app.config["site_domain"]: 118 | # TODO: un-hack-ify adding the protocol here 119 | return "https://" + current_app.config["site_domain"] + "/" 120 | else: 121 | return request.base_url 122 | 123 | 124 | def list_shortlinks(): 125 | result = query_db("SELECT * FROM urls", (), False, None) 126 | result = nested_list_to_dict(result) 127 | return result 128 | 129 | 130 | def nested_list_to_dict(l): 131 | d = {} 132 | for nl in l: 133 | d[nl[0]] = nl[1] 134 | return d 135 | 136 | 137 | def response(rq, result, error_msg="Error: Unknown error"): 138 | if rq.accept_mimetypes.accept_json and not rq.accept_mimetypes.accept_html: 139 | if result: 140 | return flask.jsonify(success=bool(result), result=result) 141 | return flask.jsonify(success=bool(result), message=error_msg) 142 | if rq.form.get("api"): 143 | return "Format type HTML (default) not supported for API" # Future-proof for non-json return types 144 | else: 145 | if result and result is not True: 146 | flask.flash(result, "success") 147 | elif not result: 148 | flask.flash(error_msg, "error") 149 | return render_template("main.html") 150 | 151 | 152 | def set_latest(long): 153 | if app.config["latest"]: 154 | if query_db( 155 | "SELECT short FROM urls WHERE short = ?", (current_app.config["latest"],) 156 | ): 157 | get_db().cursor().execute( 158 | "UPDATE urls SET long = ? WHERE short = ?", 159 | (long, current_app.config["latest"]), 160 | ) 161 | else: 162 | get_db().cursor().execute( 163 | "INSERT INTO urls (long,short) VALUES (?, ?)", 164 | (long, current_app.config["latest"]), 165 | ) 166 | 167 | 168 | def validate_short(short): 169 | if short == app.config["latest"]: 170 | return response( 171 | request, 172 | None, 173 | "Short URL cannot be the same as a special URL ({})".format(short), 174 | ) 175 | for char in short: 176 | if char not in current_app.config["allowed_chars"]: 177 | return response( 178 | request, None, "Character " + char + " not allowed in short URL" 179 | ) 180 | return True 181 | 182 | 183 | def validate_long(long): # https://stackoverflow.com/a/36283503 184 | token = urllib.parse.urlparse(long) 185 | return all([token.scheme, token.netloc]) 186 | 187 | 188 | # Database connection functions 189 | 190 | 191 | def db_path(name): 192 | paths = [ 193 | Path("/var/lib/liteshort/"), 194 | Path(user_data_dir("liteshort", "132ikl")), 195 | ] 196 | for path in paths: 197 | try: 198 | path.mkdir(exist_ok=True) 199 | db = path / Path(name + ".db") 200 | LOGGER.info(f"Selecting database file {db}") 201 | return str(db) 202 | except (PermissionError, OSError): 203 | LOGGER.warn(f"Failed to access database in {path}") 204 | LOGGER.debug("", exc_info=True) 205 | raise FileNotFoundError("Cannot access database file") 206 | 207 | 208 | def get_db(): 209 | if "db" not in g: 210 | g.db = sqlite3.connect( 211 | current_app.config["database"], detect_types=sqlite3.PARSE_DECLTYPES 212 | ) 213 | g.db.cursor().execute("CREATE TABLE IF NOT EXISTS urls (long,short)") 214 | return g.db 215 | 216 | 217 | def query_db(query, args=(), one=False, row_factory=sqlite3.Row): 218 | get_db().row_factory = row_factory 219 | cur = get_db().execute(query, args) 220 | rv = cur.fetchall() 221 | cur.close() 222 | return (rv[0] if rv else None) if one else rv 223 | 224 | 225 | @app.teardown_appcontext 226 | def close_db(error): 227 | if hasattr(g, "sqlite_db"): 228 | g.sqlite_db.close() 229 | 230 | 231 | app.config.update(load_config()) # Add YAML config to Flask config 232 | app.config["database"] = db_path(app.config["database_name"]) 233 | 234 | app.secret_key = app.config["secret_key"] 235 | app.config["SERVER_NAME"] = app.config["site_domain"] 236 | 237 | 238 | @app.route("/favicon.ico", subdomain=app.config["subdomain"]) 239 | def favicon(): 240 | return flask.send_from_directory( 241 | os.path.join(app.root_path, "static"), 242 | "favicon.ico", 243 | mimetype="image/vnd.microsoft.icon", 244 | ) 245 | 246 | 247 | @app.route("/", subdomain=app.config["subdomain"]) 248 | def main(): 249 | return response(request, True) 250 | 251 | 252 | @app.route("/") 253 | def main_redir(url): 254 | long = get_long(url) 255 | if long: 256 | resp = flask.make_response(flask.redirect(long, 301)) 257 | else: 258 | flask.flash('Short URL "' + url + "\" doesn't exist", "error") 259 | resp = flask.make_response(flask.redirect(url_for("main"))) 260 | resp.headers.set("Cache-Control", "no-store, must-revalidate") 261 | return resp 262 | 263 | 264 | @app.route("/", methods=["POST"], subdomain=app.config["subdomain"]) 265 | def main_post(): 266 | if request.form.get("api"): 267 | if current_app.config["disable_api"]: 268 | return response(request, None, "API is disabled.") 269 | # All API calls require authentication 270 | if not request.authorization or not authenticate( 271 | request.authorization["username"], request.authorization["password"] 272 | ): 273 | return response(request, None, "BaiscAuth failed") 274 | command = request.form["api"] 275 | if command == "list" or command == "listshort": 276 | return response(request, list_shortlinks(), "Failed to list items") 277 | elif command == "listlong": 278 | shortlinks = list_shortlinks() 279 | shortlinks = {v: k for k, v in shortlinks.items()} 280 | return response(request, shortlinks, "Failed to list items") 281 | elif command == "delete": 282 | deleted = 0 283 | if "long" not in request.form and "short" not in request.form: 284 | return response(request, None, "Provide short or long in POST data") 285 | if "short" in request.form: 286 | deleted = delete_short(request.form["short"]) + deleted 287 | if "long" in request.form: 288 | deleted = delete_long(request.form["long"]) + deleted 289 | if deleted > 0: 290 | return response( 291 | request, 292 | "Deleted " + str(deleted) + " URL" + ("s" if deleted > 1 else ""), 293 | ) 294 | else: 295 | return response(request, None, "URL not found") 296 | else: 297 | return response(request, None, "Command " + command + " not found") 298 | 299 | if request.form.get("long"): 300 | if not validate_long(request.form["long"]): 301 | return response(request, None, "Long URL is not valid") 302 | if request.form.get("short"): 303 | # Validate long as URL and short custom text against allowed characters 304 | result = validate_short(request.form["short"]) 305 | if validate_short(request.form["short"]) is True: 306 | short = request.form["short"] 307 | else: 308 | return result 309 | if get_long(short) == request.form["long"]: 310 | return response( 311 | request, 312 | get_baseUrl() + short, 313 | "Error: Failed to return pre-existing non-random shortlink", 314 | ) 315 | else: 316 | short = generate_short(request) 317 | if check_short_exist(short): 318 | return response(request, None, "Short URL already taken") 319 | long_exists = check_long_exist(request.form["long"]) 320 | if linking_to_blocklist(request.form["long"]): 321 | return response(request, None, "You cannot link to this site") 322 | if long_exists and not request.form.get("short"): 323 | set_latest(request.form["long"]) 324 | get_db().commit() 325 | return response( 326 | request, 327 | get_baseUrl() + long_exists, 328 | "Error: Failed to return pre-existing random shortlink", 329 | ) 330 | get_db().cursor().execute( 331 | "INSERT INTO urls (long,short) VALUES (?,?)", (request.form["long"], short) 332 | ) 333 | set_latest(request.form["long"]) 334 | get_db().commit() 335 | return response(request, get_baseUrl() + short, "Error: Failed to generate") 336 | else: 337 | return response(request, None, "Long URL required") 338 | 339 | 340 | if __name__ == "__main__": 341 | app.run() 342 | -------------------------------------------------------------------------------- /liteshort/static/GitHub.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /liteshort/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/132ikl/liteshort/5a162d8ebd5e1f3d8e3cab62e6e2782bb2153cd6/liteshort/static/favicon.ico -------------------------------------------------------------------------------- /liteshort/static/styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2019 Steven Spangler <132@ikl.sh> 3 | This file is part of liteshort by 132ikl 4 | This software is license under the MIT license. It should be included in your copy of this software. 5 | A copy of the MIT license can be obtained at https://mit-license.org/ 6 | */ 7 | 8 | input { 9 | margin: auto; 10 | } 11 | 12 | div.form { 13 | margin-top: 5%; 14 | text-align: center; 15 | } 16 | 17 | div.success { 18 | display: inline-block; 19 | font-family: Open Sans; 20 | border-radius: 2vh; 21 | padding: 2vh; 22 | color: #62ad2c; 23 | background-color: #E9FFD9; 24 | border: 1px solid #62ad2c; 25 | } 26 | 27 | 28 | div.error { 29 | display: inline-block; 30 | font-family: Open Sans; 31 | border-radius: 2vh; 32 | padding: 2vh; 33 | color: #a86464; 34 | background-color: #FCE9E9; 35 | border: 1px solid #a86464; 36 | } 37 | 38 | body { 39 | text-align: center 40 | } 41 | 42 | h2 { 43 | font-weight: normal; 44 | font-family: Open Sans; 45 | } 46 | 47 | div.success > a:link { 48 | color: #62ad2c; 49 | } 50 | 51 | div.success > a:visited { 52 | color: #507c52; 53 | } 54 | 55 | div.github { 56 | position: absolute; 57 | transition: opacity .25s ease-out; 58 | -moz-transition: opacity .25s ease-out; 59 | -webkit-transition: opacity .25s ease-out; 60 | -o-transition: opacity .25s ease-out; 61 | opacity: 0.3; 62 | right: 2.5vw; 63 | bottom: 15vh; 64 | width: 50px; 65 | height: 10px; 66 | } 67 | 68 | div.github:hover { 69 | opacity: 1; 70 | } -------------------------------------------------------------------------------- /liteshort/templates/main.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | {{ config.site_name }} 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |

{{ config.site_name }}

20 |
21 |

22 | 23 |

24 |

25 | 26 |

27 |

28 | 29 |

30 |
31 |
32 | {% with messages = get_flashed_messages(with_categories=true) %} 33 | {% if messages %} 34 | {% for category, message in messages %} 35 | {% if category == 'success' %} 36 |
37 | ✓ Shortlink successfully generated! Available at {{ message }} 38 |
39 | {% elif category == 'error' %} 40 |
41 | ✖ {{ message }} 42 |
43 | {% endif %} 44 | {% endfor %} 45 | {% endif %} 46 | {% endwith %} 47 | {% if config.show_github_link %} 48 |
49 | 50 | 51 | 52 |
53 | {% endif %} 54 | 55 | -------------------------------------------------------------------------------- /liteshort/util.py: -------------------------------------------------------------------------------- 1 | from getpass import getpass 2 | 3 | import bcrypt 4 | 5 | 6 | def hash_passwd(): 7 | salt = bcrypt.gensalt() 8 | try: 9 | unhashed = getpass("Type password to hash: ") 10 | unhashed2 = getpass("Confirm: ") 11 | except (KeyboardInterrupt, EOFError): 12 | pass 13 | 14 | if unhashed != unhashed2: 15 | print("Passwords don't match.") 16 | return None 17 | 18 | hashed = bcrypt.hashpw(unhashed.encode("utf-8"), salt) 19 | 20 | print("Password hash: " + hashed.decode("utf-8")) 21 | -------------------------------------------------------------------------------- /liteshort/wsgi.py: -------------------------------------------------------------------------------- 1 | from .main import app 2 | 3 | if __name__ == "__main__": 4 | app.run() 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="liteshort", 8 | version="1.2.2", 9 | author="Steven Spangler", 10 | author_email="132@ikl.sh", 11 | description="User-friendly, actually lightweight, and configurable URL shortener", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/132ikl/liteshort", 15 | packages=setuptools.find_packages(), 16 | package_data={"liteshort": ["templates/*", "static/*", "config.template.yml"]}, 17 | entry_points={ 18 | "console_scripts": [ 19 | "liteshort = liteshort.main:app.run", 20 | "lshash = liteshort.util:hash_passwd", 21 | ] 22 | }, 23 | classifiers=[ 24 | "Programming Language :: Python :: 3", 25 | "License :: OSI Approved :: MIT License", 26 | "Operating System :: POSIX :: Linux", 27 | ], 28 | install_requires=["flask~=1.1.2", "bcrypt~=3.1.7", "pyyaml", "appdirs~=1.4.3"], 29 | python_requires=">=3.7", 30 | ) 31 | -------------------------------------------------------------------------------- /setup/liteshort.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | module = liteshort.wsgi:app 3 | plugin = python3 4 | 5 | master = true 6 | processes = 2 7 | 8 | socket = /run/liteshort.sock 9 | chmod-socket = 666 10 | vacuum = true 11 | 12 | die-on-term = true 13 | -------------------------------------------------------------------------------- /setup/liteshort.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=uWSGI instance to serve liteshort 3 | After=network.target 4 | 5 | [Service] 6 | ExecStart=/usr/bin/uwsgi --ini /etc/liteshort/liteshort.ini 7 | 8 | [Install] 9 | WantedBy=multi-user.target 10 | --------------------------------------------------------------------------------