Instantly share code, notes, and snippets.
Created
June 22, 2026 21:51
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save haxwithaxe/7de631d474500a3b6ca0393a3a65b350 to your computer and use it in GitHub Desktop.
A script to quickly share a file over http without exposing an entire directory (on-demand snakeoil TLS in the future)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Quickly share a file or files without exposing a whole directory.""" | |
| import argparse | |
| import contextlib | |
| import http | |
| import http.server | |
| import html | |
| import io | |
| import logging | |
| import os | |
| import pathlib | |
| import select | |
| import socket | |
| import sys | |
| import urllib | |
| from typing import Any, ClassVar, Self | |
| from xml import etree | |
| from xml.etree.ElementTree import Element, SubElement | |
| __author__ = 'Chris (haxwithaxe) Koepke' | |
| __copyright__ = 'Copyright 2026, Chris Koepke' | |
| __license__ = 'GPL-3.0-only' | |
| DEFAULT_STYLE: str = ''' | |
| @media (prefers-color-scheme: dark) { | |
| body { | |
| background: black; | |
| color: white; | |
| } | |
| ''' | |
| SOCKET_EXCEPTIONS: list[type[Exception]] = [ | |
| ConnectionResetError, | |
| BrokenPipeError, | |
| TimeoutError, | |
| OSError, | |
| ] | |
| logging.basicConfig() | |
| log: logging.Logger = logging.getLogger() | |
| def _wrap_code( | |
| code: bytes | bytearray, | |
| style: dict[str, str] | None = None, | |
| encoding: str = 'utf-8', | |
| ) -> bytearray: | |
| pre: Element = Element('pre') | |
| pre.text = code.decode(encoding) | |
| log.debug( | |
| 'Wrapping pre element: %s', | |
| etree.ElementTree.tostring( | |
| pre, | |
| method='html', | |
| ), | |
| ) | |
| return bytearray( | |
| _wrap_html_content( | |
| pre, | |
| title='Code Formated', | |
| encoding=encoding, | |
| ), | |
| ) | |
| def _directory_list_item( | |
| text: str, href: str, is_dir: bool = False, is_symlink: bool = False | |
| ) -> Element: | |
| additional_classes: list[str] = [] | |
| href: str = urllib.parse.quote(href, errors='surrogatepass') | |
| text: str = html.escape(text, quote=False) | |
| if is_dir: | |
| additional_classes.append('directory-link') | |
| text = f'{text}/' | |
| href = f'{href}/' | |
| if is_symlink: | |
| additional_classes.append('symlink-link') | |
| text = f'{text}@' | |
| li: Element = Element( | |
| 'li', | |
| attrib={'class': ' '.join(['directory-listing', *additional_classes])}, | |
| ) | |
| anchor: Element = SubElement( | |
| li, | |
| 'a', | |
| attrib={ | |
| 'href': href, | |
| 'class': ' '.join(['directory-listing-link', *additional_classes]), | |
| }, | |
| ) | |
| anchor.text = text | |
| return li | |
| def _wrap_html_content( | |
| *content: Element, | |
| title: str = '', | |
| style: dict[str, str] | None = None, | |
| encoding: str = 'utf-8', | |
| language: str = 'en', | |
| head_content: list[Element | dict[str, dict | str] | str] | None = None, | |
| ) -> bytes: | |
| log.debug('Got content to wrap: %s', content) | |
| script_name: str = pathlib.Path(__file__).stem | |
| doc: Element = Element('html', attrib={'lang': language}) | |
| head: Element = SubElement(doc, 'head') | |
| head.append(Element('meta', attrib={'charset': encoding})) | |
| head.append( | |
| Element( | |
| 'meta', | |
| attrib={ | |
| 'name': 'viewport', | |
| 'content': 'width=device-width, initial-scale=1', | |
| }, | |
| ) | |
| ) | |
| head_title: Element = SubElement(head, 'title') | |
| head_title.text = f'{script_name} {title}' | |
| page_style: Element = SubElement(head, 'style') | |
| page_style.text = DEFAULT_STYLE | |
| for head_item in head_content or []: | |
| if isinstance(head_item, str): | |
| head.append(Element(tag=head_item)) | |
| elif isinstance(head_item, dict): | |
| head.append(Element(**head_item)) # type: ignore | |
| elif isinstance(head_item, Element): | |
| head.append(head_item) | |
| else: | |
| raise TypeError( | |
| 'Got head_content item of unexpected type: ' | |
| f'{type(head_item)}: {head_item}', # nofmt | |
| ) | |
| body: Element = SubElement(doc, 'body', attrib={'class': 'document-body'}) | |
| container: Element = SubElement(body, 'div', attrib={'class': 'content'}) | |
| for elem in content or []: | |
| container.append(elem) | |
| html_bytes: bytes = etree.ElementTree.tostring(doc, method='html') | |
| return f'<!DOCTYPE HTML>\n{html_bytes.decode(encoding)}'.encode( | |
| encoding, | |
| 'surrogateescape', | |
| ) | |
| class DualStackServer(http.server.ThreadingHTTPServer): | |
| """An HTTP server that picks from IPv4 and IPv6.""" | |
| def server_bind(self) -> None: | |
| """Override of `ThreadingHTTPServer.server_bind`.""" | |
| # suppress exception when protocol is IPv4 | |
| with contextlib.suppress(*SOCKET_EXCEPTIONS): | |
| self.socket.setsockopt( | |
| socket.IPPROTO_IPV6, | |
| socket.IPV6_V6ONLY, | |
| 0, | |
| ) | |
| return super().server_bind() | |
| class VirtualHandler(http.server.SimpleHTTPRequestHandler): | |
| """HTTP Request handler that serves static content from memory.""" | |
| content_type: ClassVar[str] = 'application/octet-stream' | |
| content: ClassVar[bytes] = b'<empty content>' | |
| def do_GET(self) -> None: | |
| """Serve a GET request.""" | |
| self.send_response(http.HTTPStatus.OK) | |
| self.send_header('Content-type', self.content_type) | |
| self.send_header('Content-Length', str(len(self.content))) | |
| log.critical('Sending headers: %s', self.headers) | |
| self.end_headers() | |
| log.debug('Sending content: %s', self.content) | |
| self.wfile.write(self.content) | |
| @classmethod | |
| def set_content(cls, content: bytes, content_type: str) -> type[Self]: | |
| """Prepare the handler with content and content type.""" | |
| cls.content = content | |
| cls.content_type = content_type | |
| return cls | |
| class DirectoryHandler(http.server.SimpleHTTPRequestHandler): | |
| """An HTTP request handler that only serves directory listings.""" | |
| target: ClassVar[str] = '.' | |
| def __init__(self, *args, **kwargs) -> None: | |
| if 'directory' in kwargs: | |
| kwargs.pop('directory') | |
| super().__init__(*args, directory=self.target, **kwargs) | |
| def list_directory(self, path) -> io.BytesIO | None: | |
| """Send an HTML directory listing. | |
| A helper to produce a directory listing (absent index.html). | |
| Return value is either a file object, or None (indicating an | |
| error). In either case, the headers are sent, making the | |
| interface the same as for send_head(). | |
| """ | |
| try: | |
| directory_list = os.listdir(path) | |
| except OSError: | |
| self.send_error( | |
| http.HTTPStatus.NOT_FOUND, | |
| "No permission to list directory", | |
| ) | |
| return | |
| directory_list.sort(key=lambda a: a.lower()) | |
| displaypath: str = self.path | |
| displaypath = displaypath.split('#', 1)[0] | |
| displaypath = displaypath.split('?', 1)[0] | |
| try: | |
| displaypath = urllib.parse.unquote( | |
| displaypath, | |
| errors='surrogatepass', | |
| ) | |
| except UnicodeDecodeError: | |
| displaypath = urllib.parse.unquote(displaypath) | |
| displaypath = html.escape(displaypath, quote=False) | |
| encoding: str = sys.getfilesystemencoding() | |
| encoded: bytes = self._get_directory_listing_html( | |
| path, directory_list, displaypath, encoding | |
| ) | |
| f = io.BytesIO() | |
| f.write(encoded) | |
| f.seek(0) | |
| self.send_response(http.HTTPStatus.OK) | |
| self.send_header("Content-type", "text/html; charset=%s" % encoding) | |
| self.send_header("Content-Length", str(len(encoded))) | |
| self.end_headers() | |
| return f | |
| def _get_directory_listing_html( | |
| self, | |
| path: str, | |
| directory_list: list[str], | |
| displaypath: str, | |
| encoding: str, | |
| ) -> bytes: | |
| """Return an HTML directory listing. | |
| Arguments: | |
| path: The original path given to `list_directory`. | |
| directory_list: The output of `os.listdir`. | |
| displaypath: The human facing directory path. | |
| encoding: The output encoding. | |
| """ | |
| title: str = f'Directory listing for {displaypath}' | |
| heading: Element = Element('h1', attrib={'id': 'title'}) | |
| heading.text = title | |
| open_rule: Element = Element('hr', attrib={'id': 'opening-rule'}) | |
| listings: Element = Element( | |
| 'ul', | |
| attrib={ | |
| 'class': 'directory-listing-list', | |
| 'id': 'directory-listings', | |
| }, | |
| ) | |
| for name in directory_list: | |
| fullname = os.path.join(path, name) | |
| displayname = linkname = name | |
| listings.append( | |
| _directory_list_item( | |
| displayname, | |
| linkname, | |
| os.path.isdir(fullname), | |
| os.path.islink(fullname), | |
| ), | |
| ) | |
| close_rule: Element = Element('hr', attrib={'id': 'closing-rule'}) | |
| return _wrap_html_content( | |
| heading, | |
| open_rule, | |
| listings, | |
| close_rule, | |
| title=title, | |
| encoding=encoding, | |
| ) | |
| @classmethod | |
| def set_dir(cls, directory: pathlib.Path) -> type[Self]: | |
| """Set the target directory for a request handler.""" | |
| cls.target = str(directory.absolute()) | |
| return cls | |
| def main() -> None: # noqa: D103 | |
| log.setLevel(logging.DEBUG) | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument( | |
| '-b', | |
| '--bind', | |
| metavar='ADDRESS', | |
| help='bind to this address (default: all interfaces)', | |
| ) | |
| parser.add_argument( | |
| '-t', | |
| '--target', | |
| default=os.getcwd(), | |
| help=( | |
| 'Serve this file or directory (default: current directory). ' | |
| 'If "-" is given stdin is used.' # nofmt | |
| ), | |
| ) | |
| parser.add_argument( | |
| '-p', | |
| '--port', | |
| default=0, | |
| type=int, | |
| help='bind to this port (default: %(default)s)', | |
| ) | |
| parser.add_argument( | |
| '-m', | |
| '--content-type', | |
| '--mimetype', | |
| metavar='MIMETYPE', | |
| default='application/octet-stream', | |
| help=( | |
| 'Mimetype, "text", "code", "html", "binary" (aka ' | |
| '"application/octet-stream" default).' | |
| ), | |
| ) | |
| log.critical('critical') | |
| log.critical('debug') | |
| args: argparse.Namespace = parser.parse_args() | |
| if not args.target: | |
| print( | |
| 'The value of -t|--target must be a path to an existing file or ' | |
| 'directory or "-".', # nofmt | |
| file=sys.stderr, | |
| ) | |
| sys.exit(1) | |
| # Prep content-type | |
| log.critical('content-type given as %s', args.content_type) | |
| if args.content_type == 'binary': | |
| content_type = 'application/octet-stream' | |
| elif args.content_type == 'text': | |
| content_type = 'text/plain' | |
| elif args.content_type in ('code', 'html'): | |
| content_type = 'text/html' | |
| else: | |
| content_type = args.content_type | |
| # Handle target, set request handler accordingly | |
| if args.target == '-': | |
| if not select.select( | |
| [ | |
| sys.stdin, | |
| ], | |
| [], | |
| [], | |
| 0.0, | |
| )[0]: | |
| log.critical('No data in stdin') | |
| sys.exit(1) | |
| content: bytearray = bytearray() | |
| while byte := sys.stdin.buffer.read(1): | |
| content.extend(byte) | |
| if len(content.strip()) == 0: | |
| log.warning( | |
| 'WARNING: Got only a whitespaces on stdin.' | |
| ) # zuban: ignore[unreachable] | |
| else: | |
| log.info('Got %s bytes on stdin.', len(content)) | |
| if args.content_type == 'code': | |
| content = _wrap_code(content, encoding='utf-8') | |
| handler: type[http.server.SimpleHTTPRequestHandler] = ( | |
| VirtualHandler.set_content(content, content_type) | |
| ) | |
| else: | |
| target = pathlib.Path(args.target) | |
| if not target.exists(): | |
| print( | |
| f'`{target.absolute()}` does not exist. A file or directory ' | |
| 'must exist to be shared.', # nofmt | |
| file=sys.stderr, | |
| ) | |
| sys.exit(1) | |
| if target.is_dir(): | |
| handler: type[http.server.SimpleHTTPRequestHandler] = ( | |
| DirectoryHandler.set_dir(target.absolute()) | |
| ) | |
| else: | |
| if args.content_type == 'code': | |
| content: bytearray = _wrap_code( | |
| target.read_bytes(), | |
| encoding='utf-8', | |
| ) | |
| else: | |
| content: bytearray = bytearray(target.read_bytes()) | |
| handler: type[http.server.SimpleHTTPRequestHandler] = ( | |
| VirtualHandler.set_content(content, content_type) | |
| ) | |
| addrinfo: list[Any] = socket.getaddrinfo( | |
| args.bind, | |
| args.port, | |
| type=socket.SOCK_STREAM, | |
| flags=socket.AI_PASSIVE, | |
| ) | |
| DualStackServer.address_family = addrinfo[0][0] | |
| sockaddr: tuple[str, int] | Any = addrinfo[0][-1] | |
| with DualStackServer(sockaddr, handler) as httpd: | |
| host, port = httpd.socket.getsockname()[:2] | |
| if host in ('0.0.0.0', '::'): | |
| host = 'all interfaces' | |
| url_host: str = socket.getfqdn() | |
| elif host.startswith('127.') or host == '::1': | |
| host = 'localhost' | |
| url_host: str = 'localhost' | |
| else: | |
| url_host: str = f'[{host}]' if ':' in host else host | |
| print(f'Serving HTTP on port {port} on {host}') | |
| if os.environ.get('NO_COLOR') != '1': | |
| print('\033[38;5;46m\033[48;5;16m', end='') | |
| print(f'http://{url_host}:{port}/', end='') | |
| if os.environ.get('NO_COLOR') != '1': | |
| print('\033[0m', end='') | |
| print() # Add a newline with or without color | |
| try: | |
| httpd.serve_forever() | |
| except KeyboardInterrupt: | |
| print('\nKeyboard interrupt received, exiting.') | |
| sys.exit(0) | |
| if __name__ == '__main__': | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment