This commit is contained in:
2022-08-18 15:42:27 +03:00
commit 2157306a4e
22 changed files with 1015 additions and 0 deletions

5
docker-pwss/code/maintain Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
. /venv/bin/activate
set -x
exec python3 maintain.py

View File

@@ -0,0 +1,22 @@
from share import session_clean, session_create_database
import sys
import time
def run_periodically():
while True:
try:
print("Cleaning sessions", file=sys.stderr)
session_clean()
except Exception as e:
print(e, file=sys.stderr)
# Run daily
time.sleep(86400)
if __name__ == "__main__":
session_create_database()
run_periodically()

5
docker-pwss/code/manager Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
cd $( dirname $( readlink -f "$0" ) )
. /venv/bin/activate
python3 /code/share.py "$@"

View File

@@ -0,0 +1,6 @@
gunicorn
flask
flask-limiter[memcached]
bcrypt
pymemcache
supervisor

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)

9
docker-pwss/code/serve Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
. /venv/bin/activate
set -x
exec gunicorn \
-b 0.0.0.0:5000 \
-w "$WORKERS" \
serve:app

128
docker-pwss/code/serve.py Normal file
View File

@@ -0,0 +1,128 @@
from flask import (
Flask,
request,
session,
g,
redirect,
url_for,
render_template,
send_file,
)
from flask_limiter import Limiter
from revprox import ReverseProxied
from utils import (
authenticate,
check_auth,
get_ip,
get_valid_sessions,
read_config,
)
import os
import sys
DEBUG = False
SECRET_KEY = os.getenv("SECRET_KEY", "2f6aa45dfcfc37a50537f0b05af6452c")
DATABASE = os.getenv("DATABASE", "serve.db")
SESSION_EXPIRY = int(os.getenv("SESSION_EXPIRY", 1800))
FOLDERS = os.getenv("STATIC_FOLDER")
app = Flask(__name__)
app.config.from_object(__name__)
app.wsgi_app = ReverseProxied(app.wsgi_app)
limiter = Limiter(
key_func=get_ip,
default_limits=["5 per 5 seconds"],
storage_uri="memcached://localhost:11211",
)
limiter.init_app(app)
@app.route("/s/<path:path>", methods=["POST", "GET"])
@limiter.limit(os.getenv("LIMITER_SHARE"))
def serve(path=None):
realpath = os.path.join(FOLDERS, path)
is_auth = check_auth(path)
if not is_auth:
session["return_to"] = path
return redirect(
url_for("login", folder=path.split(os.sep)[0]), code=302
)
if os.path.isdir(realpath):
if not path.endswith("/"):
return redirect(url_for("serve", path=f"{path}/"), code=302)
realpath = os.path.join(realpath, "index.html")
if not os.path.exists(realpath):
return "", 404
return send_file(
realpath,
)
@app.route("/l", methods=["POST", "GET"])
@app.route("/l/<folder>", methods=["POST", "GET"])
def login(folder=None):
if request.method == "POST":
folder = "".join(
letter for letter in request.form["folder"] if letter.isalnum()
)
config = read_config(folder)
success = authenticate(config, request.form["password"])
print(
f"{'Successful' if success else 'Failed'} login {folder}: {get_ip()}",
file=sys.stderr,
)
ret = session.get("return_to", None)
if "return_to" in session:
session.pop("return_to")
if ret:
return redirect(url_for("serve", path=ret))
else:
# GET
config = read_config(folder)
sessions = get_valid_sessions()
if not folder:
folder = ""
return render_template("login.html", folder=folder, sessions=sessions)
@app.route("/logout", methods=["GET"])
def logout():
to_delete = [key for key in session if key.startswith("auth/")]
for key in to_delete:
del session[key]
return redirect(url_for("index"))
@app.route("/", methods=["GET"])
def index():
return render_template("index.html")
@app.teardown_appcontext
def close_connection(exception):
db = getattr(g, "_database", None)
if db is not None:
db.close()
@app.errorhandler(429)
def ratelimit_handler(e):
print(
f"Ratelimit exceeded: {e.description} key: {e.limit.key_func()}",
file=sys.stderr,
)
return render_template("ratelimit.html", description=e.description), 429
if __name__ == "__main__":
app.run()

222
docker-pwss/code/share.py Normal file
View File

