Back to blog
FILE 0x2E·THREE THINGS BREAK WHEN A SCREENSHOT UPLOADER STARTS TAKING

Three things break when a screenshot uploader starts taking video

July 28, 2026 · ios, http, python

I have a small drop site for screenshots. Paste, drag, or tap to choose; the file lands on disk; a puller grabs it later. About 300 lines of BaseHTTPRequestHandler. It has worked fine for a year.

Then I wanted to send it a video, and found the picker wouldn't let me. On iOS, tapping "Photo Library" on a file input shows every video greyed out unless the input's accept list says video is welcome:

// before — iOS hides/greys every video in the library sheet
chooser.accept = 'image/*';

// after
chooser.accept = 'image/*,video/*,video/quicktime,video/mp4,.mov,.mp4,.m4v';

The wildcards are what the picker filters on. The concrete types and extensions are for the Files app, where some UTIs come back as application/octet-stream and a wildcard-only list drops them.

That's the whole fix, and it's also where the interesting part starts, because the moment a video can actually reach the server two latent bugs stop being latent.

The parser read the whole body into RAM

body = self.rfile.read(length)
for part in body.split(b"--" + boundary):
    ...

Perfectly reasonable for a 2MB PNG. My box has 1GB of RAM. A phone video is 150–400MB, and .read(length) means resident bytes — plus the copies split() makes. First real upload would have been an OOM kill.

So the part body streams to disk instead. The trick is that you can't recognise the terminating boundary if you flush your whole buffer each pass, so you hold back a tail slightly longer than the delimiter:

def drain_part(buf, sink):
    marker = b"\r\n--" + boundary
    keep = len(marker) + 4
    while True:
        idx = buf.find(marker)
        if idx != -1:
            sink and sink.write(buf[:idx])
            return buf[idx + len(marker):], False
        if len(buf) > keep:              # flush all but a possible partial match
            sink and sink.write(buf[:-keep])
            buf = buf[-keep:]
        chunk = read_chunk()
        if not chunk:
            sink and sink.write(buf)
            return b"", True
        buf += chunk

sink=None drains a part you don't want, which is how ordinary form fields get skipped without buffering them either. 150MB upload after the change: peak process memory on the container moved from 41MB to 43MB.

Safari won't play a video off a server that ignores Range

The file handler did data = f.read() then sent it with a Content-Length. Images don't care. Video does: Safari sends a HEAD, then a Range: bytes=0-1 probe, and if it doesn't get Accept-Ranges and a 206 back it declines to play the file at all — no error, just a dead player.

BaseHTTPRequestHandler also answers HEAD with 501 Not Implemented unless you define it, which is its own small trap:

do_HEAD = do_GET   # and skip the body write when self.command == "HEAD"

Then parse the header, seek, and write in chunks:

if rng.startswith("bytes="):
    s, _, e = rng[6:].split(",")[0].partition("-")
    start = int(s) if s else max(0, size - int(e))   # suffix form: bytes=-500
    end = int(e) if (e and s) else size - 1
    ...
    self.send_header("Content-Range", f"bytes {start}-{end}/{size}")

The suffix form (bytes=-500, meaning the last 500 bytes) is the one people forget, and it's exactly what a player uses to find the moov atom at the tail of a QuickTime file.

Two smaller ones

The gallery renders each item as <img src="/thumb/id">, and the thumbnailer fell back to serving the original file whenever Pillow couldn't open it. Pillow can't open an MOV — so the fallback would have quietly pulled 400MB into an <img> tag and still rendered nothing. Videos now get an ffmpeg poster frame, or a placeholder SVG if ffmpeg is missing, and never the fallback.

And the reverse proxy in front had client_max_body_size 60M, which would have turned every real phone video into a 413 well before any of the above mattered.

What I'd do differently

I'd have grepped for .read( and accept= before touching anything else. Every one of these was a size assumption baked in when the only input was a screenshot: the parser assumed small, the file server assumed download-not-stream, the thumbnailer assumed decodable, the proxy assumed 60MB. Changing the input type didn't break the feature I edited — it broke four things I didn't.