Skip to content

Album Sharing over the Local Network

PictoPy can serve a single album over the local network so that someone on the same Wi-Fi can browse it in a normal browser. Nothing is uploaded anywhere — the photos are streamed straight from the host machine, and the share disappears when PictoPy closes.

This page is for developers: running the backend yourself, driving the share API by hand, and understanding how the pieces fit. If you are looking for what sharing means for your photos and your privacy, read Sharing Albums instead.

Sharing an album from the desktop app is a menu item on the album card; everything below describes the machinery underneath it.

Running the backend

Set up the environment once by following the Manual Setup Guide — create the environment, activate it, and pip install -r requirements.txt inside backend/.

After that there are two ways to start the server, both from the backend/ directory:

python main.py

This is the plain entry point. It binds localhost:52123 and is exactly what the packaged desktop app runs, so it is the closest match to production behaviour.

fastapi dev --port 52123

This adds auto-reload, which is nicer while editing. Both work with album sharing.

Check it came up:

curl http://localhost:52123/health

If fastapi dev crashes on Windows

You may see UnicodeEncodeError: 'charmap' codec can't encode character '\U0001f40d'. The FastAPI CLI prints an emoji in its banner and the default Windows console codepage cannot encode it. This is unrelated to PictoPy. Either use python main.py, or set the encoding first:

set PYTHONIOENCODING=utf-8

How sharing is wired

Three listeners, and only one of them is reachable from the network:

Process Address Reachable from
Main backend localhost:52123 this machine only
Sync microservice localhost:52124 this machine only
Share server 0.0.0.0:52125 any device that can route here

The share server is a second FastAPI app running inside the backend process. It starts on demand when the first share is created and stops when the last one is revoked, so no port is left open while nothing is shared. If 52125 is busy it tries the next few ports.

The main backend is never bound beyond localhost, because it exposes shutdown, delete and metadata routes that must not be reachable by other devices. The share server only ever exposes /s/{token} and the two media routes beneath it.

Active shares live in memory only. There is no table and no file: quitting PictoPy ends every share, and no token is ever written to disk.

What a shared photo contains

Thumbnails are PictoPy's own generated JPEGs and are served as they are. Originals go through open_without_pictopy() (app/utils/xmp/service.py), because a PNG may carry the faces, names and album names that metadata export writes into it. Formats PictoPy never writes metadata into are served unchanged.

For a PNG, every XMP chunk is passed through the codec's strip(), and nothing else in the file is touched:

The XMP chunk… What the guest receives
holds no PictoPy data the chunk, byte for byte
holds PictoPy data and other tools' data the chunk rewritten without any pictopy: element or attribute, at any depth
holds only PictoPy data no XMP chunk
cannot be parsed, and cannot hold PictoPy data the chunk, byte for byte
cannot be parsed, and may hold PictoPy data no XMP chunk
is compressed and corrupt no XMP chunk

It fails closed: an XMP chunk is dropped only when it might hold PictoPy's data and cannot be cleaned. Such a chunk "may hold" PictoPy data when any of these appear in its bytes:

  • the namespace https://pictopy.app/ns/1.0/, after resolving numeric character references (h);
  • an entity declaration;
  • NUL bytes, which mean a non-UTF-8 encoding.

Two kinds of chunk get a more conservative test, because their text cannot be extracted: those over 64 MB, which are never held in memory, and those with a broken iTXt header. They are scanned for the namespace, &# or <!ENTITY, in UTF-8, UTF-16 and UTF-32, and dropped if any appears, or if the chunk is compressed.

EXIF, ICC profiles, PNG text chunks, any bytes after IEND, and the pixel data reach the guest unchanged. The file on disk is never modified.

The response is streamed. stream_with_xmp() first walks the chunk headers, reading only XMP chunk bodies, to work out the edits and the exact Content-Length. It then copies the file in 64 KB blocks through the same open handle. Memory per request stays at roughly one block plus the XMP packet, whatever the photo's size.

Caching

Photos and thumbnails are served with:

  • ETag: a hash of the file's mtime (in nanoseconds), its size, and a variant tag;
  • Last-Modified: the file's mtime;
  • Cache-Control: private, no-cache: the browser keeps its copy, but must ask before each reuse.

A request whose If-None-Match matches the current ETag (also as W/, in a list, or *) gets an empty 304. So does one whose If-Modified-Since is not older than the file, but only when there is no If-None-Match. The 304 is answered after the token, password and album-membership checks, so a revoked, expired or locked share returns 404 even to a browser holding a valid ETag. That is what makes revoking a share also stop cached copies from being shown. A 304 never opens the file.

For a streamed PNG, the validators come from fstat on the handle being streamed, so they always describe the bytes actually sent. Bump _PHOTO_VARIANT in app/share/routes.py whenever the bytes served for an unchanged file change, such as a new stripping rule, so browsers stop reusing copies made under the old rule.

Sharing an album by hand

Find an album id

curl http://localhost:52123/albums/

Start a share

curl -X POST http://localhost:52123/share/albums/<album_id> -H "Content-Type: application/json" -d "{}"

Add an expiry in minutes if you want one:

curl -X POST http://localhost:52123/share/albums/<album_id> -H "Content-Type: application/json" -d "{\"expires_in_minutes\": 120}"

The response carries the token and one candidate URL per network interface:

{
  "success": true,
  "message": "Album is now shared on the local network",
  "data": {
    "token": "<share_token>",
    "album_name": "The Oddyseys",
    "image_count": 18,
    "port": 52125,
    "urls": [
      { "interface": "Wi-Fi", "ip": "10.170.93.60", "url": "http://10.170.93.60:52125/s/<share_token>" }
    ]
  }
}

Pick the right URL

The list is ranked best-guess first — the interface holding the default route wins, virtual adapters such as VMware and WSL sort last, and disconnected interfaces sort below live ones. A machine with virtual adapters can easily surface four candidates, so the first entry is a ranked guess, not a guarantee. If the phone cannot load one, try the next.

You can inspect the ranking without creating a share:

curl http://localhost:52123/share/interfaces

Open it

Put the chosen URL into the phone's browser, on the same Wi-Fi. The page needs no app and no account.

List and revoke

curl http://localhost:52123/share/
curl -X DELETE http://localhost:52123/share/<token>

Revoking the last share stops the network listener entirely.

When the phone cannot connect

Work through these in order.

Check the firewall profile, not just the rule. Windows commonly creates Private-only rules, and most Wi-Fi networks are classified Public. A Private-only rule on a Public network fails silently. Rules are per-binary rather than per-port, so a rule covering the Python interpreter or PictoPy_Server covers whatever port sharing lands on.

Get-NetConnectionProfile | Select-Object Name, NetworkCategory

Confirm both devices are on the same network. A laptop on wired Ethernet and a phone on Wi-Fi are often on different subnets. Some networks route between them and some deliberately do not.

Suspect the network itself. Many networks block device-to-device traffic — this is called AP or client isolation, and it is close to universal on guest Wi-Fi and common on corporate networks. If it is enabled, nothing on the PictoPy side can work around it.

To tell an isolated network apart from a broken setup, use a phone hotspot: connect the laptop to the phone's hotspot and share again. If it works there, the code is fine and the other network is blocking peer traffic. USB tethering does the same job and is more reliable still.

Internet mode

Everything above is the local-network path. Reaching a share from outside the network is handled entirely on the Tauri side rather than in this backend: frontend/src-tauri/src/services/tunnel.rs spawns the system ssh client as a reverse tunnel to the share port, parses the assigned URL from its output, and closes it when PictoPy exits.

The backend needs no changes for this — the share server is already bound to 0.0.0.0, so a tunnel forwarding to its port reaches it exactly as a phone on the LAN would. Nothing about tokens, passwords or expiry differs between the two modes.

Running the tests

cd backend && pytest tests/test_share_routes.py tests/test_share_registry.py tests/test_network_utils.py tests/test_xmp_stripping.py

These cover token issue, expiry and revocation, the interface ranking, and the security invariants — most importantly that an image belonging to a different album cannot be fetched through a share token, and that the share server exposes nothing beyond /s/{token}. They also cover revalidation and caching, and — in test_xmp_stripping.py — that no PictoPy metadata survives in a served photo while other tools' metadata and the pixels do.