Skip to content
C.W.K.
Lesson 05 of 05 · published

Photos: the Original Kept, Copies Stripped

~14 min · photos, exif, privacy, uploads

Level 0Mise en Place
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Three files per photo

Photos are the one part of the record too big for the journal. The journal holds a photo's facts; the bytes live under photos/<encounter>/, three files per photo, all written by PhotoStore.ingest:

FileWhat it isWho sees it
<id>.origThe uploaded bytes, untouched, EXIF and GPS includedThe engine. The photo route can serve it, and neither client asks for it
<id>.display.jpgUpright, at most 1600 px on the long edge, JPEG quality 85, no EXIFThe plate pages, and Pippa
<id>.thumb.jpgThe same at most 480 px, quality 80Lists and cards

Each file goes through the kit's atomic_write_bytes, so a crash leaves either the old bytes or the new ones, never half a file. Before any of that, ingest refuses what it cannot safely hold: an empty file, more than 60 MB, more than 120 million pixels, or anything Pillow cannot open. HEIC straight from an iPhone works when pillow_heif is installed. Because a stored photo never changes, the route serves it with a year-long immutable cache header, and clients may keep it forever.

Read the facts before the pixels change

The order inside ingest is the important part. It opens the image and reads its EXIF first: DateTimeOriginal with its offset when the camera wrote one, and GPS turned into a latitude and longitude rounded to six decimals. Only then does it call ImageOps.exif_transpose to stand the picture upright and convert it to RGB. The facts go into the photo's record, the record goes into encounter.captured, and the mirror's photos table keeps taken_at and gps for each one.

Those two facts are what lesson 4's capture path uses. _capture_moment prefers the camera's own clock, so a photo picked from the library days later still lands on the evening it was taken. And the first photo carrying its own GPS becomes the plate's place, ahead of whatever the client sent.

What the copies carry, and what they do not

_jpeg makes each derived copy: it copies the upright image, shrinks it with Lanczos, and saves a JPEG without passing any EXIF, so Pillow writes none. I checked it against the engine's pinned Pillow, 12.1.1, by feeding it a photo carrying a camera tag, a GPS block, an XMP packet, an ICC profile and a JPEG comment. The derived JPEG came back with an empty EXIF, no XMP and no ICC profile. The comment came back intact. Pillow keeps a JPEG's comment in the image's info, the upright copy inherits it, and the JPEG writer saves whatever comment it finds there unless it is told otherwise. Phone cameras rarely write one, but an edited file can, and whatever it says rides into both copies. So the honest description is no EXIF, not no metadata. The engine's own test checks the part that matters most: the display copy has no GPS, and the original still does.

Those copies are what leaves the engine. The reading worker sends Pippa the display copies, and conversation turns attach the same. The text Pippa gets about a plate carries its time, kind and Dad's note, and no place field. So the GPS the camera recorded never reaches her. That promise is narrower than it sounds. It covers EXIF and XMP, the blocks where cameras write location, and the JPEG comment above sits outside it: if an editing app wrote a place into the comment, that place rides along in both copies. Dad's note and his corrections travel exactly as he typed them, so a place he writes there goes along, and the pixels themselves can show a shop sign or a printed menu. The engine drops EXIF, XMP and ICC from what travels, and leaves the comment, the picture and Dad's words as they are. The camera's GPS stays in two places: the original file and the plate's own record.

Principle: Keep everything on the way in. Strip the copies that travel. Strip the source only at a public boundary.

The phone's upload keeps its EXIF now

The phone does not send the camera file. It downscales each photo first with the kit's ImageDownscale, to 2048 px on the long edge, because that size is measured to keep a vision turn fast. Until kit todo #548 the re-encode dropped all metadata on purpose, so the phone read the time and place from the original and sent them as form fields. On 2026-10-19 Dad overruled that for the whole family:

CWK 패밀리 앱에서 Public Facing 부분을 빼고는 모든 게 아빠 개인 앱이야. 그런데 Forge도 마찬가지고 그 메타데이터를 날려버리면 복구할 방법이 없는데 왜 미리 날려버리냐고. Public Facing으로 공개할 때나 제거를 하는 거지. 날려버린 메타데이터는 복구를 못 해.

Outside the public-facing parts, every CWK family app is Dad's own app, Forge included. Metadata thrown away early can never be recovered, so why throw it away in advance? You remove it when something goes public, and not before.

Since kit commit 71a29b9, which Sizzle followed in e5d76ff, the kit's carriedMetadata copies the EXIF, GPS, TIFF and IPTC groups into the downscaled JPEG, resetting the orientation to 1 and the pixel dimensions to the new ones. So the .orig of a phone plate is a 2048 px JPEG that still knows when and where it was taken, and the engine reads those facts itself. The phone still sends captured_at and lat/lon from the original as well. The web sends the picked file as it is, so a web plate's .orig is the camera file.

Code wins over comments here. Four places still describe the old behaviour: the docstring of Sizzle.capture, the place comment in the phone's Outbox.swift, a comment in test_capture.py, and the architecture doc's phone-capture paragraph. CaptureMetadata.swift and its test are current. What never changed is the engine's side, the house sign's invariant 6: derived copies carry no metadata. In code that holds for EXIF, XMP and ICC, with the JPEG comment above as its one exception.

