This commit is contained in:
Q
2023-08-19 11:35:11 +03:00
commit aa6a1d2373
16 changed files with 797 additions and 0 deletions

3
code/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
*.pyc
__pycache__
*.swp

35
code/Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update -yqq \
&& apt-get install -y --no-install-recommends \
curl \
sqlite3 \
tzdata \
git \
make \
python3-venv \
python3-pip \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
ARG UID
ARG GID
ARG TZ
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
RUN groupadd -g $GID user && \
useradd -u $UID -g $GID -ms /bin/bash user && \
mkdir -p /opt/venv && chown $UID:$GID /opt/venv
COPY ./requirements.txt /requirements.txt
COPY docker-builder.sh /
USER user
RUN bash /docker-builder.sh
COPY ./ /app
USER root
RUN chown -R $UID:$GID /app
USER user
WORKDIR /app
CMD bash /app/entrypoint.sh

163
code/app.py Normal file
View File

@@ -0,0 +1,163 @@
# -*- coding: utf-8 -*-
import os, sys, time
import json
from datetime import datetime
from flask import (
Flask,
render_template,
jsonify,
current_app,
Response,
redirect,
url_for,
request,
g,
session,
send_file,
send_from_directory,
abort,
)
from werkzeug.utils import secure_filename
from revprox import ReverseProxied
from utils import (
random_token,
db_store_file,
file_details,
file_list,
db_add_download,
db_get_file,
db_delete_file,
)
__MINI_FLEES_VERSION__ = "20230818.0"
app = Flask(__name__)
app.config.from_object(__name__)
app.config.from_prefixed_env()
app.debug = True
app.secret_key = app.config["APP_SECRET_KEY"]
app.wsgi_app = ReverseProxied(app.wsgi_app)
@app.route("/")
def index():
return "", 200
@app.route("/upload", methods=["POST"])
def upload():
if request.method == "POST":
file = request.files.get("file")
name = request.headers.get("Name", None)
if name is None:
return "Name required", 500
secret = request.headers.get("Secret", "")
if secret != app.config["ACCESS_TOKEN"]:
return "Error", 401
max_dl = request.headers.get("Max-Downloads", app.config["DEFAULT_MAX_DL"])
expires = int(time.time()) + int(app.config["DEFAULT_EXPIRE"])
if "Expires-days" in request.headers:
expires = int(time.time()) + 24 * 3600 * int(
request.headers.get("Expires-days")
)
if "Expires-hours" in request.headers:
expires = int(time.time()) + 3600 * int(
request.headers.get("Expires-hours")
)
if file:
safe_filename = secure_filename(name)
token = random_token()
folder = os.path.join(app.config["DATAFOLDER"], token)
os.mkdir(folder)
filename = os.path.join(folder, safe_filename)
file.save(filename)
db_store_file(token, safe_filename, expires, max_dl)
download_url = f"{app.config['PUBLIC_URL']}/dl/{token}/{safe_filename}"
return "File uploaded\n%s\n" % (download_url,), 200
else:
return "Use the 'file' variable to upload\n", 400
@app.route("/details/<token>/<name>", methods=["GET"])
def details(token, name):
secret = request.headers.get("Secret", "")
if secret != app.config["ACCESS_TOKEN"]:
return "Error", 401
details = file_details(token, name)
return jsonify(details), 200
@app.route("/delete/<token>/<name>", methods=["GET"])
def delete_file(name, token):
secret = request.headers.get("Secret", "")
if secret != app.config["ACCESS_TOKEN"]:
return "Error", 401
try:
os.remove(os.path.join(os.getenv("DATAFOLDER"), token, name))
except Exception:
pass
db_delete_file(token, name)
return "OK", 200
@app.route("/ls", methods=["GET"])
def ls():
secret = request.headers.get("Secret", "")
if secret != app.config["ACCESS_TOKEN"]:
return "Error", 401
return "\n".join(file_list()), 200
@app.route("/dl/<token>/<name>", methods=["GET"])
def download(name, token):
return download_file(token, name)
@app.route("/script/client", methods=["GET"])
def script_client():
return render_template(
"client.py", name=name, token=token, rooturl=request.url_root
)
@app.route("/script/flip", methods=["GET"])
def script_flip():
return render_template(
"flip",
name=name,
token=token,
rooturl=request.url_root,
version=__FLEES_VERSION__,
)
def download_file(token, name):
full_path = os.path.join(os.getenv("FLASK_DATAFOLDER"), token, name)
if not os.path.exists(full_path):
return "Error", 404
db_stat = db_get_file(token, name)
if db_stat:
added, expires, downloads, max_dl = db_stat
else:
return "Error", 404
if downloads >= max_dl:
return "Expired", 401
if expires < time.time():
return "Expired", 401
db_add_download(token, name)
return send_from_directory(
directory=os.path.join(os.getenv("FLASK_DATAFOLDER"), token), path=name
)
def print_debug(s):
if app.config["DEBUG"]:
sys.stderr.write(str(s) + "\n")
sys.stderr.flush()
if __name__ == "__main__":
app.run(debug=True)

