Add support for “secret” file URLs

Closes #47
This commit is contained in:
Mia Herkt 2022-12-01 02:49:28 +01:00
parent ed84d3752c
commit 0b80a62f80
Signed by: mia
GPG Key ID: 72E154B8622EC191
4 changed files with 70 additions and 14 deletions

View File

@ -48,6 +48,7 @@ app.config.update(
FHOST_USE_X_ACCEL_REDIRECT = True, # expect nginx by default FHOST_USE_X_ACCEL_REDIRECT = True, # expect nginx by default
FHOST_STORAGE_PATH = "up", FHOST_STORAGE_PATH = "up",
FHOST_MAX_EXT_LENGTH = 9, FHOST_MAX_EXT_LENGTH = 9,
FHOST_SECRET_BYTES = 16,
FHOST_EXT_OVERRIDE = { FHOST_EXT_OVERRIDE = {
"audio/flac" : ".flac", "audio/flac" : ".flac",
"image/gif" : ".gif", "image/gif" : ".gif",
@ -129,6 +130,7 @@ class File(db.Model):
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) mgmt_token = db.Column(db.String)
secret = db.Column(db.String)
def __init__(self, sha256, ext, mime, addr, expiration, mgmt_token): def __init__(self, sha256, ext, mime, addr, expiration, mgmt_token):
self.sha256 = sha256 self.sha256 = sha256
@ -145,9 +147,9 @@ class File(db.Model):
n = self.getname() n = self.getname()
if self.nsfw_score and self.nsfw_score > app.config["NSFW_THRESHOLD"]: 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: 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: def getpath(self) -> Path:
return Path(app.config["FHOST_STORAGE_PATH"]) / self.sha256 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 Any value greater that the longest allowed file lifespan will be rounded down to that
value. value.
""" """
def store(file_, requested_expiration: typing.Optional[int], addr): def store(file_, requested_expiration: typing.Optional[int], addr, secret: bool):
data = file_.read() data = file_.read()
digest = sha256(data).hexdigest() digest = sha256(data).hexdigest()
@ -260,6 +262,11 @@ class File(db.Model):
f.addr = addr 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 = Path(app.config["FHOST_STORAGE_PATH"])
storage.mkdir(parents=True, exist_ok=True) storage.mkdir(parents=True, exist_ok=True)
p = storage / digest 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 Any value greater that the longest allowed file lifespan will be rounded down to that
value. 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): 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, isnew = File.store(f, requested_expiration, addr) sf, isnew = File.store(f, requested_expiration, addr, secret)
response = make_response(sf.geturl()) response = make_response(sf.geturl())
response.headers["X-Expires"] = sf.expiration response.headers["X-Expires"] = sf.expiration
@ -353,7 +360,7 @@ def store_file(f, requested_expiration: typing.Optional[int], addr):
return response return response
def store_url(url, addr): def store_url(url, addr, secret: bool):
if is_fhost_url(url): if is_fhost_url(url):
abort(400) abort(400)
@ -374,7 +381,7 @@ def store_url(url, addr):
f = urlfile(read=r.raw.read, content_type=r.headers["content-type"], filename="") 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: else:
abort(413) abort(413)
else: else:
@ -404,10 +411,11 @@ def manage_file(f):
abort(400) abort(400)
@app.route("/<path:path>", methods=["GET", "POST"]) @app.route("/<path:path>", methods=["GET", "POST"])
def get(path): @app.route("/s/<secret>/<path:path>", methods=["GET", "POST"])
path = Path(path.split("/", 1)[0]) def get(path, secret=None):
sufs = "".join(path.suffixes[-2:]) p = Path(path.split("/", 1)[0])
name = path.name[:-len(sufs) or None] sufs = "".join(p.suffixes[-2:])
name = p.name[:-len(sufs) or None]
if "." in name: if "." in name:
abort(404) abort(404)
@ -416,6 +424,8 @@ def get(path):
if sufs: if sufs:
f = File.query.get(id) f = File.query.get(id)
if f.secret != secret:
abort(404)
if f and f.ext == sufs: if f and f.ext == sufs:
if f.removed: if f.removed:
@ -443,6 +453,9 @@ def get(path):
if request.method == "POST": if request.method == "POST":
abort(405) abort(405)
if "/" in path:
abort(404)
u = URL.query.get(id) u = URL.query.get(id)
if u: if u:
@ -454,6 +467,7 @@ def get(path):
def fhost(): def fhost():
if request.method == "POST": if request.method == "POST":
sf = None sf = None
secret = "secret" in request.form
if "file" in request.files: if "file" in request.files:
try: try:
@ -461,7 +475,8 @@ def fhost():
return store_file( return store_file(
request.files["file"], request.files["file"],
int(request.form["expires"]), int(request.form["expires"]),
request.remote_addr request.remote_addr,
secret
) )
except ValueError: except ValueError:
# The requested expiration date wasn't properly formed # The requested expiration date wasn't properly formed
@ -471,10 +486,15 @@ def fhost():
return store_file( return store_file(
request.files["file"], request.files["file"],
None, None,
request.remote_addr request.remote_addr,
secret
) )
elif "url" in request.form: 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: elif "shorten" in request.form:
return shorten(request.form["shorten"]) return shorten(request.form["shorten"])

View File

@ -94,6 +94,13 @@ FHOST_STORAGE_PATH = "up"
FHOST_MAX_EXT_LENGTH = 9 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 # 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 # When a user uploads a file with no file extension, we try to find an extension that

View File

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

View File

@ -6,6 +6,9 @@ HTTP POST files here:
curl -F'file=@yourfile.png' {{ fhost_url }} curl -F'file=@yourfile.png' {{ fhost_url }}
You can also POST remote URLs: You can also POST remote URLs:
curl -F'url=http://example.com/image.jpg' {{ fhost_url }} 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: Or you can shorten URLs:
curl -F'shorten=http://example.com/some/long/url' {{ fhost_url }} curl -F'shorten=http://example.com/some/long/url' {{ fhost_url }}