aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoris2024-05-20 09:40:11 +0200
committerJoris2024-05-20 09:40:11 +0200
commit436ddf6f23242eb709b591cd5e9cbf1553f8d390 (patch)
treedfed58b5e553f131fd3009f03f095ca40efc5949
parent6baa0419d3b5eb63c70be446226a321f900e433d (diff)
Allow to upload file and download from given link
-rw-r--r--.editorconfig33
-rw-r--r--.gitignore3
-rw-r--r--README.md5
-rwxr-xr-xbin/dev-server16
-rw-r--r--flake.lock60
-rw-r--r--flake.nix25
-rw-r--r--init-db.sql7
-rw-r--r--public/main.css70
-rw-r--r--public/main.js48
-rw-r--r--src/db.py20
-rw-r--r--src/main.py24
-rw-r--r--src/server.py85
-rw-r--r--src/templates.py97
-rw-r--r--src/utils.py19
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())