# Durable Channel-Media Persistence
Channel media (images, PDFs, video, audio shared in Slack / Discord / Microsoft
Teams / Mattermost) used to be **link-only**: ingestion downloaded the bytes to
run extraction, then kept only the platform CDN URL. Those URLs rot — Discord
signed URLs expire in ~24 h, and Slack / Teams / Mattermost URLs need a live bot
token forever — so media became unviewable over time.
This feature keeps a **durable copy of the bytes** and serves them back, while
keeping the platform URL as the identifier everywhere (facts, wiki, frontend,
and `channel_messages` are unchanged). A dead platform link still renders,
because your own server hands back the stored copy.
---
## How it works
The platform URL is used as a **lookup key**, not a fetch target. Bytes live in
a content-addressed blob store; a small `channel_media_refs` collection in Mongo
maps each URL's stable `host+path` to its blob.
```
INGEST (best-effort — never blocks extraction)
download bytes ──► Gemini extraction
└──► save_blob: sha256(bytes) ──► key "channels/{channel_id}/{sha256}"
├─ BlobBackend.put(key, bytes) → GridFS | MinIO/S3
└─ channel_media_refs.upsert { url_key = host+path , channel_id , sha256 , mime , size }
(query string dropped → survives re-signing; Telegram URL → not indexed)
SERVE browser renders
proxy ─ normalize url → url_key ─ find ref ─ authorize channel ─ open blob
HIT → stream stored bytes (header X-Media-Source: store)
MISS → fetch the platform origin (header X-Media-Source: origin)
PURGE channel deletion → delete_prefix("channels/{channel_id}/") + drop its refs
BACKFILL re-fetch + save_blob for media ingested before this shipped (resumable, idempotent)
```
**Why the platform URL stays:** rewriting every stored URL to an internal link
would force a migration of every fact / wiki page / message, a wiki recompile,
and would break the platform-URL-keyed dedup and frontend matching. Keeping the
URL as the key makes persistence purely **additive and reversible** (a flag),
with no data migration. The read-through proxy is the indirection that turns the
platform URL into local bytes.
**The metadata/byte split** is what makes the backend swappable:
| Layer | Lives in | Responsibility |
| --- | --- | --- |
| `channel_media_refs` + `url_key` + dedup + Telegram guard | **always MongoDB** | identity / lookup / per-channel scoping |
| Raw bytes (`BlobBackend`) | **GridFS** *or* **MinIO/S3** | storage only, keyed by `channels/{channel_id}/{sha256}` |
---
## Backends
| | **GridFS** (default) | **MinIO / S3** |
| --- | --- | --- |
| Stores bytes in | MongoDB (`channel_media` bucket) | Object storage |
| Extra infra | none | a MinIO container, or an S3 bucket |
| Best for | OSS / self-host / low volume | Enterprise / production at scale |
| Trade-off | bytes bloat the DB (backups, cache, replication) at volume | keeps the DB small; storage scales independently |
`channel_media_refs` always lives in Mongo regardless of backend — only the
bytes move.
> **Why GridFS by default?** Zero extra infrastructure for a self-hoster. At
> production volume, media in Mongo inflates backups and competes with the
> operational working set for cache — that is when MinIO/S3 earns its keep.
---
## Configuration
All settings have safe defaults; the feature is **on** out of the box on GridFS.
| Env var | Default | Purpose |
| --- | --- | --- |
| `CHANNEL_MEDIA_PERSIST` | `true` | Persist media bytes at ingestion (kill switch). |
| `CHANNEL_MEDIA_READ_THROUGH` | `true` | Serve stored bytes via the proxy (kill switch). |
| `CHANNEL_MEDIA_BACKEND` | `gridfs` | `gridfs` or `minio`. |
| `CHANNEL_MEDIA_MINIO_ENDPOINT` | `http://localhost:9000` | MinIO endpoint. **Leave empty for real AWS S3.** |
| `CHANNEL_MEDIA_MINIO_ACCESS_KEY` | — | Access key. |
| `CHANNEL_MEDIA_MINIO_SECRET_KEY` | — | Secret key. |
| `CHANNEL_MEDIA_MINIO_BUCKET` | `atlas-media` | Bucket name (must be **private**). |
| `CHANNEL_MEDIA_MINIO_REGION` | `us-east-1` | Region (used for S3). |
| `CHANNEL_MEDIA_MINIO_SECURE` | `false` | `true` for HTTPS — **set `true` whenever MinIO is not on localhost.** |
| `MEDIA_MAX_FILE_SIZE_MB` | `20` | Cap for non-video files; larger are skipped. |
| `MEDIA_VIDEO_MAX_SIZE_MB` | `100` | Cap for video. |
---
## Using MinIO / S3
### Local MinIO (Docker)
1. In `.env`:
```bash
CHANNEL_MEDIA_BACKEND=minio
CHANNEL_MEDIA_MINIO_ENDPOINT=http://minio:9000 # service name inside compose
CHANNEL_MEDIA_MINIO_ACCESS_KEY=
CHANNEL_MEDIA_MINIO_SECRET_KEY=
CHANNEL_MEDIA_MINIO_BUCKET=atlas-media
```
2. Start the profile-gated MinIO (also creates the private bucket):
```bash
docker compose --profile minio up -d
```
3. Restart the app so it picks up `CHANNEL_MEDIA_BACKEND=minio`.
The default `docker compose up` (no profile) does **not** start MinIO, so the
GridFS default stays zero-infra. MinIO console: `http://localhost:9001`.
### Production AWS S3 (EE)
Leave `CHANNEL_MEDIA_MINIO_ENDPOINT` **empty** so the client targets AWS S3, set
`CHANNEL_MEDIA_MINIO_REGION` + credentials (or an IAM role), keep the bucket
**private**, and set `CHANNEL_MEDIA_MINIO_SECURE=true`.
> **Security:** the bucket must stay private and be served only through the
> authenticated proxy. A public bucket would trade the DB-bloat problem for a
> data-exposure one.
---
## Operations
### Serving endpoints
| Endpoint | Notes |
| --- | --- |
| `GET /api/media/proxy?url=&channel_id=` | Read-through for media. Pass `channel_id` for an exact, authorized lookup. |
| `GET /api/files/proxy?url=&connection_id=&channel_id=` | Read-through for file attachments (renders `
` etc.). |
Both authorize the caller against the channel that holds the media (so one
principal cannot read another channel's media), set `nosniff` + a CSP sandbox,
and report `X-Media-Source: store` (served from the blob store) or `origin`
(fetched live as a fallback).
### Backfilling already-ingested channels
Media ingested **before** this shipped has no stored copy. Backfill re-fetches
and stores it (anything still reachable; already-expired URLs cannot be
recovered).
```bash
# dry-run first — counts what WOULD be stored, writes nothing
curl -s -H "X-Admin-Token: $ADMIN_TOKEN" -H "Content-Type: application/json" \
-X POST "$API/api/admin/channels/$CH/backfill-media" -d '{"dry_run": true}'
# real run
curl -s -H "X-Admin-Token: $ADMIN_TOKEN" -H "Content-Type: application/json" \
-X POST "$API/api/admin/channels/$CH/backfill-media" -d '{"dry_run": false}'
```
Body: `dry_run` (bool) and `max_messages` (1–5000, default 500). The run is
**idempotent** (already-stored media is skipped) and **resumable** (it persists
a cursor in `media_backfill_state` and continues from it).
### Stats
```bash
curl -s -H "X-Admin-Token: $ADMIN_TOKEN" "$API/api/admin/media/stats"
# {"total_blobs": N, "total_bytes": N, "total_refs": N}
```
A production smoke test covering ingestion, read-through, link-rot, the channel
ACL, and backfill lives at
[`scripts/smoke_test_media_persistence.sh`](../scripts/smoke_test_media_persistence.sh).
---
## Behaviour & limits
- **Best-effort persistence.** A storage failure never fails ingestion — the
bytes were already downloaded for extraction; persisting them is a side
effect, deduped per `(sha256, channel_id)`.
- **Link-rot is defeated going forward, not retroactively.** Media ingested
after this is durable; old media is rescued by backfill only if its URL is
still reachable. **Discord** URLs expire ~24 h, so old Discord media is
usually unrecoverable — backfill it with fresh posts, not old history.
- **Telegram** file URLs embed the bot token in the path, so they are never
indexed (no ref, no read-through) — by design.
- **Size caps** (`MEDIA_MAX_FILE_SIZE_MB` / `MEDIA_VIDEO_MAX_SIZE_MB`) are
enforced before buffering; oversized media is skipped.
---
## Troubleshooting
**Backfill reports `download_failed` for everything.** The bot can't fetch from
the platform — usually a missing/stale adapter registration (after a bot
restart the in-memory adapters are lost while the connection still shows
"connected"). Re-register from the stored (encrypted) credentials:
```bash
curl -s -X POST "$API/api/connections//validate" \
-H "Authorization: Bearer "
```
The bot log should show `bot rebuilt with adapters: …:…`.
**Backfill scans 0 messages on a re-run.** A previous run advanced the resume
cursor. The admin endpoint has no `reset`, so clear it:
```bash
mongosh --eval 'db.media_backfill_state.deleteMany({_id:{$regex:""}})'
```
**`X-Media-Source: origin` instead of `store`.** The media isn't stored yet
(persisted off, or never backfilled), so the proxy fell back to the live
platform. Backfill the channel, or confirm `CHANNEL_MEDIA_PERSIST=true`.
**Media `403`s through the proxy.** The caller lacks access to the channel that
holds that media — expected (the proxy enforces per-channel access on a hit).