Allow management operations like deleting files

This introduces the X-Token header field in the response of newly
uploaded files as a simple way for users to manage their own files.

It does not need to be particularly secure.
This commit is contained in:
Mia Herkt 2022-11-30 01:42:49 +01:00
parent eb0b1d2f69
commit a182b6199b
No known key found for this signature in database
GPG key ID: 72E154B8622EC191
4 changed files with 84 additions and 9 deletions

View file

@ -34,6 +34,7 @@ import sys
import time import time
import typing import typing
import requests import requests
import secrets
from validators import url as url_valid from validators import url as url_valid
from pathlib import Path from pathlib import Path
@ -127,13 +128,15 @@ class File(db.Model):
removed = db.Column(db.Boolean, default=False) removed = db.Column(db.Boolean, default=False)
nsfw_score = db.Column(db.Float) nsfw_score = db.Column(db.Float)
expiration = db.Column(db.BigInteger) 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.sha256 = sha256
self.ext = ext self.ext = ext
self.mime = mime self.mime = mime
self.addr = addr self.addr = addr
self.expiration = expiration self.expiration = expiration
self.mgmt_token = mgmt_token
def getname(self): def getname(self):
return u"{0}{1}".format(su.enbase(self.id), self.ext) return u"{0}{1}".format(su.enbase(self.id), self.ext)
@ -146,6 +149,15 @@ class File(db.Model):
else: else:
return url_for("get", path=n, _external=True) + "\n" 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: requested_expiration can be:
- None, to use the longest allowed file lifespan - None, to use the longest allowed file lifespan
@ -218,6 +230,7 @@ class File(db.Model):
else: else:
# Treat the requested expiration time as a timestamp in epoch millis # Treat the requested expiration time as a timestamp in epoch millis
return min(this_files_max_expiration, requested_expiration); return min(this_files_max_expiration, requested_expiration);
isnew = True
f = File.query.filter_by(sha256=digest).first() f = File.query.filter_by(sha256=digest).first()
if f: if f:
@ -228,14 +241,19 @@ class File(db.Model):
if f.expiration is None: if f.expiration is None:
# The file has expired, so give it a new expiration date # The file has expired, so give it a new expiration date
f.expiration = get_expiration() f.expiration = get_expiration()
# Also generate a new management token
f.mgmt_token = secrets.token_urlsafe()
else: else:
# The file already exists, update the expiration if needed # The file already exists, update the expiration if needed
f.expiration = max(f.expiration, get_expiration()) f.expiration = max(f.expiration, get_expiration())
isnew = False
else: else:
mime = get_mime() mime = get_mime()
ext = get_ext(mime) ext = get_ext(mime)
expiration = get_expiration() 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 f.addr = addr
@ -252,8 +270,7 @@ class File(db.Model):
db.session.add(f) db.session.add(f)
db.session.commit() db.session.commit()
return f return f, isnew
class UrlEncoder(object): class UrlEncoder(object):
@ -323,9 +340,14 @@ def store_file(f, requested_expiration: typing.Optional[int], addr):
if in_upload_bl(addr): if in_upload_bl(addr):
return "Your host is blocked from uploading files.\n", 451 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): def store_url(url, addr):
if is_fhost_url(url): if is_fhost_url(url):
@ -354,7 +376,20 @@ def store_url(url, addr):
else: else:
abort(411) abort(411)
@app.route("/<path:path>") 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("/<path:path>", methods=["GET", "POST"])
def get(path): def get(path):
path = Path(path.split("/", 1)[0]) path = Path(path.split("/", 1)[0])
sufs = "".join(path.suffixes[-2:]) sufs = "".join(path.suffixes[-2:])
@ -368,11 +403,14 @@ def get(path):
if f.removed: if f.removed:
abort(451) abort(451)
fpath = Path(app.config["FHOST_STORAGE_PATH"]) / f.sha256 fpath = f.getpath()
if not fpath.is_file(): if not fpath.is_file():
abort(404) abort(404)
if request.method == "POST":
return manage_file(f)
if app.config["FHOST_USE_X_ACCEL_REDIRECT"]: if app.config["FHOST_USE_X_ACCEL_REDIRECT"]:
response = make_response() response = make_response()
response.headers["Content-Type"] = f.mime response.headers["Content-Type"] = f.mime
@ -382,6 +420,9 @@ def get(path):
else: else:
return send_from_directory(app.config["FHOST_STORAGE_PATH"], f.sha256, mimetype = f.mime) return send_from_directory(app.config["FHOST_STORAGE_PATH"], f.sha256, mimetype = f.mime)
else: else:
if request.method == "POST":
abort(405)
u = URL.query.get(id) u = URL.query.get(id)
if u: if u:
@ -428,6 +469,7 @@ Disallow: /
""" """
@app.errorhandler(400) @app.errorhandler(400)
@app.errorhandler(401)
@app.errorhandler(404) @app.errorhandler(404)
@app.errorhandler(411) @app.errorhandler(411)
@app.errorhandler(413) @app.errorhandler(413)
@ -436,7 +478,7 @@ Disallow: /
@app.errorhandler(451) @app.errorhandler(451)
def ehandler(e): def ehandler(e):
try: 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: except TemplateNotFound:
return "Segmentation fault\n", e.code return "Segmentation fault\n", e.code

View file

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

2
templates/401.html Normal file
View file

@ -0,0 +1,2 @@
rm: cannot remove '{{ request.path.split("/")[1] }}': Permission denied

View file

@ -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 Expired files won't be removed immediately, but will be removed as part of
the next purge. 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) %} {% set max_size = config["MAX_CONTENT_LENGTH"]|filesizeformat(True) %}
Maximum file size: {{ max_size }} Maximum file size: {{ max_size }}
Not allowed: {{ config["FHOST_MIME_BLACKLIST"]|join(", ") }} Not allowed: {{ config["FHOST_MIME_BLACKLIST"]|join(", ") }}