diff options
| author | Louis Burda <dev@sinitax.com> | 2026-01-24 04:55:48 +0100 |
|---|---|---|
| committer | Louis Burda <dev@sinitax.com> | 2026-01-24 04:57:14 +0100 |
| commit | 608e959ef71b3d717f55de092d8301d4703a631a (patch) | |
| tree | 27a116a9dfceab58224f30afe84c9da23767426f | |
| download | uploadserver-main.tar.gz uploadserver-main.zip | |
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | README.md | 52 | ||||
| -rw-r--r-- | man/uploadserver.1 | 87 | ||||
| -rw-r--r-- | pyproject.toml | 16 | ||||
| -rw-r--r-- | src/uploadserver/__init__.py | 133 | ||||
| -rw-r--r-- | uv.lock | 8 |
6 files changed, 298 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fafd07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +*.egg-info diff --git a/README.md b/README.md new file mode 100644 index 0000000..96e3d18 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# uploadserver + +A simple HTTP file upload server with no external dependencies. + +- Python 3.10+ (including 3.13+) +- Standard library only + +## Installation + +```bash +uv sync +``` + +## Usage + +```bash +# Start server with default settings (port 8000, form field "file") +uv run uploadserver + +# Custom port and form field name +uv run uploadserver -p 9000 -k document + +# Raw body mode - save POST body directly to a file +uv run uploadserver --raw -o /tmp/received.bin +``` + +## Options + +| Option | Description | +|--------|-------------| +| `-p, --port PORT` | Port to listen on (default: 8000) | +| `-k, --key NAME` | Form field name for file uploads (default: file) | +| `--raw` | Accept raw request body as file | +| `-o, --output PATH` | Output path for raw mode (required with --raw) | +| `-d, --debug` | Print incoming requests to stderr | + +## Examples + +Upload via browser: +``` +Open http://localhost:8000 and use the form +``` + +Upload via curl (multipart): +```bash +curl -F "file=@myfile.txt" http://localhost:8000 +``` + +Upload via curl (raw mode): +```bash +curl -X POST --data-binary @myfile.txt http://localhost:8000 +``` diff --git a/man/uploadserver.1 b/man/uploadserver.1 new file mode 100644 index 0000000..64d6708 --- /dev/null +++ b/man/uploadserver.1 @@ -0,0 +1,87 @@ +.TH UPLOADSERVER 1 "January 2025" "uploadserver 0.1.0" "User Commands" +.SH NAME +uploadserver \- simple HTTP file upload server +.SH SYNOPSIS +.B uploadserver +[\fB\-p\fR \fIPORT\fR] +[\fB\-k\fR \fINAME\fR] +.br +.B uploadserver +\fB\-\-raw\fR \fB\-o\fR \fIPATH\fR +[\fB\-p\fR \fIPORT\fR] +.SH DESCRIPTION +.B uploadserver +is a simple HTTP server for receiving file uploads. It supports two modes: +multipart form uploads (default) and raw body uploads. +.PP +In default mode, it serves a web interface for uploading files and stores them +in an \fIuploads/\fR directory. In raw mode, it saves the raw POST request body +directly to a specified file. +.PP +The server uses only Python standard library modules and is compatible with +Python 3.10 and later, including Python 3.13+. +.SH OPTIONS +.TP +.BR \-p ", " \-\-port " " \fIPORT\fR +Port to listen on. Default is 8000. +.TP +.BR \-k ", " \-\-key " " \fINAME\fR +Form field name for file uploads in multipart mode. Default is "file". +.TP +.BR \-\-raw +Accept raw request body as file instead of multipart form data. +Requires \fB\-o\fR option. +.TP +.BR \-o ", " \-\-output " " \fIPATH\fR +Output file path for raw mode. Required when using \fB\-\-raw\fR. +.TP +.BR \-d ", " \-\-debug +Print incoming requests to stderr, including method, path, headers, and body size. +.SH EXAMPLES +Start server with defaults: +.PP +.RS +.nf +uploadserver +.fi +.RE +.PP +Use custom port and form field: +.PP +.RS +.nf +uploadserver -p 9000 -k document +.fi +.RE +.PP +Raw body mode: +.PP +.RS +.nf +uploadserver --raw -o /tmp/received.bin +.fi +.RE +.PP +Upload file with curl (multipart): +.PP +.RS +.nf +curl -F "file=@myfile.txt" http://localhost:8000 +.fi +.RE +.PP +Upload file with curl (raw): +.PP +.RS +.nf +curl -X POST --data-binary @myfile.txt http://localhost:8000 +.fi +.RE +.SH FILES +.TP +.I uploads/ +Default directory where uploaded files are stored (multipart mode). +.SH NOTES +This server has no external dependencies and uses only the Python standard library. +.SH AUTHORS +Written for use with uv. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ad151fa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "uploadserver" +version = "0.1.0" +description = "Simple file upload server" +requires-python = ">=3.10" +dependencies = [] + +[project.scripts] +uploadserver = "uploadserver:main" + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/uploadserver/__init__.py b/src/uploadserver/__init__.py new file mode 100644 index 0000000..9d64919 --- /dev/null +++ b/src/uploadserver/__init__.py @@ -0,0 +1,133 @@ +import argparse +import os +import http.server +import socketserver +from email.parser import BytesParser +import re +import sys + +def parse_multipart(headers, body): + content_type = headers.get('Content-Type', '') + if not content_type.startswith('multipart/form-data'): + return {} + + header_bytes = f"Content-Type: {content_type}\r\n\r\n".encode() + msg = BytesParser().parsebytes(header_bytes + body) + + parts = {} + for part in msg.walk(): + if part.get_content_maintype() == 'multipart': + continue + disp = part.get('Content-Disposition', '') + name_match = re.search(r'name="([^"]*)"', disp) + filename_match = re.search(r'filename="([^"]*)"', disp) + if name_match: + name = name_match.group(1) + parts[name] = { + 'data': part.get_payload(decode=True), + 'filename': filename_match.group(1) if filename_match else None + } + return parts + +def log_request(handler, body=None): + print(f"--- {handler.command} {handler.path} {handler.request_version}", file=sys.stderr) + for name, value in handler.headers.items(): + print(f"{name}: {value}", file=sys.stderr) + if body is not None: + print(f"--- Body: {len(body)} bytes", file=sys.stderr) + print(file=sys.stderr) + +def make_handler(upload_dir, file_key, debug=False): + class UploadHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + if debug: + log_request(self) + if self.path == '/': + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + files = os.listdir(upload_dir) if os.path.exists(upload_dir) else [] + files_list = ''.join(f'<li><a href="/uploads/{f}">{f}</a></li>' for f in files) + html = f'''<!DOCTYPE html> +<html> +<head><title>File Upload</title></head> +<body> +<h1>Upload File</h1> +<form method="POST" enctype="multipart/form-data"> +<input type="file" name="{file_key}" required> +<input type="submit" value="Upload"> +</form> +<h2>Uploaded Files</h2> +<ul>{files_list}</ul> +</body> +</html>''' + self.wfile.write(html.encode()) + else: + super().do_GET() + + def do_POST(self): + content_length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(content_length) + if debug: + log_request(self, body) + if self.path == '/': + parts = parse_multipart(self.headers, body) + if file_key in parts and parts[file_key]['filename']: + filename = os.path.basename(parts[file_key]['filename']) + filepath = os.path.join(upload_dir, filename) + with open(filepath, 'wb') as f: + f.write(parts[file_key]['data']) + print(f"Received file: {filepath}", file=sys.stderr) + self.send_response(303) + self.send_header('Location', '/') + self.end_headers() + return + self.send_response(400) + self.end_headers() + else: + self.send_response(404) + self.end_headers() + return UploadHandler + +def make_raw_handler(output_path, debug=False): + class RawHandler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + content_length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(content_length) + if debug: + log_request(self, body) + with open(output_path, 'wb') as f: + f.write(body) + print(f"Received file: {output_path}", file=sys.stderr) + self.send_response(200) + self.send_header('Content-type', 'text/plain') + self.end_headers() + self.wfile.write(f"Saved {len(body)} bytes to {output_path}\n".encode()) + return RawHandler + +def main(): + parser = argparse.ArgumentParser(description='File upload server') + parser.add_argument('-k', '--key', default='file', help='Body parameter name for file uploads') + parser.add_argument('-p', '--port', type=int, default=8000, help='Port to listen on') + parser.add_argument('--raw', action='store_true', help='Accept raw body as file') + parser.add_argument('-o', '--output', help='Output path for raw mode') + parser.add_argument('-d', '--debug', action='store_true', help='Print incoming requests') + args = parser.parse_args() + + if args.raw: + if not args.output: + parser.error('--raw requires -o/--output PATH') + handler = make_raw_handler(args.output, args.debug) + print(f"Server running on http://localhost:{args.port} (raw mode, output: {args.output})") + else: + upload_dir = "uploads" + os.makedirs(upload_dir, exist_ok=True) + handler = make_handler(upload_dir, args.key, args.debug) + print(f"Server running on http://localhost:{args.port} (file key: {args.key})") + + socketserver.TCPServer.allow_reuse_address = True + with socketserver.TCPServer(("", args.port), handler) as httpd: + httpd.serve_forever() + +if __name__ == '__main__': + main() @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "uploadserver" +version = "0.1.0" +source = { editable = "." } |