@@ -0,0 +1,222 @@
import os
import sys
import json
import time
import bcrypt
import argparse
import sqlite3
from werkzeug.utils import secure_filename
from datetime import datetime, timedelta
from utils import read_config
ENTRY = {"expires": "never", "password": None}
CONFIGS = os.getenv("CONFIG_FOLDER")
FOLDERS = os.getenv("STATIC_FOLDER")
DATABASE = os.getenv("DATABASE", None)
SCHEMA = """CREATE TABLE IF NOT EXISTS sessions(
ID INTEGER PRIMARY KEY AUTOINCREMENT,
folder TEXT NOT NULL,
ip TEXT NOT NULL,
token TEXT NOT NULL,
expire INTEGER NOT NULL
);"""
def get_opts():
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command")
sub_add = subparsers.add_parser("add")
sub_remove = subparsers.add_parser("remove")
sub_edit = subparsers.add_parser("edit")
sub_list = subparsers.add_parser("list")
sub_session_list = subparsers.add_parser("sessions-list")
sub_session_clean = subparsers.add_parser(
"sessions-clean", help="Clean out session DB"
)
sub_session_remove = subparsers.add_parser(
"sessions-remove", help="Terminates all sessions"
)
for p in (sub_add, sub_remove, sub_edit):
p.add_argument("folder", action="store", help="Folder name to share")
for p in (sub_add, sub_edit):
p.add_argument(
"--expires",
action="store",
help="Share expires in days, or 'never'",
default=None,
)
p.add_argument(
"--password", action="store", help="Set password", default=None
)
args = parser.parse_args()
return args
def manager():
opts = get_opts()
if opts.command == None or opts.command == "list":
shares_list()
if opts.command == "add":
share_add(opts)
if opts.command == "remove":
share_remove(opts)
if opts.command == "edit":
share_edit(opts)
if opts.command == "sessions-list":
session_list()
if opts.command == "sessions-remove":
session_remove()
if opts.command == "sessions-clean":
session_clean()
def load_config(name):
config = read_config(name)
return config
def save_config(path, config):
with open(path, "wt") as fp:
save_config = ENTRY.copy()
for key in save_config:
save_config[key] = config[key]
return json.dump(save_config, fp, indent=2, sort_keys=True)
def share_oneliner(entry):
return f"{(entry['name']+'/').ljust(15)} expires: {entry['expires']}"
def shares_list():
print("Shared folders:")
for c in sorted(os.listdir(CONFIGS)):
if c.endswith(".json"):
entry = load_config(c[0:-5])
print(share_oneliner(entry))
def share_add(opts):
entry = ENTRY.copy()
if opts.folder != secure_filename(opts.folder):
raise ValueError(f"Folder '{opts.folder}' is not a safe filename")
if opts.expires:
entry["expires"] = (
(datetime.now() + timedelta(days=float(opts.expires)))
.replace(microsecond=0)
.isoformat()
)
if not opts.password:
raise ValueError("Password required")
(pw, _) = hash_password(opts.password)
entry["password"] = pw
share_folder = os.path.join(FOLDERS, opts.folder)
share_config = os.path.join(CONFIGS, f"{opts.folder}.json")
if os.path.exists(share_config):
raise FileExistsError("Configuration already exists. Edit with `edit`")
save_config(share_config, entry)
if not os.path.exists(share_folder):
os.mkdir(share_folder)
print(f"Added:\n{share_oneliner(load_config(opts.folder))}")
def share_remove(opts):
share_folder = os.path.join(FOLDERS, opts.folder)
share_config = os.path.join(CONFIGS, f"{opts.folder}.json")
if not os.path.exists(share_config):
raise FileNotFoundError("Configuration doesn't exist.")
else:
print(f"Removing configuration {share_config}")
os.remove(share_config)
if os.path.exists(share_folder):
if len(os.listdir(share_folder)) > 0:
print(f"Not removing folder {share_folder}, contains data")
else:
print(f"Removing empty folder {share_folder}")
os.rmdir(share_folder)
def share_edit(opts):
share_config = os.path.join(CONFIGS, f"{opts.folder}.json")
if not os.path.exists(share_config):
raise FileNotFoundError("Configuration doesn't exist.")
entry = load_config(opts.folder)
if opts.expires:
print(f"Updating expiry date: {opts.expires}")
if opts.expires == "never":
entry["expires"] = opts.expires
else:
entry["expires"] = (
(datetime.now() + timedelta(days=float(opts.expires)))
.replace(microsecond=0)
.isoformat()
)
if opts.password:
print("Updating password")
(pw, _) = hash_password(opts.password)
entry["password"] = pw
save_config(share_config, entry)
print(f"Added:\n{share_oneliner(load_config(opts.folder))}")
def session_get_database():
return sqlite3.connect(DATABASE)
def session_create_database():
args = tuple()
db = session_get_database()
cur = db.execute(SCHEMA, args)
db.commit()
cur.close()
def session_list():
query = "SELECT folder, expire, token, ip FROM sessions"
args = tuple()
cur = session_get_database().execute(query, args)
print(f"{'Share'.ljust(15)} {'Expires'.ljust(19)} IP")
for session in cur.fetchall():
d = datetime.fromtimestamp(session[1])
print(f"{session[0].ljust(15)} {d} {session[3]}")
cur.close()
def session_remove():
query = "DELETE FROM sessions"
args = tuple()
db = session_get_database()
cur = db.execute(query, args)
db.commit()
cur.close()
def session_clean():
query = "DELETE FROM sessions WHERE expire < ?"
args = (int(time.time()),)
db = session_get_database()
cur = db.execute(query, args)
db.commit()
cur.close()
def hash_password(pw):
pw = pw.encode("utf-8")
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(pw, salt)
return hashed.decode(), salt.decode()
if __name__ == "__main__":
manager()

