NextGate Media Delivery — Redesign
Target: a safe, scalable media layer for the feed, modelled on patterns observed in TikTok's delivery (signed expiring URLs, CDN edge, variant matrix, batch minting) and mapped onto NextGate's existing media engine (MinIO + RabbitMQ + workers + Redis).
1. What's wrong today
Observed in the current GET /posts (feed) response.
| # | Problem | Why it matters |
|---|---|---|
| 1 | originalUrl is a raw, permanent, unsigned link to s3.nexgate.co |
Post visibility is enforced at the API only. A PRIVATE post's bytes stay reachable at a permanent public URL → media bypasses access control entirely. |
| 2 | Storage origin exposed, no CDN | Every view hits MinIO directly. High latency from East Africa, full egress cost, zero edge cache. |
| 3 | thumbnailUrl: null, placeholderBase64: null |
Full-res images (e.g. 1536×2048) served into feed thumbnail slots. Heavy on expensive mobile data. Blurhash field exists but unused. |
| 4 | Video = raw .mp4, width/height/duration = null |
No transcode, no poster frame, no ladder, no HLS. No adaptive playback. |
| 5 | Per-user path prefix doesn't match owner | e.g. joshdoe's avatar stored under eddiesmith19's stg-acc-{uuid} prefix. Leaks UUIDs and misattributes ownership. |
| 6 | dev.s3.nexgate.co leaking into staging data |
Environment bleed in a live feed response. |
What to KEEP (already good): batch-hydrated feed (one call, all posts + nested quotes +
engagement), permanent shareCode short IDs, graceful deleted-post handling
(unavailable: true), the rich post data model.
The fix is scoped to the media delivery layer, not the data model.
2. Core principle
Two URLs, two lifespans (same split TikTok uses):
- Permanent identity — the post
id/shareCodeand the storageobjectKey. Stored in DB. Shareable forever. - Ephemeral delivery ticket — a signed, expiring CDN URL. Minted on every feed build. Never stored.
Rule: store the objectKey, never store the signed URL.
3. Three delivery tiers
| Tier | Content | Auth gate? | URL signing | Expiry | CDN cache |
|---|---|---|---|---|---|
| 1 — public asset | avatars, thumbnails | no | none (or very long) | n/a / long | aggressive, long TTL |
| 2 — public content, protected delivery | public post images/video | no | yes (short-ish) | hours | careful (see §6) |
| 3 — private content | private/followers posts, paid digital | yes (ownership/visibility check) | yes | minutes | none / private |
The only code difference between tier 2 and tier 3 is whether the permission check runs before minting. Same minting call underneath.
4. Storage layer changes
4.1 Buckets
public— tier 1 assets (avatars, generated thumbnails)private— tier 2 + tier 3 post mediadigital— paid products (tier 3, strictest)
private/{ownerId}/{mediaId}/original.jpg
private/{ownerId}/{mediaId}/thumb_320.webp
private/{ownerId}/{mediaId}/thumb_720.webp
4.2 DB: store keys, not URLs
Persist the storage coordinates and processing state, not a full URL.
// ── MediaEntity (delivery-relevant fields) ──
private String bucket; // "private"
private String objectKey; // "ownerId/mediaId/original.jpg" (opaque, owner-correct)
private MediaType mediaType; // IMAGE | VIDEO
private Integer width;
private Integer height;
private Double duration; // video only
private String placeholder; // blurhash / LQIP — fills the empty placeholderBase64
private MediaStatus status; // PROCESSING | READY | FAILED
// variants: thumb sizes, video rungs, formats — JSONB
private String variantsJson; // {"thumb":["320","720"],"formats":["webp"],"rungs":[...]}
5. Delivery layer changes
5.1 CDN in front of MinIO
Serve everything through media.nexgate.co (Cloudflare) → MinIO origin.
Never return s3.nexgate.co (or dev.s3…) in an API response again.
5.2 Minting helper (SDK does the crypto)
// ── Presigned URL minting (MinIO SigV4) ──
public String mintUrl(String bucket, String objectKey, int seconds) {
return minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(bucket)
.object(objectKey)
.expiry(seconds, TimeUnit.SECONDS) // → X-Amz-Expires + X-Amz-Signature
.build());
// returned host is rewritten to media.nexgate.co at the edge / via MinIO public endpoint
}
5.3 Feed response shape (mint at build time, batch)
Mirror TikTok's batch mint: build the page, mint every media URL inline, ship once. No per-item round trip from the client.
"media": [{
"id": "014ab4eb-...",
"mediaType": "IMAGE",
"url": "https://media.nexgate.co/.../original.jpg?X-Amz-Expires=3600&X-Amz-Signature=...",
"thumbnailUrl": "https://media.nexgate.co/.../thumb_720.webp?...sig...",
"placeholderBase64": "LEHV6nWB2yk8...", // blurhash, no longer null
"width": 945, "height": 2048,
"expiresIn": 3600
}]
// ── In the feed mapper ──
String url = mintUrl(m.getBucket(), m.getObjectKey(), tier2Seconds); // hours
String thumb = mintUrl(m.getBucket(), thumbKey(m, 720), tier2Seconds);
// tier 3 (private/paid): run accessService.canAccess(user, m) first, use short expiry
6. CDN + signed-URL gotcha
Do not let the CDN cache a per-user signed URL under a key that includes the signature, or one user's link can be served to another. Options:
- cache key = path only (strip query/signature), short edge TTL for tier 2; or
- tier 1 (avatars/thumbs) = truly public objects, cache hard; tier 2/3 originals = bypass shared cache or use very short edge TTL.
Tier 1 assets are the ones you cache aggressively. Tier 3 never shares cache.
7. Variant + transcode pipeline (reuse existing media engine)
On upload (presigned PUT direct to MinIO → enqueue job → workers → webhook back):
- ClamAV scan first; reject on hit.
- Image worker: generate
thumb_320+thumb_720(WebP), compute blurhash →placeholder, read width/height. Write variants to JSONB, set status READY. - Video worker: extract poster frame (tier-1 thumbnail), read width/height/duration, transcode to 1–2 MP4 rungs now (e.g. 540p + 720p), HLS later when analytics/scale justify it.
- Redis tracks job state; webhook flips
MediaEntity.statusPROCESSING → READY.
Until a media row is READY, the feed returns the blurhash placeholder + a processing flag
instead of a broken/raw link.
8. Migration sequence (non-breaking)
- CDN — stand up
media.nexgate.co→ MinIO, dual-serve. Stop emittings3.nexgate.co. - Schema — add
bucket/objectKey/status/placeholder/variantsJson. BackfillobjectKeyby parsing existingoriginalUrl; fix owner prefix + dropdev.rows here. - Mint — change feed to mint from
objectKeyinstead of returning raworiginalUrl. Roll out tier 1 + tier 2 first (public) so nothing visibly breaks. - Backfill variants — run image/video workers over existing media; populate thumbs + blurhash.
- Lock the bucket — remove public-read from MinIO; force all access through signed CDN URLs. At this point the visibility-bypass hole (problem #1) is closed.
- Tier 3 — gate private/followers/paid media behind
canAccess+ short expiry.
Closing problem #1 happens at step 5; everything before it is safe groundwork.
9. Security checklist ("safe" part)
- No storage origin host (
s3.nexgate.co,dev.s3…) in any API response. - No user UUID in public URLs (opaque object keys).
- Private/followers post media served tier 3 (auth gate + short signed URL).
- MinIO buckets are not public-read once migration completes.
- Owner prefix on every key matches the real owner.
- Dev/staging/prod storage hosts strictly separated; no cross-env URLs.
No comments to display
No comments to display