aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLouis Burda <dev@sinitax.com>2026-01-24 04:55:48 +0100
committerLouis Burda <dev@sinitax.com>2026-01-24 04:57:14 +0100
commit608e959ef71b3d717f55de092d8301d4703a631a (patch)
tree27a116a9dfceab58224f30afe84c9da23767426f
downloaduploadserver-main.tar.gz
uploadserver-main.zip
Add initial versionHEADmain
-rw-r--r--.gitignore2
-rw-r--r--README.md52
-rw-r--r--man/uploadserver.187
-rw-r--r--pyproject.toml16
-rw-r--r--src/uploadserver/__init__.py133
-rw-r--r--uv.lock8
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()
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..4c3e54f
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,8 @@
+version = 1
+revision = 3
+requires-python = ">=3.10"
+
+[[package]]
+name = "uploadserver"
+version = "0.1.0"
+source = { editable = "." }