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:
parent
eb0b1d2f69
commit
a182b6199b
4 changed files with 84 additions and 9 deletions
60
fhost.py
60
fhost.py
|
@ -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
|
||||||
|
|
||||||
|
|
26
migrations/versions/0659d7b9eea8_.py
Normal file
26
migrations/versions/0659d7b9eea8_.py
Normal 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
2
templates/401.html
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
rm: cannot remove '{{ request.path.split("/")[1] }}': Permission denied
|
||||||
|
|
|
@ -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(", ") }}
|
||||||
|
|
Loading…
Reference in a new issue