7
code/docker-builder.sh Normal file
View File

@@ -0,0 +1,7 @@
#!/bin/sh
set -eux
python3 -m venv /opt/venv
. /opt/venv/bin/activate
pip3 install -r requirements.txt

22
code/entrypoint.sh Normal file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
PYTHON=python3
SQLITE=sqlite3
export FLASK_DATAFOLDER="/data"
export FLASK_DB="/data/flees.db"
export FLASK_CONF="/data/config.json"
export SERVER=gunicorn
export PID="flees.pid"
export WORKERS
if [[ $( stat -c %u /data ) -ne $( id -u ) ]]; then
echo User id and /data folder owner do not match
printf 'UID: %s\nFolder: %s\n' $( id -u ) $( stat -c %u /data )
exit 1
fi
set -eu
. /opt/venv/bin/activate
sh ./init_db.sh "$FLASK_DB"
exec "$SERVER" -w $WORKERS 'app:app' --pid="$PID" -b 0.0.0.0:5000

13
code/init_db.sh Normal file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
cat <<'EOF' | sqlite3 "$1"
CREATE TABLE IF NOT EXISTS files (
token text PRIMARY KEY,
name text NOT NULL,
added integer NOT NULL,
expires integer NOT NULL,
downloads integer NOT NULL,
max_downloads integer NOT NULL
);
EOF

2
code/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
flask
gunicorn

33
code/revprox.py Normal file
View File

@@ -0,0 +1,33 @@
class ReverseProxied(object):
"""Wrap the application in this middleware and configure the
front-end server to add these headers, to let you quietly bind
this to a URL other than / and to an HTTP scheme that is
different than what is used locally.
In nginx:
location /myprefix {
proxy_pass http://192.168.0.1:5001;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Scheme $scheme;
proxy_set_header X-Script-Name /myprefix;
}
:param app: the WSGI application
"""
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
script_name = environ.get("HTTP_X_SCRIPT_NAME", "")
if script_name:
environ["SCRIPT_NAME"] = script_name
path_info = environ["PATH_INFO"]
if path_info.startswith(script_name):
environ["PATH_INFO"] = path_info[len(script_name) :]
scheme = environ.get("HTTP_X_SCHEME", "")
if scheme:
environ["wsgi.url_scheme"] = scheme
return self.app(environ, start_response)

3
code/utils/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .misc import *
from .files import *
from .crypt import *

5
code/utils/crypt.py Normal file
View File

@@ -0,0 +1,5 @@
import secrets
def random_token():
return secrets.token_urlsafe(8)

230
code/utils/files.py Normal file
View File

