pretty big rework

- thrown out the multi-domain support, instead you can now specify the full base url in the config
- improved config docs
- changed authorization to follow the standard Bearer Token pattern
- link response is now simple json
- updated and modernized nginx config example
- reworked config and argument parsing
- removed unnecessary extra file write buffer layer
- removed no-cache response header. (why did I do this?)
- various minor improvements
- clarified hash length limit to be 32 (16 bytes, one aes block)
This commit is contained in:
BluBb_mADe 2023-12-12 13:20:44 +01:00
parent ef3d829dba
commit 6faac9742b
Signed by untrusted user: Gardener
GPG Key ID: 1FAEB4540A5B4D1D
3 changed files with 147 additions and 96 deletions

View File

@ -1,28 +1,29 @@
# if this is false, everyone can upload stuff.
# Enable upload authorization
auth: True
# if auth is true, only people that provide one of the tokens in the http headers are allowed to upload.
# When auth is true, only clients providing one of the valid Bearer token in the HTTP Authorization header are permitted to upload.
# The token must be included in the HTTP headers using the standard format:
# "Authorization: Bearer <token>"
tokens:
- 'example token'
- 'example_token'
# everyone who has it can delete files based on the download link.
# To make deletion links work without a token while not having to maintain a database of deletion codes
# the script encrypts deletion links from file links using this key.
del_crypt_key: 'secret delete link key'
# in bytes
# Maximum file size in bytes (100MB in this example)
max_filesize: 1024 ** 2 * 100
# uploaded files will be stored here
# Directory where uploaded files will be stored
data_path: 'data'
# this can only be a multiple of 2
# URL hash length must be a multiple of 2 and can not exceed 32
# Uploads will start failing if the script can not find a free url hash with 100 random generated ones
url_hash_len: 6
# just affects the printed links (e.g. for reverse proxies)
protocol: 'https'
# Base URL for routing to this service. The trailing slash is optional.
base_url: 'https://your-domain.net/f/'
# uri that routes to this
prefix: '/f'
# whether or not extensions are appended in the generated links.
# links without file extensions still work. (just like push)
show_ext: True
# Should file extensions be appended to the generated links.
# Links always work with or without file extensions (like puush.me).
show_ext: True

View File

@ -1,15 +1,22 @@
upstream filehoster {
# the hostname/ip and port on which the sharex_server script is reachable
server file_hoster:80;
}
server {
listen 443 ssl http2;
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
server_name your-domain.net;
# ShareX_Storage prefix is /f
# Include the location in your ShareX_Storage base_url
location /f/ {
# Should match ShareX_Storage max_filesize
client_max_body_size 100M;
proxy_pass http://filehoster$uri/$server_name;
proxy_pass http://filehoster;
}
}

View File