12
docker-pwss/code/start_daemon Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
set -e
mkdir -p /data/static /data/configs
cd $( dirname $( readlink -f "$0" ) )
. /venv/bin/activate
cat /venv/freeze.txt
supervisord \
-c supervisord.conf \
-n \
-l /tmp/supervisord.log \
-j /tmp/supervisord.pid

View File

@@ -0,0 +1,38 @@
[supervisord]
[program:maintain]
command=bash maintain
directory=/code
user=user
redirect_stderr=true
stdout_logfile=/proc/1/fd/1
stderr_logfile=/proc/1/fd/1
stdout_maxbytes=0
stderr_maxbytes=0
stdout_logfile_maxbytes = 0
stderr_logfile_maxbytes = 0
[program:serve]
command=bash serve
directory=/code
user=user
redirect_stderr=true
stdout_logfile=/proc/1/fd/1
stderr_logfile=/proc/1/fd/1
stdout_maxbytes=0
stderr_maxbytes=0
stdout_logfile_maxbytes = 0
stderr_logfile_maxbytes = 0
[program:memcached]
command=/usr/bin/memcached start -u memcached
directory=/
user=user
redirect_stderr=true
stdout_logfile=/proc/1/fd/1
stderr_logfile=/proc/1/fd/1
stdout_maxbytes=0
stderr_maxbytes=0
stdout_logfile_maxbytes = 0
stderr_logfile_maxbytes = 0

View File

@@ -0,0 +1,28 @@
<!doctype html>
<html>
<head>
<meta charset=utf-8/>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="HandheldFriendly" content="true" />
<meta name="viewport" content="width=device-width, height=device-height, user-scalable=yes" />
<style>
body {
background-color: #2c3338;
height: 100%;
margin: 0;
min-height: 100vh;
}
</style>
</head>
<body>
<script>
setTimeout(function(){
window.location.href = '{{ url_for("login") }}';
}, 3000);
</script>
</body>
</html>

View File

