diff --git a/fhost.py b/fhost.py index bda680f..c771d3e 100755 --- a/fhost.py +++ b/fhost.py @@ -34,6 +34,7 @@ import sys import time import typing import requests +import secrets from validators import url as url_valid from pathlib import Path @@ -127,13 +128,15 @@ class File(db.Model): removed = db.Column(db.Boolean, default=False) nsfw_score = db.Column(db.Float) expiration = db.Column(db.BigInteger) + mgmt_token = db.Column(db.String) - def __init__(self, sha256, ext, mime, addr, expiration): + def __init__(self, sha256, ext, mime, addr, expiration, mgmt_token): self.sha256 = sha256 self.ext = ext self.mime = mime self.addr = addr self.expiration = expiration + self.mgmt_token = mgmt_token def getname(self): return u"{0}{1}".format(su.enbase(self.id), self.ext) @@ -146,6 +149,15 @@ class File(db.Model): else: return url_for("get", path=n, _external=True) + "\n" + def getpath(self) -> Path: + return Path(app.config["FHOST_STORAGE_PATH"]) / self.sha256 + + def delete(self, permanent=False): + self.expiration = None + self.mgmt_token = None + self.removed = permanent + self.getpath().unlink(missing_ok=True) + """ requested_expiration can be: - None, to use the longest allowed file lifespan @@ -218,6 +230,7 @@ class File(db.Model): else: # Treat the requested expiration time as a timestamp in epoch millis return min(this_files_max_expiration, requested_expiration); + isnew = True f = File.query.filter_by(sha256=digest).first() if f: @@ -228,14 +241,19 @@ class File(db.Model): if f.expiration is None: # The file has expired, so give it a new expiration date f.expiration = get_expiration() + + # Also generate a new management token + f.mgmt_token = secrets.token_urlsafe() else: # The file already exists, update the expiration if needed f.expiration = max(f.expiration, get_expiration()) + isnew = False else: mime = get_mime() ext = get_ext(mime) expiration = get_expiration() - f = File(digest, ext, mime, addr, expiration) + mgmt_token = secrets.token_urlsafe() + f = File(digest, ext, mime, addr, expiration, mgmt_token) f.addr = addr @@ -252,8 +270,7 @@ class File(db.Model): db.session.add(f) db.session.commit() - return f - + return f, isnew class UrlEncoder(object): @@ -323,9 +340,14 @@ def store_file(f, requested_expiration: typing.Optional[int], addr): if in_upload_bl(addr): return "Your host is blocked from uploading files.\n", 451 - sf = File.store(f, requested_expiration, addr) + sf, isnew = File.store(f, requested_expiration, addr) - return sf.geturl() + response = make_response(sf.geturl()) + + if isnew: + response.headers["X-Token"] = sf.mgmt_token + + return response def store_url(url, addr): if is_fhost_url(url): @@ -354,7 +376,20 @@ def store_url(url, addr): else: abort(411) -@app.route("/") +def manage_file(f): + try: + assert(request.form["token"] == f.mgmt_token) + except: + abort(401) + + if "delete" in request.form: + f.delete() + db.session.commit() + return "" + + abort(400) + +@app.route("/", methods=["GET", "POST"]) def get(path): path = Path(path.split("/", 1)[0]) sufs = "".join(path.suffixes[-2:]) @@ -368,11 +403,14 @@ def get(path): if f.removed: abort(451) - fpath = Path(app.config["FHOST_STORAGE_PATH"]) / f.sha256 + fpath = f.getpath() if not fpath.is_file(): abort(404) + if request.method == "POST": + return manage_file(f) + if app.config["FHOST_USE_X_ACCEL_REDIRECT"]: response = make_response() response.headers["Content-Type"] = f.mime @@ -382,6 +420,9 @@ def get(path): else: return send_from_directory(app.config["FHOST_STORAGE_PATH"], f.sha256, mimetype = f.mime) else: + if request.method == "POST": + abort(405) + u = URL.query.get(id) if u: @@ -428,6 +469,7 @@ Disallow: / """ @app.errorhandler(400) +@app.errorhandler(401) @app.errorhandler(404) @app.errorhandler(411) @app.errorhandler(413) @@ -436,7 +478,7 @@ Disallow: / @app.errorhandler(451) def ehandler(e): try: - return render_template(f"{e.code}.html", id=id), e.code + return render_template(f"{e.code}.html", id=id, request=request), e.code except TemplateNotFound: return "Segmentation fault\n", e.code diff --git a/migrations/versions/0659d7b9eea8_.py b/migrations/versions/0659d7b9eea8_.py new file mode 100644 index 0000000..2ef2151 --- /dev/null +++ b/migrations/versions/0659d7b9eea8_.py @@ -0,0 +1,26 @@ +"""add file management token + +Revision ID: 0659d7b9eea8 +Revises: 939a08e1d6e5 +Create Date: 2022-11-30 01:06:53.362973 + +""" + +# revision identifiers, used by Alembic. +revision = '0659d7b9eea8' +down_revision = '939a08e1d6e5' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('file', sa.Column('mgmt_token', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('file', 'mgmt_token') + # ### end Alembic commands ### diff --git a/templates/401.html b/templates/401.html new file mode 100644 index 0000000..672c7e4 --- /dev/null +++ b/templates/401.html @@ -0,0 +1,2 @@ +rm: cannot remove '{{ request.path.split("/")[1] }}': Permission denied + diff --git a/templates/index.html b/templates/index.html index 6f84f59..f315daf 100644 --- a/templates/index.html +++ b/templates/index.html @@ -20,6 +20,11 @@ OR by setting "expires" to a timestamp in epoch milliseconds Expired files won't be removed immediately, but will be removed as part of the next purge. +Whenever a file that does not already exist or has expired is uploaded, +the HTTP response header includes an X-Token field. You can use this +to delete the file immediately: + curl -Ftoken=token_here -Fdelete= {{ fhost_url }}/abc.txt + {% set max_size = config["MAX_CONTENT_LENGTH"]|filesizeformat(True) %} Maximum file size: {{ max_size }} Not allowed: {{ config["FHOST_MIME_BLACKLIST"]|join(", ") }}