@ -1,78 +1,114 @@
import os
import io
import sys
import yaml
import string
import hashlib
import argparse
from collections import defaultdict
from urllib.parse import urlparse
from Cryptodome.Cipher import AES
from Cryptodome.Util import Padding
from aiohttp import web, hdrs
class AttrDict(dict):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__dict__ = self
class AppConfig:
_instance = None
def update(self, *d, **kwargs):
for key, val in (d[0] if d else kwargs).items():
setattr(self, key, val)
def __new__(cls, *args, **kwargs):
if not cls._instance:
cls._instance = super(AppConfig, cls).__new__(cls)
return cls._instance
def __getattr__(self, item):
return self.setdefault(item, AttrDict())
def __init__(self, config_file):
if not hasattr(self, 'is_loaded'):
self.load_config(config_file)
self.is_loaded = True
def load_config(self, config_file):
with open(config_file) as f:
data = yaml.safe_load(f)
self.url_hash_len = data.get('url_hash_len', 8)
self.data_path = data.get('data_path', '/data')
self.auth = data.get('auth', True)
self.tokens = set(data.get('tokens', []))
self.del_crypt_key = data.get('del_crypt_key', 'default_key')
self.show_ext = data.get('show_ext', True)
self.max_filesize = data.get('max_filesize', '1024 ** 2 * 100') # Default 100 MB
self.base_url = data.get('base_url', 'http://localhost/f/')
self.validate_config()
def validate_config(self):
if self.url_hash_len > 32:
raise ValueError('url_hash_len cannot be greater than 32')
if self.url_hash_len % 2 != 0:
raise ValueError('url_hash_len must be a multiple of 2')
self.max_filesize = self.evaluate_filesize(self.max_filesize)
self.del_crypt_key = hashlib.md5(self.del_crypt_key.encode()).digest()[:16]
if not os.path.isdir(self.data_path):
os.mkdir(self.data_path)
self.base_url = f"{self.base_url.strip("/ \t\r\n")}"
@staticmethod
def from_dict_recur(d):
if not isinstance(d, AttrDict):
d = AttrDict(d)
for k, v in dict(d.items()).items():
if " " in k:
del d[k]
d[k.replace(" ", "_")] = v
if isinstance(v, dict):
d[k] = AttrDict.from_dict_recur(v)
return d
def evaluate_filesize(size_str):
valid_chars = set(string.hexdigits + '* ')
if not set(size_str).issubset(valid_chars):
raise ValueError('Invalid characters in max_filesize')
try:
return eval(size_str)
except Exception:
raise ValueError('Invalid format for max_filesize')
def sizeof_fmt(num, suffix='B'):
for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']:
if abs(num) < 1024.0:
if abs(num) < 1024.:
return f"{num:3.1f}{unit}{suffix}"
num /= 1024.0
num /= 1024.
return f"{num:.1f}Yi{suffix}"
async def prepare(_, handler):
async def prepare_handler(req):
if 'acc' not in req.match_info:
return web.Response(text='bad request', status=400)
return await handler(req, req.match_info["acc"], file_db[req.match_info["acc"]])
return prepare_handler
async def handle_upload(req):
if conf.auth:
auth_header = req.headers.get(hdrs.AUTHORIZATION, None)
if auth_header is None:
return web.Response(text='Authentication required', status=401)
try:
scheme, token = auth_header.split(' ')
if scheme.lower() != 'bearer':
raise ValueError
except ValueError:
return web.Response(text='Invalid authentication scheme', status=401)
if token not in conf.tokens:
return web.Response(text='Access denied', status=403)
async def handle_upload(req, acc, acc_db):
if conf.auth and req.headers.get('auth') not in conf.auth_tokens:
return web.Response(text='access denied', status=403)
reader = await req.multipart()
file = await reader.next()
filename = os.path.basename(file.filename)
if not os.path.isdir(f'{conf.data_path}/{acc}'):
os.mkdir(f'{conf.data_path}/{acc}')
if not os.path.isdir(f'{conf.data_path}'):
os.mkdir(f'{conf.data_path}')
for _ in range(100):
hb = os.urandom(conf.url_hash_len//2)
h = hb.hex()
if h not in acc_db:
if h not in file_db:
break
else:
return web.Response(text='url key-space full', status=500)
acc_db[h] = filename
local_fname = f'{conf.data_path}/{acc}/{h}_{filename}'
file_db[h] = filename
local_fname = f'{conf.data_path}/{h}_{filename}'
ext = os.path.splitext(filename)[1] if conf.show_ext else ''
os.fdopen(os.open(local_fname, os.O_WRONLY | os.O_CREAT, 0o600)).close()
try:
@ -83,16 +119,17 @@ async def handle_upload(req, acc, acc_db):
c = AES.new(conf.del_crypt_key, AES.MODE_CBC)
hb = Padding.pad(hb, AES.block_size)
del_h = (c.encrypt(hb) + c.iv).hex()
return web.Response(text=f'{conf.protocol}://{acc}{conf.prefix}/{h[:conf.url_hash_len]}{ext}\n'
f'{conf.protocol}://{acc}{conf.prefix}/del/{del_h}')
return web.Response(text=f'{{"file_link":"{conf.base_url}/{h}{ext}",'
f'"delete_link":"{conf.base_url}/del/{del_h}"}}', status=200)
os.unlink(local_fname)
del acc_db[h]
del file_db[h]
return web.Response(text=f'file is bigger than {sizeof_fmt(conf.max_filesize)}', status=413)
async def recv_file(file, local_fname):
size = 0
with io.BufferedWriter(open(local_fname, 'wb')) as f:
with open(local_fname, 'wb') as f:
while True:
chunk = await file.read_chunk()
if not chunk:
@ -103,9 +140,9 @@ async def recv_file(file, local_fname):
f.write(chunk)
async def handle_delete(req, acc, acc_db):
async def handle_delete(req):
chashiv = req.match_info.get('hash', 'x')
if not set(chashiv).issubset(valid_hash_chars) or len(chashiv) != 64:
if len(chashiv) != 64 or not set(chashiv).issubset(hexdigits_set):
return web.Response(text='invalid delete link', status=400)
chashiv = bytes.fromhex(chashiv)
@ -115,58 +152,64 @@ async def handle_delete(req, acc, acc_db):
fhash = Padding.unpad(fhash, AES.block_size).hex()
except ValueError:
pass
if fhash not in acc_db or len(fhash) == 32:
if fhash not in file_db:
return web.Response(text='this file doesn\'t exist on the server', status=404)
os.unlink(f"{conf.data_path}/{acc}/{fhash}_{acc_db[fhash]}")
del acc_db[fhash]
os.unlink(f"{conf.data_path}/{fhash}_{file_db[fhash]}")
del file_db[fhash]
return web.Response(text='file deleted')
async def handle_download(req, acc, acc_db):
async def handle_download(req):
fhash = req.match_info.get('hash', '').split('.', 1)[0]
if fhash not in acc_db:
if fhash not in file_db:
return web.Response(text='file not found', status=404)
return web.FileResponse(f"{conf.data_path}/{acc}/{fhash}_{acc_db[fhash]}", headers={
hdrs.CACHE_CONTROL: "no-cache",
hdrs.CONTENT_DISPOSITION: f'inline;filename="{acc_db[fhash]}"'
return web.FileResponse(f"{conf.data_path}/{fhash}_{file_db[fhash]}", headers={
hdrs.CONTENT_DISPOSITION: f'inline;filename="{file_db[fhash]}"'
})
def main():
if conf.url_hash_len > 31:
raise ValueError('url_hash_len can\'t be bigger than 31')
if not set(conf.max_filesize.replace(' ', ''))\
.issubset(valid_hash_chars | {'*'}):
raise ValueError('max_filesize only can contain numbers and *')
conf.max_filesize = eval(conf.max_filesize)
conf.auth_tokens = set(conf.tokens)
conf.prefix = conf.prefix.strip("/")
if conf.prefix:
conf.prefix = f'/{conf.prefix}'
conf.del_crypt_key = hashlib.md5(conf.del_crypt_key.encode()).digest()[:16]
if not os.path.isdir(conf.data_path):
os.mkdir(conf.data_path)
for acc in os.listdir(conf.data_path):
if not os.path.isdir(f'{conf.data_path}/{acc}'):
for file in os.listdir(f"{conf.data_path}"):
try:
fhash, fname = file.split('_', 1)
except ValueError:
print(f"file \"{file}\" has an invalid file name format, skipping...")
continue
for file in os.listdir(f"{conf.data_path}/{acc}"):
if "_" in file:
fhash, fname = file.split('_', 1)
file_db[acc][fhash] = fname
file_db[fhash] = fname
app = web.Application(middlewares=[prepare])
app.router.add_post(conf.prefix + '/post/{acc}', handle_upload)
app.router.add_get(conf.prefix + '/del/{hash}/{acc}', handle_delete)
app.router.add_get(conf.prefix + '/{hash}/{acc}', handle_download)
parsed_url = urlparse(conf.base_url)
base_path = parsed_url.path
app = web.Application()
app.router.add_post(base_path + '/post', handle_upload)
app.router.add_get(base_path + '/del/{hash}', handle_delete)
app.router.add_get(base_path + '/{hash}', handle_download)
web.run_app(app, port=80)
def parse_args():
parser = argparse.ArgumentParser(description="File serving and uploading server intended for use as a ShareX host.")
parser.add_argument('-c', '--config', default=None,
help='Path to the configuration file.')
parser.add_argument('config_file', nargs='?', default='config.yaml',
help='Path to the configuration file (positional argument).')
args = parser.parse_args()
if args.config and args.config_file != 'config.yaml':
print("Warning: Both positional and optional config arguments provided. Using the -c argument.")
return args.config
return args.config or args.config_file
if __name__ == '__main__':
valid_hash_chars = set(string.hexdigits)
hexdigits_set = set(string.hexdigits)
file_db = defaultdict(dict)
confname = sys.argv[1] if sys.argv[1:] and os.path.isfile(sys.argv[1]) else 'config.yaml'
with open(confname) as cf:
conf = AttrDict.from_dict_recur(yaml.safe_load(cf))
conf_name = parse_args()
print("Loading config file", conf_name)
conf = AppConfig(conf_name)
main()