initial
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
data/**
|
||||||
24
docker-compose.yaml
Normal file
24
docker-compose.yaml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
pwss:
|
||||||
|
build:
|
||||||
|
context: docker-pwss
|
||||||
|
args:
|
||||||
|
- UUID
|
||||||
|
- TZ
|
||||||
|
image: pwss
|
||||||
|
volumes:
|
||||||
|
- ./data/:/data/
|
||||||
|
environment:
|
||||||
|
- UUID
|
||||||
|
- SESSION_EXPIRY
|
||||||
|
- SECRET_KEY
|
||||||
|
- DATABASE
|
||||||
|
- WORKERS
|
||||||
|
- CONFIG_FOLDER
|
||||||
|
- STATIC_FOLDER
|
||||||
|
- LIMITER_SHARE
|
||||||
|
ports:
|
||||||
|
- "${EXPOSE}:5000"
|
||||||
|
restart: "unless-stopped"
|
||||||
26
docker-pwss/Dockerfile
Normal file
26
docker-pwss/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM python:3.10-slim-bullseye
|
||||||
|
|
||||||
|
ARG TZ=UTC
|
||||||
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
||||||
|
memcached \
|
||||||
|
&& rm -rf /var/lib/apt/lists/
|
||||||
|
|
||||||
|
COPY code/requirements.txt /tmp/requirements.txt
|
||||||
|
|
||||||
|
ARG UUID=1000
|
||||||
|
RUN useradd -u $UUID -ms /bin/bash user && \
|
||||||
|
mkdir /venv && chown $UUID /venv
|
||||||
|
USER user
|
||||||
|
RUN python3 -m venv /venv && \
|
||||||
|
. /venv/bin/activate && \
|
||||||
|
pip install --no-cache-dir -U pip wheel && \
|
||||||
|
pip install --no-cache-dir -r /tmp/requirements.txt && \
|
||||||
|
rm -rf /home/user/.cache && pip freeze > /venv/freeze.txt
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
EXPOSE 5001
|
||||||
|
COPY code /code
|
||||||
|
WORKDIR /code
|
||||||
|
CMD ["bash", "/code/start_daemon"]
|
||||||
5
docker-pwss/code/maintain
Executable file
5
docker-pwss/code/maintain
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
. /venv/bin/activate
|
||||||
|
set -x
|
||||||
|
exec python3 maintain.py
|
||||||
22
docker-pwss/code/maintain.py
Normal file
22
docker-pwss/code/maintain.py
Normal 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
5
docker-pwss/code/manager
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
cd $( dirname $( readlink -f "$0" ) )
|
||||||
|
. /venv/bin/activate
|
||||||
|
python3 /code/share.py "$@"
|
||||||
6
docker-pwss/code/requirements.txt
Normal file
6
docker-pwss/code/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
gunicorn
|
||||||
|
flask
|
||||||
|
flask-limiter[memcached]
|
||||||
|
bcrypt
|
||||||
|
pymemcache
|
||||||
|
supervisor
|
||||||
33
docker-pwss/code/revprox.py
Normal file
33
docker-pwss/code/revprox.py
Normal 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
9
docker-pwss/code/serve
Executable 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
128
docker-pwss/code/serve.py
Normal 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
222
docker-pwss/code/share.py
Normal 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
12
docker-pwss/code/start_daemon
Executable 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
|
||||||
|
|
||||||
38
docker-pwss/code/supervisord.conf
Normal file
38
docker-pwss/code/supervisord.conf
Normal 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
|
||||||
|
|
||||||
28
docker-pwss/code/templates/index.html
Normal file
28
docker-pwss/code/templates/index.html
Normal 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>
|
||||||
|
|
||||||
196
docker-pwss/code/templates/layout.html
Normal file
196
docker-pwss/code/templates/layout.html
Normal 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>
|
||||||
52
docker-pwss/code/templates/login.html
Normal file
52
docker-pwss/code/templates/login.html
Normal 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 %}
|
||||||
55
docker-pwss/code/templates/ratelimit.html
Normal file
55
docker-pwss/code/templates/ratelimit.html
Normal 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
131
docker-pwss/code/utils.py
Normal 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 {}
|
||||||
10
env-example
Normal file
10
env-example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
EXPOSE=8088
|
||||||
|
UUID=1000
|
||||||
|
SESSION_EXPIRY=1800
|
||||||
|
SECRET_KEY=2f6aa45dfcfc37a50537f0b05af6452c
|
||||||
|
CONFIG_FOLDER=/data/configs
|
||||||
|
STATIC_FOLDER=/data/static
|
||||||
|
DATABASE=/dev/shm/serve.db
|
||||||
|
WORKERS=6
|
||||||
|
TZ=Europe/Helsinki
|
||||||
|
LIMITER_SHARE=200 per 5 seconds
|
||||||
3
manage
Executable file
3
manage
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
docker-compose exec pwss bash /code/manager "$@"
|
||||||
3
service-down
Executable file
3
service-down
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
docker-compose down
|
||||||
6
service-up
Executable file
6
service-up
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
mkdir -p data
|
||||||
|
docker-compose build
|
||||||
|
docker-compose up -d -t 0
|
||||||
|
docker-compose logs -f
|
||||||
Reference in New Issue
Block a user