@@ -0,0 +1,196 @@
<!doctype html>
<html>
<head>
<meta charset=utf-8/>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="HandheldFriendly" content="true" />
<meta name="viewport" content="width=device-width, height=device-height, user-scalable=yes" />
<link href="https://fonts.googleapis.com/css?family=Open+Sans:400,700" rel="stylesheet">
<style>
.align {
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
}
.grid {
margin-left: auto;
margin-right: auto;
max-width: 320px;
max-width: 20rem;
width: 90%;
}
.hidden {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
.icons {
display: none;
}
.icon {
display: inline-block;
fill: #606468;
font-size: 16px;
font-size: 1rem;
height: 1em;
vertical-align: middle;
width: 1em;
}
* {
-webkit-box-sizing: inherit;
box-sizing: inherit;
}
html {
-webkit-box-sizing: border-box;
box-sizing: border-box;
font-size: 100%;
height: 100%;
}
body {
background-color: #2c3338;
color: #606468;
font-family: 'Open Sans', sans-serif;
font-size: 14px;
font-size: 0.875rem;
font-weight: 400;
height: 100%;
line-height: 1.5;
margin: 0;
min-height: 100vh;
}
a {
color: #eee;
outline: 0;
text-decoration: none;
}
a:focus,
a:hover {
text-decoration: underline;
}
input {
background-image: none;
border: 0;
color: inherit;
font: inherit;
margin: 0;
outline: 0;
padding: 0;
-webkit-transition: background-color 0.3s;
transition: background-color 0.3s;
}
input[type='submit'] {
cursor: pointer;
}
.form {
margin: -14px;
margin: -0.875rem;
}
.form input[type='password'],
.form input[type='text'],
.form input[type='submit'] {
width: 100%;
}
.form__field {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
margin: 14px;
margin: 0.875rem;
}
.form__input {
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
}
.login {
color: #eee;
}
.login label,
.login input[type='text'],
.login input[type='password'],
.login input[type='submit'] {
border-radius: 0.25rem;
padding: 16px;
padding: 1rem;
}
.login label {
background-color: #363b41;
border-bottom-right-radius: 0;
border-top-right-radius: 0;
padding-left: 20px;
padding-left: 1.25rem;
padding-right: 20px;
padding-right: 1.25rem;
}
.login input[type='password'],
.login input[type='text'] {
background-color: #3b4148;
border-bottom-left-radius: 0;
border-top-left-radius: 0;
}
.login input[type='password']:focus,
.login input[type='password']:hover,
.login input[type='text']:focus,
.login input[type='text']:hover {
background-color: #434a52;
}
.login input[type='submit'] {
background-color: #ea4c88;
color: #eee;
font-weight: 700;
text-transform: uppercase;
}
.login input[type='submit']:focus,
.login input[type='submit']:hover {
background-color: #d44179;
}
p {
margin-bottom: 24px;
margin-bottom: 1.5rem;
margin-top: 24px;
margin-top: 1.5rem;
}
ul {
margin-top: 0;
list-style-type: disclosure-closed;
}
.text--center {
text-align: center;
}
</style>
</head>
<body class=align>
{% block body %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,52 @@
{% extends "layout.html" %}
{% block body %}
<div class="grid">
{% if sessions %}
<div>
Open sessions:<br/>
{% for session in sessions %}
<a href={{ url_for('serve', path = session[0] + "/" ) }}>{{ session[0] }}/</a>
<ul>
<li>Session expires: {{ session[1] }} minutes</li>
<li>Share expires: {{ session[2] }} days</li>
</ul>
{% endfor %}
<hr>
</div>
{% endif %}
<div>
<p>Login to a share:</p>
</div>
<form action="{{ url_for('login') }}" method="post" class="form login">
<div class="form__field">
<label for="login__folder">
<svg class=icon style="width:24px;height:24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M20 6H12L10 4H4C2.89 4 2 4.89 2 6V18C2 19.1 2.89 20 4 20H20C21.1 20 22 19.1 22 18V8C22 6.9 21.1 6 20 6M18.42 13.5L15 17L11.59 13.5C11.22 13.12 11 12.62 11 12.05C11 10.92 11.9 10 13 10C13.54 10 14.05 10.23 14.42 10.61L15 11.2L15.59 10.6C15.95 10.23 16.46 10 17 10C18.1 10 19 10.92 19 12.05C19 12.61 18.78 13.13 18.42 13.5Z" />
</svg>
<span class="hidden">Share</span>
</label>
<input id="login__folder" type="text" name="folder" class="form__input"
placeholder="Share" required="" value="{{ folder }}">
</div>
<div class="form__field">
<label for="login__password">
<svg class="icon" style="width:24px;height:24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z" />
</svg>
<span class="hidden">Password</span>
</label>
<input id="login__password" type="password" name="password" class="form__input"
placeholder="Password" required="" autofocus="">
</div>
<div class="form__field">
<input type="submit" value="Sign In">
</div>
</form>
<div>
<p>Visit <a href="{{ url_for('logout') }}">Logout</a> to log out all sessions.</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,55 @@
<!doctype html>
<html>
<head>
<meta charset=utf-8/>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="HandheldFriendly" content="true" />
<meta name="viewport" content="width=device-width, height=device-height, user-scalable=yes" />
<style>
html {
-webkit-box-sizing: border-box;
box-sizing: border-box;
font-size: 100%;
height: 100%;
}
body {
background-color: #2c3338;
color: #fff;
font-family: 'Open Sans', sans-serif;
font-size: 24px;
font-size: 0.875rem;
font-weight: 400;
height: 100%;
line-height: 1.5;
margin: 0;
min-height: 100vh;
}
.align {
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
}
</style>
</head>
<body class=align>
Ratelimit exceeded: {{ description }}
<script>
setTimeout(function(){
location.reload();
}, 60000);
</script>
</body>
</html>

131
docker-pwss/code/utils.py Normal file
View File

@@ -0,0 +1,131 @@
from flask import request, current_app as app, g, session
from datetime import datetime
import bcrypt
import os
import json
import secrets
import sqlite3
import time
import sys
CONFIGS = os.getenv("CONFIG_FOLDER")
def check_password(config, pw):
pw = pw.encode("utf-8")
return bcrypt.checkpw(pw, config["password"].encode("utf-8"))
def check_auth(path):
folder = path.split(os.sep)[0]
if not f"auth/{folder}" in session:
return False
return has_session(folder)
def authenticate(config, password):
"""Return success of authentication"""
if not "name" in config:
return False
folder = config["name"]
if config["expires"] == "never":
expiration = time.time() + app.config["SESSION_EXPIRY"]
else:
expiration = datetime.fromisoformat(config["expires"]).timestamp()
if f"auth/{folder}" in session:
session.pop(f"auth/{folder}")
if expiration > time.time():
if "password" in config and check_password(config, password):
set_session(folder, max_expiration=expiration)
return True
return False
def get_db():
db = getattr(g, "_database", None)
if db is None:
db = g._database = sqlite3.connect(app.config["DATABASE"])
return db
def get_valid_sessions():
query = "SELECT folder, expire, token FROM sessions WHERE expire > ? AND ip = ?"
args = (int(time.time()), get_ip())
# TODO, validate that sessions have the token correct too.
tokens = [session[key] for key in session if key.startswith("auth/")]
try:
cur = get_db().execute(query, args)
valid_sessions = [
(
row[0],
int((row[1] - time.time()) / 60),
read_config(row[0]).get("days_left", "end of"),
)
for row in cur.fetchall()
if row[2] in tokens
]
cur.close()
return valid_sessions
except Exception as e:
print(e, file=sys.stderr)
return []
def has_session(folder):
query = "SELECT count(token) FROM sessions WHERE folder = ? AND expire > ? AND token = ? AND ip = ?"
args = (folder, int(time.time()), session[f"auth/{folder}"], get_ip())
try:
cur = get_db().execute(query, args)
is_valid = cur.fetchall()[0][0] > 0
cur.close()
except Exception as e:
print(e, file=sys.stderr)
return False
return is_valid
def set_session(folder, max_expiration=None):
token = secrets.token_hex(16)
query = "INSERT INTO sessions (folder, expire, token, ip) VALUES (?,?,?,?)"
expiry_time = int(app.config["SESSION_EXPIRY"] + time.time())
if max_expiration:
expiry_time = int(min(max_expiration, expiry_time))
args = (folder, expiry_time, token, get_ip())
db = get_db()
cur = db.execute(query, args)
cur.close()
db.commit()
session[f"auth/{folder}"] = token
def get_ip():
ip = request.environ.get(
"HTTP_X_FORWARDED_FOR", request.remote_addr or "127.0.0.1"
)
ip = ip.split(",")[0].strip()
return ip
def read_config(path):
try:
rootdir = path.split(os.sep)[0]
config_file = os.path.join(CONFIGS, f"{rootdir}.json")
with open(config_file, "rt") as fp:
config = json.load(fp)
config["name"] = rootdir
try:
config["days_left"] = round(
(
datetime.fromisoformat(config["expires"]).timestamp()
- time.time()
)
/ 86400,
1,
)
except ValueError:
config["days_left"] = "end of"
return config
except (FileNotFoundError, AttributeError):
return {}