@@ -0,0 +1,230 @@
import os
from datetime import datetime
from flask import current_app as app
import re
import sys
import json
import stat
import time
import sqlite3
from .misc import *
from .crypt import *
def get_db():
db = sqlite3.connect(os.getenv("FLASK_DB"), timeout=5)
c = db.cursor()
return db, c
def db_store_file(token, name, expires, max_dl):
db, c = get_db()
c.execute(
"""
insert into files(token,name,added,expires,downloads,max_downloads)
values (?,?,?,?,?,?)
""",
(token, name, int(time.time()), expires, 0, max_dl),
)
if c.rowcount > 0:
db.commit()
return
def db_get_file(token, name):
db, c = get_db()
return db.execute(
"""
SELECT added,expires,downloads,max_downloads
FROM files WHERE token = ? AND name = ?
""",
(token, name),
).fetchone()
def db_get_files():
db, c = get_db()
return db.execute(
"""
SELECT token,name,added,expires,downloads,max_downloads
FROM files
WHERE expires > ? and downloads < max_downloads
""",
(time.time(),),
)
def db_delete_file(token, name):
db, c = get_db()
c.execute(
"""
DELETE FROM files WHERE token = ? and name = ?
""",
(token, name),
)
if c.rowcount > 0:
db.commit()
def db_add_download(token, name):
db, c = get_db()
c.execute(
"""
UPDATE files SET downloads = downloads + 1 WHERE token = ? AND name = ?
""",
(token, name),
)
if c.rowcount > 0:
db.commit()
return
def file_autoremove():
db, c = get_db()
rows = db.execute(
"""
select
token, name
from files
where expires > ? or downloads >= max_downloads
""",
(int(time.time()),),
)
deleted_tokens = []
for row in rows:
try:
os.remove(os.path.join(os.getenv("DATAFOLDER"), row[0], row[1]))
except FileNotFoundError:
pass
try:
os.rmdir(os.path.join(os.getenv("DATAFOLDER"), row[0]))
except FileNotFoundError:
pass
deleted_tokens.append(row[0])
if len(deleted_tokens) > 0:
db, c = get_db()
for token in deleted_tokens:
c.execute(
"""
DELETE FROM files WHERE token = ?
""",
(token,),
)
db.commit()
return
def file_age(path):
now = datetime.now()
then = datetime.fromtimestamp(os.stat(path).st_mtime)
diff = now - then
return (
diff,
"%03d d %s"
% (diff.days, datetime.utcfromtimestamp(diff.seconds).strftime("%H:%M:%S")),
)
def file_details(token, name):
full_path = os.path.join(os.getenv("FLASK_DATAFOLDER"), token, name)
try:
s = os.stat(full_path)
db_stat = db_get_file(token, name)
if db_stat:
added, expires, downloads, max_dl = db_stat
else:
return {}
return {
"size": file_size_MB(s.st_size),
"hsize": file_size_human(s.st_size, HTML=False),
"added": file_date_human(added),
"name": name,
"url": f"{app.config['PUBLIC_URL']}/dl/{token}/{name}",
"expires": file_date_human(expires),
"downloaded": downloads,
"max-dl": max_dl,
}
except FileNotFoundError:
return {}
def file_list():
files = list(db_get_files())
details = []
maxlen = 4
for file in files:
maxlen = max(maxlen, len(file[1]))
details = []
details.append(
"Added/Expiry DL/MaxDL URL"
)
details.append("=" * 75)
for file in files:
details.append(
" ".join((
str(file[2]),str(file[3]),str(file[4]),str(file[5]),
f"{app.config['PUBLIC_URL']}/dl/{file[0]}/{file[1]}"
))
)
return details
def get_expiring_file(ehash):
connection = apsw.Connection(app.config["SQLITE_FILE"])
cursor = connection.cursor()
for row in cursor.execute(
"SELECT file, expires FROM expiring WHERE hash = ?", (ehash,)
):
return row[0], row[1]
return None, None
def get_script_url(public_url, share, end_point, token="[TOKEN]"):
cmd = None
doc = None
if get_or_none("direct_links", share) and end_point == "download":
end_point = "direct"
url = "%s/script/%s/%s/%s" % (public_url, end_point, share["name"], token)
if end_point in ("download", "direct"):
cmd = "curl -s %s | bash /dev/stdin [-f]" % (url,)
doc = "Download all files in the share. -f to force overwrite existing files."
if end_point == "client":
cmd = "python <( curl -s %s )" % (url,)
doc = "Console client to download and upload files."
if end_point == "upload_split":
cmd = (
"curl -s %s | python - [-s split_size_in_Mb] file_to_upload.ext [second.file.ext]"
% (url,)
)
doc = "Upload files to the share. -s to set splitting size."
if end_point == "flip":
cmd = "curl -s %s > flip && ./flip" % (url,)
doc = "Use the share as a command line clipboard"
return {"cmd": cmd, "doc": doc}
def set_expiring_file(share, filename, expires):
connection = apsw.Connection(app.config["SQLITE_FILE"])
cursor = connection.cursor()
while True:
ehash = random_expiring_hash()
matches = len(
list(cursor.execute("SELECT file FROM expiring WHERE hash = ?", (ehash,)))
)
if matches == 0:
break
cursor.execute(
"INSERT INTO expiring (hash, file, expires) VALUES (?,?,?)",
(ehash, filename, expires),
)
return "/".join((app.config["PUBLIC_URL"], "e", ehash, os.path.basename(filename)))
def remove_expiring_file(share, filename):
connection = apsw.Connection(app.config["SQLITE_FILE"])
cursor = connection.cursor()
cursor.execute("DELETE FROM expiring WHERE file = ?", (filename,))

62
code/utils/misc.py Normal file
View File

@@ -0,0 +1,62 @@
from datetime import datetime
from flask import current_app as app
def file_date_human(num):
return datetime.fromtimestamp(num).strftime("%y-%m-%d %H:%M")
def file_size_human(num, HTML=True):
space = "&nbsp;" if HTML else " "
for x in [space + "B", "KB", "MB", "GB", "TB"]:
if num < 1024.0:
if x == space + "B":
return "%d%s%s" % (num, space, x)
return "%3.1f%s%s" % (num, space, x)
num /= 1024.0
def file_size_MB(num):
return "{:,.2f}".format(num / (1024 * 1024))
def get_or_none(key, d, none=None):
if key in d:
return d[key]
else:
return none
def is_path_safe(path):
if path.startswith("."):
return False
if "/." in path:
return False
return True
def is_valid_url(url, qualifying=None):
min_attributes = ("scheme", "netloc")
qualifying = min_attributes if qualifying is None else qualifying
token = urlparse(url)
return all([getattr(token, qualifying_attr) for qualifying_attr in qualifying])
def path2url(path):
return pathname2url(path)
def safe_name(s):
return safe_string(s, "-_")
def safe_path(s):
return safe_string(s, "-_/")
def safe_string(s, valid, no_repeat=False):
"""return a safe string, replace non alnum characters with _ . all characters in valid are considered valid."""
safe = "".join([c if c.isalnum() or c in valid else "_" for c in s])
if no_repeat:
safe = re.sub(r"_+", "_", safe)
return safe