Staged, claimed, released

Since kit todo #545 a photo travels on its own request before the plate does. POST /api/staged-uploads takes a client_key and one file. The staged id is the first 32 hex characters of the key's SHA-256, so a retry after an engine restart finds the same file, and the same key a second time answers with the first copy and writes nothing. Bytes and metadata are written to .part files and moved into place with os.replace, and the route enforces the 60 MB cap while it reads, a megabyte at a time.

The plate then names its staged ids. take reads them without deleting anything. Only after the plate has committed does the route call release, so a crash between the two leaves a retry that still works. Anything nobody claims for seven days is swept on the next upload. A plate naming an id that is gone is refused with a message saying it is not here, expired or never sent, and the phone's outbox catches exactly that refusal, forgets its staged ids, and sends the photos again on its next pass.

Code

cwkSizzle, backend/photos.py: PhotoStore.ingest, facts read before the transform, three files written (verbatim, lines 55-86)·python
    def ingest(self, encounter_id: str, photo_id: str, filename: str, data: bytes) -> dict[str, Any]:
        """Validate, store and derive one upload. Blocking; run in a thread."""
        if not data:
            raise PhotoError(f"{filename}: empty file")
        if len(data) > MAX_BYTES:
            raise PhotoError(f"{filename}: larger than {MAX_BYTES // (1024 * 1024)} MB")
        try:
            source = Image.open(io.BytesIO(data))
            source.load()
        except (UnidentifiedImageError, OSError, Image.DecompressionBombError) as exc:
            raise PhotoError(f"{filename}: not a readable image") from exc
        with source:
            if source.width * source.height > MAX_PIXELS:
                raise PhotoError(f"{filename}: image dimensions are too large")
            taken_at, gps = _exif_facts(source)
            upright = ImageOps.exif_transpose(source).convert("RGB")
        width, height = upright.size
        directory = self._dir(encounter_id)
        directory.mkdir(parents=True, exist_ok=True)
        atomic_write_bytes(self.path(encounter_id, photo_id, "original"), data)
        atomic_write_bytes(self.path(encounter_id, photo_id, "display"), _jpeg(upright, DISPLAY_EDGE, 85))
        atomic_write_bytes(self.path(encounter_id, photo_id, "thumb"), _jpeg(upright, THUMB_EDGE, 80))
        return {
            "id": photo_id,
            "filename": Path(filename or "photo").name[:200],
            "media_type": Image.MIME.get(source.format or "", "application/octet-stream"),
            "width": width,
            "height": height,
            "sha256": hashlib.sha256(data).hexdigest(),
            "taken_at": taken_at,
            "gps": gps,
        }
cwkSizzle, backend/photos.py: _jpeg, a derived copy saved without EXIF (verbatim, lines 92-97)·python
def _jpeg(image: Image.Image, edge: int, quality: int) -> bytes:
    copy = image.copy()
    copy.thumbnail((edge, edge), Image.Resampling.LANCZOS)
    out = io.BytesIO()
    copy.save(out, format="JPEG", quality=quality, optimize=True)
    return out.getvalue()
cwkSiblingKit, CwkKit/Sources/CwkKitMedia/ImageDownscale.swift: carriedMetadata, the phone's downscale keeps what the photo knew (verbatim, lines 188-213)·swift
  /// The original's metadata as the new pixels can truthfully carry it. Everything in
  /// `carriedGroups` is copied; what describes the OLD pixels is rewritten: the
  /// orientation becomes 1, because the thumbnail was drawn with its transform applied
  /// (a kept 6 would turn an upright picture sideways), and EXIF's pixel dimensions
  /// become the new ones.
  static func carriedMetadata(_ properties: [CFString: Any]?, width: Int, height: Int)
    -> [CFString: Any]
  {
    var carried: [CFString: Any] = [kCGImagePropertyOrientation: 1]
    guard let properties else { return carried }
    for group in carriedGroups {
      if let value = properties[group] as? [CFString: Any], !value.isEmpty {
        carried[group] = value
      }
    }
    if var tiff = carried[kCGImagePropertyTIFFDictionary] as? [CFString: Any] {
      tiff[kCGImagePropertyTIFFOrientation] = 1
      carried[kCGImagePropertyTIFFDictionary] = tiff
    }
    if var exif = carried[kCGImagePropertyExifDictionary] as? [CFString: Any] {
      exif[kCGImagePropertyExifPixelXDimension] = width
      exif[kCGImagePropertyExifPixelYDimension] = height
      carried[kCGImagePropertyExifDictionary] = exif
    }
    return carried
  }

External links

Exercise

In an app of yours that accepts photos, store the uploaded bytes untouched and derive a display copy and a thumbnail. Read the capture time and GPS before you transform anything, and save them with the record. Then write a test that uploads a JPEG with a synthetic GPS block (for example lat 12.5, lon 34.5) and asserts the derived copies have no EXIF while the stored original still has its GPS.
Hint
With Pillow, build the test image's EXIF with Image.Exif and get_ifd(ExifTags.IFD.GPSInfo), and save it with exif=. Check the copies with getexif() after reopening them from bytes, and give the test image a comment= too: a copy can drop its EXIF and still carry the comment. Apply exif_transpose before resizing, or portrait photos come out sideways.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.