WIP: add admin views

This commit is contained in:
ksamuel 2020-08-12 09:19:38 +02:00
parent 6843a19fa8
commit 88df4787bf
6 changed files with 216 additions and 45 deletions

View File

@ -3,3 +3,4 @@ clize==4.1.1
lockfile==0.12.2
sigtools==2.0.2
bottle==0.12.18
Beaker==1.11.0

View File

@ -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)

View File

@ -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
@ -67,3 +67,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

View File

@ -11,16 +11,24 @@ import urllib.parse as urlparse
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, 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,6 +37,17 @@ GLOBAL_CONTEXT = {
}
app = SessionMiddleware(
Bottle(),
{
"session.type": "file",
"session.cookie_expires": 300,
"session.data_dir": settings.SESSIONS_DIR,
"session.auto": True,
},
)
@app.route("/")
@view("home")
def index():
@ -41,6 +60,50 @@ def faq():
return GLOBAL_CONTEXT
@app.route(settings.ADMIN_URL, method="GET")
@view("admin")
def admin():
session = request.environ.get("beaker.session")
if not session or not session.get("is_authenticated"):
redirect(settings.ADMIN_URL + "/login")
return {}
@app.route(settings.ADMIN_URL + "/:paste_id", method="DELETE")
@view("admin")
def delete_paste_from_admin(paste_id):
session = request.environ.get("beaker.session")
if not session or not session.get("is_authenticated"):
abort(403, "Sorry, access denied.")
try:
paste = Paste.load(paste_id)
except (TypeError, ValueError):
return error404(ValueError)
paste.delete()
return {"status": "ok", "message": "Paste deleted"}
@app.route(settings.ADMIN_URL + "/login")
def login():
password = request.forms.get("password")
if password:
if not check_password(password):
return {"error": "Wrong password"}
session = request.environ.get("beaker.session")
session["is_authenticated"] = True
session.save()
redirect(settings.ADMIN_URL)
else:
return {}
@app.route("/paste/create", method="POST")
def create_paste():
@ -125,8 +188,7 @@ 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")

View File

@ -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 = VAR_DIR / "content"
settings.PASTE_FILES_ROOT.mkdir(exist_ok=True)
settings.SESSIONS_DIR = 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

32
zerobin/views/admin.tpl Normal file
View File

@ -0,0 +1,32 @@
%if is_authenticated:
<form action="" method="delete">
<div>
<form>
<div class="form-group">
<label>Paste to delete</label>
<input type="text" class="form-control" placeholder="Paste URL or ID">
</div>
<button type="submit" class="btn btn-black">Delete</button>
</form>
</div>
%else:
<form action="/login" method="post">
<div class="login-form">
<form>
<label>Password</label>
<input type="password" class="form-control" placeholder="Password">
<button type="submit" class="btn btn-black">Login</button>
</form>
</div>
</form>
%end
% rebase('base', settings=settings, pastes_count=pastes_count)