diff --git a/app/api/photos.py b/app/api/photos.py index 394323d..7bacafd 100644 --- a/app/api/photos.py +++ b/app/api/photos.py @@ -47,15 +47,31 @@ def upload_photo(): --------------------- file — binary image data (jpg / png / gif) entity_type — "inspection" | "issue" | "issue_result" (controls subfolder) + captured_at — OPTIONAL ISO-8601 capture time (e.g. 2026-07-20T09:14:22-04:00) + latitude — OPTIONAL decimal degrees at capture + longitude — OPTIONAL decimal degrees at capture + + A capture-time + geo overlay is burned into the image before it is stored + (see app/utils/photo_stamp.py). Metadata is taken from the client fields + above, falling back to the image's EXIF, then to server receipt time. + Sending captured_at/latitude/longitude is strongly preferred for an + offline-first client: a photo taken at 09:14 but synced at 16:00 would + otherwise be stamped with the sync time. Response 200 ------------ { "ok": true, "data": { - "server_path": "uploads/inspection_photos/abc123.jpg" + "server_path": "uploads/inspection_photos/abc123.jpg", + "stamped": true, + "captured_at": "2026-07-20T09:14:22", + "capture_source": "client" } } + + The three stamp keys are additive — an iPad build that predates them decodes + explicit CodingKeys and ignores what it doesn't know. """ user = g.api_user @@ -85,12 +101,37 @@ def upload_photo(): else: subfolder = 'inspection_photos' + # Burn the capture-time + geo overlay before the bytes are ever stored, so + # exactly one (already-stamped) object is written and nothing has to be + # read back out of R2. Any stamping failure returns the original bytes. + meta = {'stamped': False, 'captured_at': None, 'source': None} + if current_app.config.get('PHOTO_STAMP_ENABLED', True): + from app.utils.photo_stamp import stamp_file_storage + file_obj, meta = stamp_file_storage( + file_obj, + captured_at = request.form.get('captured_at'), + latitude = request.form.get('latitude'), + longitude = request.form.get('longitude'), + ) + # Write via the active storage backend (local disk or R2). Key format - # 'uploads//.' is unchanged across backends. + # 'uploads//.' is unchanged across backends. The + # stamped FileStorage keeps the original filename, so the derived key — and + # the tenant prefix applied inside S3Backend — are unaffected. from app.utils import storage server_path = storage.save(file_obj, subfolder) - logger.info('API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s', - entity_type, server_path, user.username) + logger.info( + 'API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s | ' + 'stamped=%s | capture_source=%s', + entity_type, server_path, user.username, + meta.get('stamped'), meta.get('source'), + ) - return api_ok({'server_path': server_path}) \ No newline at end of file + captured_at = meta.get('captured_at') + return api_ok({ + 'server_path': server_path, + 'stamped': bool(meta.get('stamped')), + 'captured_at': captured_at.isoformat() if captured_at else None, + 'capture_source': meta.get('source'), + }) \ No newline at end of file diff --git a/app/utils/photo_stamp.py b/app/utils/photo_stamp.py new file mode 100644 index 0000000..ebae621 --- /dev/null +++ b/app/utils/photo_stamp.py @@ -0,0 +1,357 @@ +""" +app/utils/photo_stamp.py +------------------------ +Burn a capture-time + geolocation overlay into uploaded evidence photos. + +Applied at UPLOAD time (``app/api/photos.py``) rather than on +``PATCH /issues//photos``. At upload the raw bytes and any camera EXIF are +in hand, so nothing has to be read back out of R2, and each upload writes +exactly one already-stamped object. That also keeps stamping clear of the +retry/double-burn hazard the PATCH endpoint would have — it is deliberately +idempotent and re-runnable (CLAUDE.md rule 45), so burning there could stack a +second bar onto an already-stamped image. + +Metadata resolution order +------------------------- +1. Client-supplied ``captured_at`` / ``latitude`` / ``longitude`` — most + reliable for an offline-first app: the iPad knows when and where the shot + was taken even if it syncs hours later. +2. The image's own EXIF ``DateTimeOriginal`` / ``GPSInfo``. +3. Server receipt time (last resort; no geo). + +FAILURE POLICY +-------------- +Stamping must never cost us the photo. Every failure path falls back to +storing the original bytes unmodified — an unstamped photo beats a lost one. + +MULTI-TENANT NOTES (MT-12) +-------------------------- +Ported from the single-tenant tree. This module is tenant-agnostic by +construction and needs no tenancy awareness: + + - It only transforms bytes. It never touches the DB, ``g.tenant``, or a + storage key, so there is nothing here that could leak across tenants. + - ``stamp_file_storage()`` returns a FileStorage carrying the ORIGINAL + ``filename`` and ``content_type``, so ``storage.save()`` derives exactly the + same ``uploads//.`` key it would have derived for the + unstamped upload. The per-tenant ``t/`` prefix is applied further down, + inside ``S3Backend._object_key`` — stamping happens strictly upstream of + that and cannot interfere with it. + - Because the bytes are stamped BEFORE ``storage.save()``, exactly one + already-stamped object is written per upload and nothing is ever read back + out of R2 to be re-encoded. + +Scope matches the single-tenant tree deliberately: only the mobile API upload +path (``app/api/photos.py``) stamps. Web uploads through +``routes/inspections._save_photo`` are NOT stamped, there or here — a desk user +attaching a file has no capture-time or GPS to burn in, and the server receipt +time would be misleading. +""" + +import io +import logging +from datetime import datetime + +from app.utils.time_utils import EASTERN, now_eastern + +logger = logging.getLogger(__name__) + +# Only these are stamped. GIF (possibly animated) and anything exotic passes +# through untouched rather than risking a broken re-encode. +_STAMPABLE_FORMATS = {'JPEG', 'PNG'} + +# Candidate TrueType fonts, in preference order. Pillow does not reliably ship +# a TTF, and the bitmap default is unreadably small on a 4000px photo, so we +# probe the usual Linux locations and degrade gracefully. +_FONT_CANDIDATES = ( + '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', + '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', + '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf', + '/usr/share/fonts/truetype/freefont/FreeSansBold.ttf', + 'C:/Windows/Fonts/arialbd.ttf', + 'C:/Windows/Fonts/arial.ttf', +) + +_EXIF_DATETIME_ORIGINAL = 36867 # 0x9003 +_EXIF_DATETIME_DIGITIZED = 36868 # 0x9004 +_EXIF_DATETIME = 306 # 0x0132 +_EXIF_GPS_IFD = 34853 # 0x8825 + + +# ── Font ────────────────────────────────────────────────────────────────────── + +def _load_font(size): + """Return a TrueType font at *size*, or Pillow's bitmap default.""" + from PIL import ImageFont + for path in _FONT_CANDIDATES: + try: + return ImageFont.truetype(path, size) + except Exception: + continue + try: + # Pillow >= 9.2 can scale the default font. + return ImageFont.load_default(size=size) + except Exception: + return ImageFont.load_default() + + +# ── Metadata extraction ─────────────────────────────────────────────────────── + +def _parse_client_datetime(value): + """Parse a client ISO-8601 timestamp into naive Eastern, or None. + + Accepts offsets and a trailing 'Z'. An offset-aware value is converted to + Eastern; a naive value is taken as already-Eastern wall time. + """ + if not value: + return None + text = str(value).strip() + if not text: + return None + if text.endswith(('Z', 'z')): + text = text[:-1] + '+00:00' + try: + dt = datetime.fromisoformat(text) + except ValueError: + return None + if dt.tzinfo is not None: + dt = dt.astimezone(EASTERN).replace(tzinfo=None) + return dt + + +def _parse_exif_datetime(raw): + """Parse an EXIF 'YYYY:MM:DD HH:MM:SS' string into a naive datetime.""" + if not raw: + return None + try: + return datetime.strptime(str(raw).strip(), '%Y:%m:%d %H:%M:%S') + except (ValueError, TypeError): + return None + + +def _exif_datetime(exif): + """Best available capture time from EXIF, or None.""" + if not exif: + return None + for tag in (_EXIF_DATETIME_ORIGINAL, _EXIF_DATETIME_DIGITIZED, _EXIF_DATETIME): + dt = _parse_exif_datetime(exif.get(tag)) + if dt: + return dt + return None + + +def _dms_to_decimal(dms, ref): + """Convert EXIF degrees/minutes/seconds rationals to signed decimal.""" + try: + deg, minutes, seconds = (float(x) for x in dms) + except (TypeError, ValueError): + return None + value = deg + minutes / 60.0 + seconds / 3600.0 + if str(ref).upper().strip() in ('S', 'W'): + value = -value + return value + + +def _exif_gps(exif): + """Return (lat, lng) decimal degrees from EXIF GPSInfo, or (None, None).""" + if not exif: + return (None, None) + try: + gps = exif.get_ifd(_EXIF_GPS_IFD) + except Exception: + gps = None + if not gps: + return (None, None) + # 1/2 = LatitudeRef/Latitude, 3/4 = LongitudeRef/Longitude + lat = _dms_to_decimal(gps.get(2), gps.get(1)) if gps.get(2) and gps.get(1) else None + lng = _dms_to_decimal(gps.get(4), gps.get(3)) if gps.get(4) and gps.get(3) else None + return (lat, lng) + + +def _coerce_coord(value): + """Parse a coordinate to float, rejecting out-of-range/garbage values.""" + if value is None or value == '': + return None + try: + num = float(value) + except (TypeError, ValueError): + return None + if num != num or abs(num) > 180: # NaN or impossible + return None + return num + + +def resolve_metadata(exif, captured_at=None, latitude=None, longitude=None): + """Resolve (capture_dt, lat, lng, source) from client fields then EXIF. + + ``source`` is one of 'client', 'exif', or 'server' and describes where the + *timestamp* came from — useful for logging and for judging trust later. + """ + dt = _parse_client_datetime(captured_at) + source = 'client' if dt else None + + lat = _coerce_coord(latitude) + lng = _coerce_coord(longitude) + + if dt is None: + dt = _exif_datetime(exif) + source = 'exif' if dt else None + + if lat is None or lng is None: + ex_lat, ex_lng = _exif_gps(exif) + lat = lat if lat is not None else ex_lat + lng = lng if lng is not None else ex_lng + + if dt is None: + dt = now_eastern() + source = 'server' + + return dt, lat, lng, source + + +# ── Overlay rendering ───────────────────────────────────────────────────────── + +def _tz_abbrev(dt): + """EDT/EST label for a naive Eastern datetime.""" + try: + return EASTERN.localize(dt).strftime('%Z') + except Exception: + return 'ET' + + +def _overlay_lines(dt, lat, lng): + """Text lines for the overlay bar.""" + lines = [f"{dt.strftime('%Y-%m-%d %H:%M:%S')} {_tz_abbrev(dt)}"] + if lat is not None and lng is not None: + lines.append(f'{lat:.5f}, {lng:.5f}') + return lines + + +def _draw_overlay(img, lines): + """Draw a translucent bar with *lines* across the bottom of *img*.""" + from PIL import Image, ImageDraw + + if img.mode not in ('RGB', 'RGBA'): + img = img.convert('RGB') + + width, height = img.size + # Scale everything off the short edge so portrait and landscape match. + base = min(width, height) + font_size = max(11, int(base * 0.020)) + pad = max(6, int(base * 0.012)) + font = _load_font(font_size) + + measure = ImageDraw.Draw(img) + + # Measure the block. + heights, widths = [], [] + for line in lines: + box = measure.textbbox((0, 0), line, font=font) + widths.append(box[2] - box[0]) + heights.append(box[3] - box[1]) + line_gap = max(2, int(font_size * 0.25)) + text_h = sum(heights) + line_gap * (len(lines) - 1) + bar_h = text_h + pad * 2 + + # Draw the whole overlay on a transparent layer so both the bar AND the + # text carry alpha, then composite once. Keeps the photo readable through + # the stamp instead of masking it behind a solid strip. + layer = Image.new('RGBA', (width, bar_h), (0, 0, 0, 0)) + draw = ImageDraw.Draw(layer) + draw.rectangle((0, 0, width, bar_h), fill=(0, 0, 0, 80)) + + y = pad + for line, h in zip(lines, heights): + # Faint dark outline still keeps the text legible over a bright photo. + for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)): + draw.text((pad + dx, y + dy), line, font=font, fill=(0, 0, 0, 90)) + draw.text((pad, y), line, font=font, fill=(255, 255, 255, 165)) + y += h + line_gap + + if img.mode == 'RGBA': + img.alpha_composite(layer, (0, height - bar_h)) + else: + img.paste(Image.alpha_composite( + img.crop((0, height - bar_h, width, height)).convert('RGBA'), layer + ).convert('RGB'), (0, height - bar_h)) + + return img + + +# ── Public API ──────────────────────────────────────────────────────────────── + +def stamp_image_bytes(data, captured_at=None, latitude=None, longitude=None): + """Burn the capture-time/geo overlay into *data*. + + Returns ``(out_bytes, meta)``. On any failure — unsupported format, corrupt + image, missing Pillow — returns the ORIGINAL bytes with ``meta['stamped']`` + False rather than raising, so an upload is never lost to a stamping bug. + """ + meta = {'stamped': False, 'captured_at': None, 'latitude': None, + 'longitude': None, 'source': None} + try: + from PIL import Image, ImageOps + + img = Image.open(io.BytesIO(data)) + fmt = (img.format or '').upper() + + try: + exif = img.getexif() + except Exception: + exif = None + + dt, lat, lng, source = resolve_metadata(exif, captured_at, latitude, longitude) + meta.update({'captured_at': dt, 'latitude': lat, + 'longitude': lng, 'source': source}) + + if fmt not in _STAMPABLE_FORMATS: + logger.info('PHOTO STAMP | skipped unsupported format=%s', fmt or '?') + return data, meta + + # Honour the camera's EXIF orientation BEFORE drawing, otherwise the + # bar lands on a rotated edge and the re-encode (which drops EXIF) + # would leave the photo visibly rotated versus the original. + img = ImageOps.exif_transpose(img) + + img = _draw_overlay(img, _overlay_lines(dt, lat, lng)) + + out = io.BytesIO() + if fmt == 'JPEG': + if img.mode != 'RGB': + img = img.convert('RGB') + img.save(out, format='JPEG', quality=88, optimize=True) + else: + img.save(out, format='PNG', optimize=True) + + meta['stamped'] = True + return out.getvalue(), meta + + except Exception as exc: # never lose a photo + logger.warning('PHOTO STAMP | failed, storing original: %s', exc) + return data, meta + + +def stamp_file_storage(file_obj, captured_at=None, latitude=None, longitude=None): + """Return a FileStorage of the stamped image, plus the resolved metadata. + + The result is a drop-in replacement for the incoming upload: it keeps the + original ``filename``/``content_type``, so ``storage.save()`` derives the + same key and works unchanged on both the local and s3 backends. + """ + from werkzeug.datastructures import FileStorage + + file_obj.stream.seek(0) + original = file_obj.stream.read() + + out_bytes, meta = stamp_image_bytes( + original, captured_at=captured_at, latitude=latitude, longitude=longitude + ) + if not meta['stamped']: + file_obj.stream.seek(0) # hand back the untouched upload + return file_obj, meta + + return FileStorage( + stream=io.BytesIO(out_bytes), + filename=file_obj.filename, + content_type=file_obj.content_type, + ), meta diff --git a/config.py b/config.py index 9daa86c..d801262 100644 --- a/config.py +++ b/config.py @@ -80,6 +80,14 @@ class Config: # full verification, so objects exist. R2_MEDIA_FALLBACK = os.environ.get('R2_MEDIA_FALLBACK', 'false').lower() == 'true' + # ── Photo capture-time / geo overlay (MT-12) ──────────────────────────── + # When true (default), POST /api/v1/photos/upload burns a timestamp + GPS + # bar into the image before storing it. Set false to store raw uploads. + # Global, not per-tenant: the overlay is evidence provenance, which every + # tenant wants and none should be able to switch off from the UI. Promote to + # a TenantSettings column only if a tenant ever has a real reason to opt out. + PHOTO_STAMP_ENABLED = os.environ.get('PHOTO_STAMP_ENABLED', 'true').lower() == 'true' + # ── Session / cookies ─────────────────────────────────────────────────── PERMANENT_SESSION_LIFETIME = timedelta(hours=24) # Secure by default — subclasses must explicitly opt out for local dev.