From 0b80a62f80ebe6b5ad6e9db99036020b8d7d0cea Mon Sep 17 00:00:00 2001 From: Mia Herkt Date: Thu, 1 Dec 2022 02:49:28 +0100 Subject: [PATCH] =?UTF-8?q?Add=20support=20for=20=E2=80=9Csecret=E2=80=9D?= =?UTF-8?q?=20file=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #47 --- fhost.py | 48 ++++++++++++++++++++-------- instance/config.example.py | 7 ++++ migrations/versions/e2e816056589_.py | 26 +++++++++++++++ templates/index.html | 3 ++ 4 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 migrations/versions/e2e816056589_.py diff --git a/fhost.py b/fhost.py index 8770010..6f11db7 100755 --- a/fhost.py +++ b/fhost.py @@ -48,6 +48,7 @@ app.config.update( FHOST_USE_X_ACCEL_REDIRECT = True, # expect nginx by default FHOST_STORAGE_PATH = "up", FHOST_MAX_EXT_LENGTH = 9, + FHOST_SECRET_BYTES = 16, FHOST_EXT_OVERRIDE = { "audio/flac" : ".flac", "image/gif" : ".gif", @@ -129,6 +130,7 @@ class File(db.Model): nsfw_score = db.Column(db.Float) expiration = db.Column(db.BigInteger) mgmt_token = db.Column(db.String) + secret = db.Column(db.String) def __init__(self, sha256, ext, mime, addr, expiration, mgmt_token): self.sha256 = sha256 @@ -145,9 +147,9 @@ class File(db.Model): n = self.getname() if self.nsfw_score and self.nsfw_score > app.config["NSFW_THRESHOLD"]: - return url_for("get", path=n, _external=True, _anchor="nsfw") + "\n" + return url_for("get", path=n, secret=self.secret, _external=True, _anchor="nsfw") + "\n" else: - return url_for("get", path=n, _external=True) + "\n" + return url_for("get", path=n, secret=self.secret, _external=True) + "\n" def getpath(self) -> Path: return Path(app.config["FHOST_STORAGE_PATH"]) / self.sha256 @@ -195,7 +197,7 @@ class File(db.Model): Any value greater that the longest allowed file lifespan will be rounded down to that value. """ - def store(file_, requested_expiration: typing.Optional[int], addr): + def store(file_, requested_expiration: typing.Optional[int], addr, secret: bool): data = file_.read() digest = sha256(data).hexdigest() @@ -260,6 +262,11 @@ class File(db.Model): f.addr = addr + if isnew: + f.secret = None + if secret: + f.secret = secrets.token_urlsafe(app.config["FHOST_SECRET_BYTES"]) + storage = Path(app.config["FHOST_STORAGE_PATH"]) storage.mkdir(parents=True, exist_ok=True) p = storage / digest @@ -339,11 +346,11 @@ requested_expiration can be: Any value greater that the longest allowed file lifespan will be rounded down to that value. """ -def store_file(f, requested_expiration: typing.Optional[int], addr): +def store_file(f, requested_expiration: typing.Optional[int], addr, secret: bool): if in_upload_bl(addr): return "Your host is blocked from uploading files.\n", 451 - sf, isnew = File.store(f, requested_expiration, addr) + sf, isnew = File.store(f, requested_expiration, addr, secret) response = make_response(sf.geturl()) response.headers["X-Expires"] = sf.expiration @@ -353,7 +360,7 @@ def store_file(f, requested_expiration: typing.Optional[int], addr): return response -def store_url(url, addr): +def store_url(url, addr, secret: bool): if is_fhost_url(url): abort(400) @@ -374,7 +381,7 @@ def store_url(url, addr): f = urlfile(read=r.raw.read, content_type=r.headers["content-type"], filename="") - return store_file(f, None, addr) + return store_file(f, None, addr, secret) else: abort(413) else: @@ -404,10 +411,11 @@ def manage_file(f): abort(400) @app.route("/", methods=["GET", "POST"]) -def get(path): - path = Path(path.split("/", 1)[0]) - sufs = "".join(path.suffixes[-2:]) - name = path.name[:-len(sufs) or None] +@app.route("/s//", methods=["GET", "POST"]) +def get(path, secret=None): + p = Path(path.split("/", 1)[0]) + sufs = "".join(p.suffixes[-2:]) + name = p.name[:-len(sufs) or None] if "." in name: abort(404) @@ -416,6 +424,8 @@ def get(path): if sufs: f = File.query.get(id) + if f.secret != secret: + abort(404) if f and f.ext == sufs: if f.removed: @@ -443,6 +453,9 @@ def get(path): if request.method == "POST": abort(405) + if "/" in path: + abort(404) + u = URL.query.get(id) if u: @@ -454,6 +467,7 @@ def get(path): def fhost(): if request.method == "POST": sf = None + secret = "secret" in request.form if "file" in request.files: try: @@ -461,7 +475,8 @@ def fhost(): return store_file( request.files["file"], int(request.form["expires"]), - request.remote_addr + request.remote_addr, + secret ) except ValueError: # The requested expiration date wasn't properly formed @@ -471,10 +486,15 @@ def fhost(): return store_file( request.files["file"], None, - request.remote_addr + request.remote_addr, + secret ) elif "url" in request.form: - return store_url(request.form["url"], request.remote_addr) + return store_url( + request.form["url"], + request.remote_addr, + secret + ) elif "shorten" in request.form: return shorten(request.form["shorten"]) diff --git a/instance/config.example.py b/instance/config.example.py index 019ec11..2315b75 100644 --- a/instance/config.example.py +++ b/instance/config.example.py @@ -94,6 +94,13 @@ FHOST_STORAGE_PATH = "up" FHOST_MAX_EXT_LENGTH = 9 +# The number of bytes used for "secret" URLs +# +# When a user uploads a file with the "secret" option, 0x0 generates a string +# from this many bytes of random data. It is base64-encoded, so on average +# each byte results in approximately 1.3 characters. +FHOST_SECRET_BYTES = 16 + # A list of filetypes to use when the uploader doesn't specify one # # When a user uploads a file with no file extension, we try to find an extension that diff --git a/migrations/versions/e2e816056589_.py b/migrations/versions/e2e816056589_.py new file mode 100644 index 0000000..7c31ba9 --- /dev/null +++ b/migrations/versions/e2e816056589_.py @@ -0,0 +1,26 @@ +"""add URL secret + +Revision ID: e2e816056589 +Revises: 0659d7b9eea8 +Create Date: 2022-12-01 02:16:15.976864 + +""" + +# revision identifiers, used by Alembic. +revision = 'e2e816056589' +down_revision = '0659d7b9eea8' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('file', sa.Column('secret', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('file', 'secret') + # ### end Alembic commands ### diff --git a/templates/index.html b/templates/index.html index d98482c..f36fb54 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,6 +6,9 @@ HTTP POST files here: curl -F'file=@yourfile.png' {{ fhost_url }} You can also POST remote URLs: curl -F'url=http://example.com/image.jpg' {{ fhost_url }} +If you don't want the resulting URL to be easy to guess: + curl -F'file=@yourfile.png' -Fsecret= {{ fhost_url }} + curl -F'url=http://example.com/image.jpg' -Fsecret= {{ fhost_url }} Or you can shorten URLs: curl -F'shorten=http://example.com/some/long/url' {{ fhost_url }}