diff options
author | Joris | 2024-05-20 09:40:11 +0200 |
---|---|---|
committer | Joris | 2024-05-20 09:40:11 +0200 |
commit | 436ddf6f23242eb709b591cd5e9cbf1553f8d390 (patch) | |
tree | dfed58b5e553f131fd3009f03f095ca40efc5949 | |
parent | 6baa0419d3b5eb63c70be446226a321f900e433d (diff) |
Allow to upload file and download from given link
-rw-r--r-- | .editorconfig | 33 | ||||
-rw-r--r-- | .gitignore | 3 | ||||
-rw-r--r-- | README.md | 5 | ||||
-rwxr-xr-x | bin/dev-server | 16 | ||||
-rw-r--r-- | flake.lock | 60 | ||||
-rw-r--r-- | flake.nix | 25 | ||||
-rw-r--r-- | init-db.sql | 7 | ||||
-rw-r--r-- | public/main.css | 70 | ||||
-rw-r--r-- | public/main.js | 48 | ||||
-rw-r--r-- | src/db.py | 20 | ||||
-rw-r--r-- | src/main.py | 24 | ||||
-rw-r--r-- | src/server.py | 85 | ||||
-rw-r--r-- | src/templates.py | 97 | ||||
-rw-r--r-- | src/utils.py | 19 |
14 files changed, 512 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4c851e8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,33 @@ +root = true + +[*.js] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.py] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.css] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.sql] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b64351 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +download +db.sqlite3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c567a19 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Getting started + +Enter shell with `nix develop`, then start server with `bin/dev-server`. + +Dev key is '1234'. diff --git a/bin/dev-server b/bin/dev-server new file mode 100755 index 0000000..d489127 --- /dev/null +++ b/bin/dev-server @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! [ -f db.sqlite3 ]; then + + echo "Creating databise" + sqlite3 db.sqlite3 < init-db.sql + sleep 1 + +fi + +watchexec \ + --restart \ + --clear \ + --exts py \ + python src/main.py diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ea71eaf --- /dev/null +++ b/flake.lock @@ -0,0 +1,60 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1716040799, + "narHash": "sha256-0U19tjIaggl2b+v1ozMj7yVMCoWb1MOcV8dzTuyEZB8=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "cb7884d6de31c46736adb561533527238fe7d3c9", + "type": "github" + }, + "original": { + "owner": "nixos", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..92951f0 --- /dev/null +++ b/flake.nix @@ -0,0 +1,25 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem + (system: + let pkgs = nixpkgs.legacyPackages.${system}; + in { devShell = pkgs.mkShell { + buildInputs = with pkgs; [ + (pkgs.python3.withPackages (pythonPackages: with pythonPackages; [ + sqlite + watchexec + ])) + ]; + shellHook = '' + export HOST="127.0.0.1" + export PORT="8080" + export KEY="1234" + ''; + }; } + ); +} diff --git a/init-db.sql b/init-db.sql new file mode 100644 index 0000000..57afd00 --- /dev/null +++ b/init-db.sql @@ -0,0 +1,7 @@ +CREATE TABLE files( + id TEXT PRIMARY KEY, + filename TEXT NOT NULL, + created TEXT NOT NULL, + expires TEXT NOT NULL, + content_length INTEGER NOT NULL +) diff --git a/public/main.css b/public/main.css new file mode 100644 index 0000000..0a20779 --- /dev/null +++ b/public/main.css @@ -0,0 +1,70 @@ +html { + margin: 0 1rem; +} + +body { + max-width: 500px; + margin: 0 auto; + font-family: sans-serif; +} + +a { + text-decoration: none; + color: #06C; +} + +h1 { + text-align: center; + font-variant: small-caps; + font-size: 40px; + letter-spacing: 0.2rem; +} + +.g-Link { + text-decoration: underline; +} + +label { + display: flex; + gap: 0.5rem; + flex-direction: column; + margin-bottom: 2rem; +} + +input[type=submit] { + width: 100%; +} + +.g-Loading { + display: none; + align-items: center; + justify-content: center; + gap: 1rem; + margin-bottom: 2rem; +} + +.g-Error { + text-align: center; + margin-bottom: 2rem; + color: #C00; +} + +.g-Spinner { + width: 25px; + height: 25px; + border: 4px solid #06C; + border-bottom-color: transparent; + border-radius: 50%; + display: inline-block; + box-sizing: border-box; + animation: rotation 1s linear infinite; +} + +@keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/public/main.js b/public/main.js new file mode 100644 index 0000000..1729d38 --- /dev/null +++ b/public/main.js @@ -0,0 +1,48 @@ +window.onload = function() { + const form = document.querySelector('form') + + if (form !== null) { + const submit = document.querySelector('input[type="submit"]') + const loading = document.querySelector('.g-Loading') + const error = document.querySelector('.g-Error') + + function showError(msg) { + loading.style.display = 'none' + submit.disabled = false + error.innerText = msg + error.style.display = 'block' + } + + form.onsubmit = function(event) { + event.preventDefault() + + loading.style.display = 'flex' + submit.disabled = true + error.style.display = 'none' + + const key = document.querySelector('input[name="key"]').value + const expiration = document.querySelector('select[name="expiration"]').value + const file = document.querySelector('input[name="file"]').files[0] + + // Wait a bit to prevent showing the loader too briefly + setTimeout(function() { + const xhr = new XMLHttpRequest() + xhr.open('POST', '/', true) + xhr.onload = function () { + if (xhr.status === 200) { + window.location = `/${xhr.responseText}` + } else { + showError(`Error uploading: ${xhr.status}`) + } + } + xhr.onerror = function () { + showError('Upload error') + } + xhr.setRequestHeader('X-FileName', file.name) + xhr.setRequestHeader('X-Expiration', expiration) + xhr.setRequestHeader('X-Key', key) + xhr.send(file) + }, 500) + } + } +} diff --git a/src/db.py b/src/db.py new file mode 100644 index 0000000..8aa20f8 --- /dev/null +++ b/src/db.py @@ -0,0 +1,20 @@ +import secrets + +def insert_file(conn, filename: str, expiration_days: int, content_length: int): + cur = conn.cursor() + file_id = secrets.token_urlsafe() + cur.execute( + 'INSERT INTO files(id, filename, created, expires, content_length) VALUES(?, ?, datetime(), datetime(datetime(), ?), ?)', + (file_id, filename, f'+{expiration_days} days', content_length) + ) + conn.commit() + return file_id + +def get_file(conn, file_id: str): + cur = conn.cursor() + res = cur.execute( + 'SELECT filename, expires, content_length FROM files WHERE id = ?', + (file_id,) + ) + return res.fetchone() + diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..56c8e9e --- /dev/null +++ b/src/main.py @@ -0,0 +1,24 @@ +import http.server +import logging +import os +import sys + +import server + +logger = logging.getLogger(__name__) +hostName = os.environ['HOST'] +serverPort = int(os.environ['PORT']) + +if __name__ == '__main__': + logging.basicConfig(stream=sys.stdout, level=logging.INFO) + webServer = http.server.HTTPServer((hostName, serverPort), server.MyServer) + logger.info('Server started at http://%s:%s.' % (hostName, serverPort)) + + try: + webServer.serve_forever() + except KeyboardInterrupt: + pass + + webServer.server_close() + conn.close() + logger.info('Server stopped.') diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..2cd6741 --- /dev/null +++ b/src/server.py @@ -0,0 +1,85 @@ +import http.server +import logging +import os +import sqlite3 +import tempfile + +import db +import templates +import utils + +logger = logging.getLogger(__name__) +conn = sqlite3.connect('db.sqlite3') +files_directory = 'download' +authorized_key = os.environ['KEY'] + +class MyServer(http.server.BaseHTTPRequestHandler): + def do_GET(self): + match self.path: + case '/': + self._serve_str(templates.index, 200, 'text/html') + case '/main.js': + self._serve_file('public/main.js', 'application/javascript') + case '/main.css': + self._serve_file('public/main.css', 'text/css') + case path: + prefix = f'/{files_directory}/' + if path.startswith(prefix): + file_id = path[len(prefix):] + res = db.get_file(conn, file_id) + if res is None: + self._serve_str(templates.not_found, 404, 'text/html') + else: + filename, _, content_length = res + path = os.path.join(files_directory, file_id) + headers = [ + ('Content-Disposition', f'attachment; filename={filename}'), + ('Content-Length', content_length) + ] + self._serve_file(path, 'application/octet-stream', headers) + else: + file_id = path[1:] + res = db.get_file(conn, file_id) + if res is None: + self._serve_str(templates.not_found, 404, 'text/html') + else: + filename, expires, _ = res + href = os.path.join(files_directory, file_id) + self._serve_str(templates.download(href, filename, expires), 200, 'text/html') + + def do_POST(self): + key = self.headers['X-Key'] + if not key == authorized_key: + logging.info('Unauthorized to upload file: wrong key') + self._serve_str('Unauthorized', 401) + + else: + logging.info('Uploading file') + content_length = int(self.headers['content-length']) + filename = utils.sanitize_filename(self.headers['X-FileName']) + expiration = self.headers['X-Expiration'] + + with tempfile.NamedTemporaryFile(delete = False) as tmp: + utils.transfer(self.rfile, tmp, content_length = content_length) + + logging.info('File uploaded') + file_id = db.insert_file(conn, filename, expiration, content_length) + os.makedirs(files_directory, exist_ok=True) + os.rename(tmp.name, os.path.join(files_directory, file_id)) + + self._serve_str(file_id, 200) + + def _serve_str(self, s, code, content_type='text/plain'): + self.send_response(code) + self.send_header('Content-type', content_type) + self.end_headers() + self.wfile.write(bytes(s, 'utf-8')) + + def _serve_file(self, filename, content_type, headers = []): + self.send_response(200) + self.send_header('Content-type', content_type) + for header_name, header_value in headers: + self.send_header(header_name, header_value) + self.end_headers() + with open(filename, 'rb') as f: + utils.transfer(f, self.wfile) diff --git a/src/templates.py b/src/templates.py new file mode 100644 index 0000000..1308fc0 --- /dev/null +++ b/src/templates.py @@ -0,0 +1,97 @@ +import html + +page: str = ''' + <!doctype html> + <html lang="fr"> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width"> + + <title>Files</title> + <link rel="stylesheet" href="/main.css"> + <script src="/main.js"></script> + + <a href="/"> + <h1>Files</h1> + </a> +''' + +index: str = f''' + {page} + + <form> + <label> + File + <input type="file" name="file" required> + </label> + + <label> + Expiration + <select name="expiration"> + <option value="1">1 day</option> + <option value="2">2 days</option> + <option value="3">3 days</option> + <option value="4">4 days</option> + <option value="5">5 days</option> + <option value="6">6 days</option> + <option value="7" selected>7 days</option> + <option value="8">8 days</option> + <option value="9">9 days</option> + <option value="10">10 days</option> + <option value="11">11 days</option> + <option value="12">12 days</option> + <option value="13">13 days</option> + <option value="14">14 days</option> + <option value="15">15 days</option> + <option value="16">16 days</option> + <option value="17">17 days</option> + <option value="18">18 days</option> + <option value="19">19 days</option> + <option value="20">20 days</option> + <option value="21">21 days</option> + <option value="22">22 days</option> + <option value="23">23 days</option> + <option value="24">24 days</option> + <option value="25">25 days</option> + <option value="26">26 days</option> + <option value="27">27 days</option> + <option value="28">28 days</option> + <option value="29">29 days</option> + <option value="30">30 days</option> + <option value="31">31 days</option> + </select> + </label> + + <label> + Key + <input type="password" name="key" required> + </label> + + <div class="g-Loading"> + <div class="g-Spinner"></div> + Uploading… + </div> + + <div class="g-Error"> + </div> + + <input type="submit" value="Upload"> + </form> +''' + +def download(href: str, filename: str, expires: str) -> str: + return f''' + {page} + + <div> + <a class="g-Link" href="{html.escape(href)}">{html.escape(filename)}</a> + <div> + Expires: {html.escape(expires)} + </div> + </div> + ''' + +not_found: str = f''' + {page} + + Sorry, the file you are looking for can not be found. It may have already expired. +''' diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..ccf92c0 --- /dev/null +++ b/src/utils.py @@ -0,0 +1,19 @@ +import io + +def transfer(reader, writer, content_length = None, buffer_size = io.DEFAULT_BUFFER_SIZE): + if content_length is None: + while (data := reader.read(buffer_size)): + writer.write(data) + else: + remaining = content_length + while remaining > 0: + size = min(buffer_size, remaining) + writer.write(reader.read(size)) + remaining -= size + +def sanitize_filename(s: str) -> str: + return '.'.join([sanitize_filename_part(p) for p in s.split('.')]) + +def sanitize_filename_part(s: str) -> str: + alnum_or_space = ''.join([c if c.isalnum() else ' ' for c in s]) + return '-'.join(alnum_or_space.split()) |