diff --git a/requirements.txt b/requirements.txt index 5bd677c..2a1ce71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ clize==4.1.1 lockfile==0.12.2 sigtools==2.0.2 bottle==0.12.18 +Beaker==1.11.0 diff --git a/zerobin/cmd.py b/zerobin/cmd.py index ffaa78f..47bcce6 100644 --- a/zerobin/cmd.py +++ b/zerobin/cmd.py @@ -7,10 +7,16 @@ import sys import re -import secrets +import hashlib import _thread as thread -from zerobin.utils import settings, SettingsValidationError, drop_privileges +from zerobin.utils import ( + settings, + SettingsValidationError, + drop_privileges, + ensure_var_env, + hash_password, +) from zerobin.routes import get_app from zerobin.paste import Paste @@ -43,13 +49,7 @@ def runserver( settings.PASTE_ID_LENGTH = paste_id_length or settings.PASTE_ID_LENGTH settings.DEBUG = bool(debug) if debug is not None else settings.DEBUG - settings.VAR_DIR.mkdir(exist_ok=True, parents=True) - settings.PASTE_FILES_ROOT.mkdir(exist_ok=True, parents=True) - - secret_key_file = settings.VAR_DIR / "secret_key" - if not secret_key_file.is_file(): - secret_key_file.write_text(secrets.token_urlsafe(64)) - settings.SECRET_KEY = secret_key_file.read_text() + ensure_var_env() try: _, app = get_app(debug, settings_file, compressed_static, settings=settings) @@ -60,6 +60,7 @@ def runserver( thread.start_new_thread(drop_privileges, (settings.USER, settings.GROUP)) if settings.DEBUG: + print(f"Admin URL: http://{settings.HOST}:{settings.PORT}{settings.ADMIN_URL}") run( app, host=settings.HOST, port=settings.PORT, reloader=True, server=server, ) @@ -104,13 +105,51 @@ def delete_paste(*pastes, quiet=False): print("Paste {} doesn't exist".format(paste_uuid)) +def print_admin_url(): + """ Print the route to the 0bin admin. + + The admin route is generated by zerobin so that bots won't easily + bruteforce it. To get the full URL, simply preppend your website domain + name to it. + + E.G: + + If this command prints: + + "/admin/f1cc3972a4b933c734b37906940cf69886161492ee4eb7c1faff5d7b5e92efb8" + + Then the admin url is: + + "http://yourdomain.com/admin/f1cc3972a4b933c734b37906940cf69886161492ee4eb7c1faff5d7b5e92efb8" + + Adapt "http" and "yourdomain.com" to your configuration. + + In debug mode, the dev server will print the url when starting. + + """ + + ensure_var_env() + print(settings.ADMIN_URL) + + +def set_admin_password(password): + """ Set the password for the admin + + It will be stored as a scrypt hash in a file in the var dir. + + """ + + ensure_var_env() + settings.ADMIN_PASSWORD_FILE.write_bytes(hash_password(password)) + + def main(): - subcommands = [runserver, delete_paste] + subcommands = [runserver, delete_paste, print_admin_url, set_admin_password] subcommand_names = [ clize.util.name_py2cli(name) for name in clize.util.dict_from_names(subcommands).keys() ] if len(sys.argv) < 2 or sys.argv[1] not in subcommand_names: sys.argv.insert(1, subcommand_names[0]) - clize.run(runserver, delete_paste) + clize.run(runserver, delete_paste, print_admin_url, set_admin_password) diff --git a/zerobin/default_settings.py b/zerobin/default_settings.py index 62cf112..ce8c248 100644 --- a/zerobin/default_settings.py +++ b/zerobin/default_settings.py @@ -19,11 +19,6 @@ STATIC_FILES_ROOT = ROOT_DIR / "static" # otherwise, will use the ordinary files COMPRESSED_STATIC_FILES = False -# absolute path where the paste files should be store -# default in projectdirectory/static/content/ -# use "/" even under Windows -PASTE_FILES_ROOT = VAR_DIR / "content" - # A tuple of absolute paths of directory where to look the template for # the first one will be the first to be looked into # if you want to override, it needs to be it a directory at the begining of @@ -48,7 +43,12 @@ GROUP = None # Be carreful if your site have to many pastes this can hurt your hard drive performances. # Refresh counter interval. Default to every minute after a paste. DISPLAY_COUNTER = True -REFRESH_COUNTER = 60 * 1 +REFRESH_COUNTER = 60 * 1 # Fill this if you want to +ADMIN_CREDENTIALS = { + "username": None, + "password": None, +} + # Names/links to insert in the menu bar. # Any link with "mailto:" will be escaped to prevent spam @@ -68,3 +68,4 @@ MAX_SIZE = 1024 * 500 # total number of unique pastes can be calculated as 2^(6*PASTE_ID_LENGTH) # for PASTE_ID_LENGTH=8, for example, it's 2^(6*8) = 281 474 976 710 656 PASTE_ID_LENGTH = 8 + diff --git a/zerobin/routes.py b/zerobin/routes.py index 4bc3101..c1f8a59 100644 --- a/zerobin/routes.py +++ b/zerobin/routes.py @@ -7,20 +7,37 @@ import sys import _thread as thread -import urllib.parse as urlparse +from urllib.parse import urlparse, parse_qs from datetime import datetime, timedelta -from zerobin import __version__ -from zerobin.utils import settings, SettingsValidationError, dmerge - import bottle -from bottle import Bottle, static_file, view, request, HTTPResponse +from bottle import ( + Bottle, + debug, + static_file, + view, + request, + HTTPResponse, + redirect, + abort, +) +from beaker.middleware import SessionMiddleware + +from zerobin import __version__ +from zerobin.utils import ( + settings, + SettingsValidationError, + ensure_var_env, + check_password, +) from zerobin.paste import Paste -app = Bottle() +ensure_var_env() + + GLOBAL_CONTEXT = { "settings": settings, "VERSION": __version__, @@ -29,23 +46,79 @@ GLOBAL_CONTEXT = { } +app = Bottle() + +ADMIN_LOGIN_URL = settings.ADMIN_URL + "login/" + + @app.route("/") @view("home") def index(): return GLOBAL_CONTEXT -@app.route("/faq/") +@app.get("/faq/") @view("faq") def faq(): return GLOBAL_CONTEXT -@app.route("/paste/create", method="POST") +@app.get(settings.ADMIN_URL) +@app.post(settings.ADMIN_URL) +@view("admin") +def admin(): + session = request.environ.get("beaker.session") + if not session or not session.get("is_authenticated"): + redirect(ADMIN_LOGIN_URL) + + paste_id = request.forms.get("paste", "") + if paste_id: + try: + if "/paste/" in paste_id: + paste_id = urlparse(paste_id).path.split("/path/")[-1] + paste = Paste.load(paste_id) + paste.delete() + except (TypeError, ValueError, FileNotFoundError): + return {"status": "error", "message": f"Cannot find paste '{paste_id}'"} + + return {"status": "ok", "message": "Paste deleted"} + + return {"status": "ok", **GLOBAL_CONTEXT} + + +@app.get(ADMIN_LOGIN_URL) +@app.post(ADMIN_LOGIN_URL) +@view("login") +def login(): + + password = request.forms.get("password") + if password: + if not check_password(password): + return {"status": "error", "message": "Wrong password", **GLOBAL_CONTEXT} + + session = request.environ.get("beaker.session") + session["is_authenticated"] = True + session.save() + + redirect(settings.ADMIN_URL) + + return {"status": "ok", **GLOBAL_CONTEXT} + + +@app.post(settings.ADMIN_URL + "logout/") +@view("logout") +def logout(): + session = request.environ.get("beaker.session") + session["is_authenticated"] = False + session.save() + redirect("/") + + +@app.post("/paste/create") def create_paste(): try: - body = urlparse.parse_qs(request.body.read(int(settings.MAX_SIZE * 1.1))) + body = parse_qs(request.body.read(int(settings.MAX_SIZE * 1.1))) except ValueError: return {"status": "error", "message": "Wrong data payload."} @@ -95,7 +168,7 @@ def create_paste(): } -@app.route("/paste/:paste_id", method="GET") +@app.get("/paste/:paste_id") @view("paste") def display_paste(paste_id): @@ -125,11 +198,10 @@ def display_paste(paste_id): except (TypeError, ValueError): return error404(ValueError) - context = {"paste": paste, "keep_alive": keep_alive} - return dmerge(context, GLOBAL_CONTEXT) + return {"paste": paste, "keep_alive": keep_alive, **GLOBAL_CONTEXT} -@app.route("/paste/:paste_id", method="DELETE") +@app.delete("/paste/:paste_id") def delete_paste(paste_id): try: @@ -154,7 +226,7 @@ def error404(code): return GLOBAL_CONTEXT -@app.route("/static/") +@app.get("/static/") def server_static(filename): return static_file(filename, root=settings.STATIC_FILES_ROOT) @@ -187,3 +259,14 @@ def get_app(debug=None, settings_file="", compressed_static=None, settings=setti bottle.debug(True) return settings, app + + +app = SessionMiddleware( + app, + { + "session.type": "file", + "session.cookie_expires": 300, + "session.data_dir": settings.SESSIONS_DIR, + "session.auto": True, + }, +) diff --git a/zerobin/utils.py b/zerobin/utils.py index ac92e4d..fcd7765 100644 --- a/zerobin/utils.py +++ b/zerobin/utils.py @@ -2,9 +2,10 @@ import time import os import glob import tempfile -import sys import codecs import unicodedata +import hashlib +import secrets from functools import partial from zerobin import default_settings @@ -12,16 +13,10 @@ from zerobin import default_settings try: from zerobin.privilege import drop_privileges_permanently, coerce_user, coerce_group except (AttributeError): - pass # privilege does't work on several plateform + pass # privilege does't work on several plateforms -try: - from runpy import run_path -except ImportError: - # python-2.6 or earlier - use simplier less-optimized execfile() - def run_path(file_path): - mod_globals = {"__file__": file_path} - execfile(file_path, mod_globals) - return mod_globals + +from runpy import run_path def drop_privileges(user=None, group=None, wait=5): @@ -47,17 +42,6 @@ def drop_privileges(user=None, group=None, wait=5): print("Failed to drop privileges. Running with current user.") -def dmerge(*args): - """ - Return new directionay being the sum of all merged dictionaries passed - as arguments - """ - dictionary = {} - for arg in args: - dictionary.update(arg) - return dictionary - - class SettingsValidationError(Exception): pass @@ -134,3 +118,55 @@ def as_unicode(obj): return unicode(obj) except NameError: return str(obj) + + +def ensure_var_env(): + """ Ensure all the variable things we generate are available. + + This will make sure we have: + + - a var dir + - a content dir + - a secret key + - an admin URL + """ + + settings.VAR_DIR.mkdir(exist_ok=True, parents=True) + settings.PASTE_FILES_ROOT = settings.VAR_DIR / "content" + settings.PASTE_FILES_ROOT.mkdir(exist_ok=True) + settings.SESSIONS_DIR = settings.VAR_DIR / "sessions" + settings.SESSIONS_DIR.mkdir(exist_ok=True) + + secret_key_file = settings.VAR_DIR / "secret_key" + if not secret_key_file.is_file(): + secret_key_file.write_text(secrets.token_urlsafe(64)) + settings.SECRET_KEY = secret_key_file.read_text() + + admin_password_file = settings.VAR_DIR / "admin_password" + if not secret_key_file.is_file(): + admin_password_file.write_text( + "No password set. Use the set_admin_passord command. Don't write this file by hand." + ) + settings.ADMIN_PASSWORD_FILE = admin_password_file + + payload = ("admin" + settings.SECRET_KEY).encode("ascii") + settings.ADMIN_URL = "/admin/" + hashlib.sha256(payload).hexdigest() + "/" + + +def hash_password(password): + return hashlib.scrypt( + password.encode("utf8"), + salt=settings.SECRET_KEY.encode("ascii"), + n=16384, + r=8, + p=1, + dklen=32, + ) + + +def check_password(password): + try: + return settings.ADMIN_PASSWORD_FILE.read_bytes() == hash_password(password) + except (FileNotFoundError, AttributeError): + return False + diff --git a/zerobin/views/admin.tpl b/zerobin/views/admin.tpl new file mode 100644 index 0000000..14c1a2f --- /dev/null +++ b/zerobin/views/admin.tpl @@ -0,0 +1,25 @@ +
+ %if status == "error": +
+ {{message}} +
+ %end +
+
+ + +
+ + +
+ +
+ +
+
+ +
+
+ + +% rebase('base', settings=settings, pastes_count=pastes_count) diff --git a/zerobin/views/base.tpl b/zerobin/views/base.tpl index b74578c..f3aa0a7 100644 --- a/zerobin/views/base.tpl +++ b/zerobin/views/base.tpl @@ -35,14 +35,14 @@