Move quiz media to a private bucket and gate reads at the edge.
Signed cookie gate for lesson media PLANNED
Today course content sits in a public R2 bucket behind static.skillpixel.vn. Keys are random, so brute force is not a risk, but any leaked URL works forever. We put a Cloudflare Worker at /media/* on every tenant host, move lesson content to a private bucket, and only serve requests that carry a signed cookie. Marketing assets stay public on purpose.
| Estimate | Value |
|---|---|
| Engineer days | 8 to 11 |
| Calendar for one developer | 2 to 3 weeks |
1What is in the public bucket today FACT
Prod inventory, 3 tenants, queried 2026-09-03.
| Content | Count | Public R2 today? | Plan |
|---|---|---|---|
| Questions with images | 4,225 | Yes, all under tenants/ keys, served from static.skillpixel.vn | Gate |
| Questions with audio | 121 | Yes | Gate |
| Questions with inline base64 images | 671 | No, stored in the DB | Out of scope |
| Lesson attachments, resources JSON | 89 | No, all external links, zero uploaded files | Gate new uploads when a file upload flow exists |
| Reading lessons with images | 3 | Negligible | Gate new uploads |
| Course covers | 17, 10 tenant keys plus 7 legacy keys | Yes | Stay public |
| Slide lessons | 0 | Already in the private slide bucket with 5-minute presigned GET URLs | Unchanged |
| Video lessons | 36 | Bunny CDN, not R2 | Check Bunny token authentication is on |
| Student submissions: assignment, practice, grading artefacts | not counted | Yes | Gate in follow-up SP-676 |
2Threat model
Brute force
A private bucket does not change this, and it was never a real risk. Keys end in a 21-character nanoid and the tenant id is a random UUID, over 120 bits of randomness per file. R2 public buckets do not allow listing.
Leaked and shared links
- A student shares a link after leaving.
- A scraper with one paid account mirrors a tenant.
- Hot-linking that costs us bandwidth.
Be honest: an enrolled student can always download what they can see. No storage setup changes that. The question is only whether leaked links keep working.
3Why the tenant prefix in keys is fine
The tenant id comes from gen_random_uuid() and the API already returns it on the user profile. The bucket does not list, and the prefix is not an access control. The gate uses the prefix only to match the cookie tenant against the key tenant.
tenant_object_key in apps/backend/app/services/storage.py is the only place a tenant id enters a key. Never put a slug or hostname in a key.4Architecture
A Worker on the tenant host reads a private bucket. Auth runs before the cache. Uploads do not change.
Pieces
- Private R2 bucket. Public access off. The Worker reads it through an R2 binding, so no S3 keys leave Cloudflare. Reuse the slide bucket or add a media bucket per env. SP-670 decides.
- Route. Worker mounted at
/media/*on every tenant host. Tenant hosts already go through Cloudflare, both the*.skillpixel.vnwildcard and Phase 4 custom domains via Cloudflare for SaaS. Stored URLs become path-only:/media/tenants/{tenant_id}/quiz-images/{quiz_nanoid}/{file}.png - Signed token and cookie. After Firebase login the frontend calls the backend, which checks tenant membership and returns a token
{tenant_id, user_id, exp}with an HMAC. The frontend POSTs the token to/media/sessionon the current host and the Worker sets thesp_mediacookie. HttpOnly, Secure, SameSite=Lax, Path=/media, about 30 minutes. - Why the Worker sets the cookie, not the API. The API host is not the tenant host. A Set-Cookie from the api host with
Domain=.skillpixel.vnreaches wildcard tenants but never a custom domain likelearn.partner.com. The cookie must be first-party on the page host. - Refresh. Timer at two thirds of cookie life, paused when the tab is hidden, plus a one-shot refresh and retry when an image or audio element errors with 401. This piggy-backs on the Firebase token renewal the frontend already does.
- Uploads unchanged. Presigned PUT still writes to the bucket. Only reads change.
Login and first image load
sequenceDiagram
participant B as Browser tenant-a.skillpixel.vn
participant API as Backend API
participant W as Worker
participant R2 as R2 bucket
B->>API: Firebase login, POST /media/session, bearer
API->>API: membership active? sign {tenant, user, exp}
API-->>B: token JSON
B->>W: POST /media/session {token}
W-->>B: Set-Cookie sp_media, HttpOnly, Secure, Path=/media, 30 min
B->>W: GET /media/tenants/A/quiz-images/x/y.png, cookie
W->>W: verify HMAC, exp, tenant A == key prefix
W->>W: edge cache lookup
alt cache miss
W->>R2: get(key)
R2-->>W: bytes
W->>W: store in cache
end
W-->>B: 200, Cache-Control private, max-age=300
Worker decision per request
flowchart TD
A["Request /media/tenants/T/..."] --> B{cookie present?}
B -- no --> X1[401]
B -- yes --> C{HMAC valid and not expired, 60s slack?}
C -- no --> X2[401]
C -- yes --> D{cookie tenant == T?}
D -- no --> X3[403]
D -- yes --> E{edge cache hit?}
E -- yes --> S[serve]
E -- no --> F[read R2 binding]
F --> G{exists?}
G -- no --> X4[404]
G -- yes --> H[store in cache] --> S
Leaked link
sequenceDiagram
participant S as Student
participant O as Outsider
participant W as Worker
S->>O: shares link
O->>W: GET /media/... without cookie
W-->>O: 401
Note over O,W: no cookie without a tenant login
Refresh
sequenceDiagram
participant F as Frontend
participant API as Backend
participant W as Worker
Note over F: timer fires or Firebase token renewed
F->>API: POST /media/session
API->>API: membership check
API-->>F: token
F->>W: POST /media/session
W-->>F: Set-Cookie sp_media
Note over F: laptop slept, cookie expired
F->>W: GET image
W-->>F: 401
F->>API: refresh once
API-->>F: token
F->>W: retry GET image
W-->>F: 200
5Cookie and token spec
| Field | Value |
|---|---|
| Name | sp_media |
| Payload | tenant_id, user_id, exp in unix seconds, optional nonce |
| Signature | HMAC-SHA256 over the payload with a secret shared between backend, in Infisical, and Worker, as a wrangler secret |
| Flags | HttpOnly, Secure, SameSite=Lax, Path=/media |
| Max-Age | 1800 s |
| Refresh | about 1200 s |
| Clock slack | 60 s on the Worker |
| Rotation | New cookies, handled by the refresh call |
6Why not presigned URLs everywhere
Presigned GET works for slides because pages are fetched fresh per view. For images inside stored HTML you would rewrite every URL on every read, and browsers cannot cache a URL that changes each time. The cookie gate keeps stored HTML stable and keeps edge and browser caching.
7What stays public
Course covers, tenant branding, banners, blog images, instructor avatars, hall of fame. Landing pages and search engines need them. Nothing moves for these.
8Work plan
Total 8 to 11 engineer days for phases 0 to 5. Worker and frontend can run in parallel with backend.
| Phase | Ticket | What | Effort | Depends on |
|---|---|---|---|---|
| 0 | SP-670 | Spike: Worker route on Cloudflare for SaaS custom hostnames, bucket choice, HMAC secret in Infisical and Worker | 0.5 to 1 day | none |
| 1 | SP-671 | Media Worker: cookie parse, HMAC, tenant prefix match, R2 binding, edge cache, Range support, wrangler config, CI deploy, vitest and miniflare tests | 1.5 to 2 days | SP-670 |
| 2 | SP-672 | Backend: session token endpoint with membership check; gated write path in the storage service returning /media/ URLs; switch quiz image and audio uploads; update validators in rich_text.py, key parsers, delete helpers, clone path to the private bucket | 2.5 to 3 days | SP-670 |
| 3 | SP-673 | Frontend: refresh on Firebase token renewal plus timer; error listener and one-shot retry; logout clears cookie; local dev via wrangler dev behind the Vite proxy or a dev-only backend route | 1.5 to 2 days | SP-671, SP-672 |
| 4 | SP-674 | Migration: server-side copy of quiz objects to the private bucket, rewrite URLs in question text, explanation, options, resources; dry run, rollback file, beta then prod; delete public copies after a soak | 1 to 1.5 days | SP-672 |
| 5 | SP-675 | Rollout: beta walkthrough on wildcard and custom domain, Safari audio Range check, PostHog watch, prod release, public bucket cleanup | 1 day | SP-673, SP-674 |
| Follow-up | SP-676 | Gate student submissions, assignment, practice, grading artefacts, the same way | about 2 days | SP-675 |
| Related | SP-677 | Hard-copy R2 assets on cross-tenant course copies, licensing and marketplace: course image, lesson description images, file resources | about 2 days | SP-672 |
Dependency graph
flowchart LR
SP670["SP-670 Spike"] --> SP671["SP-671 Worker"]
SP670 --> SP672["SP-672 Backend"]
SP671 --> SP673["SP-673 Frontend"]
SP672 --> SP673
SP672 --> SP674["SP-674 Migration"]
SP673 --> SP675["SP-675 Rollout"]
SP674 --> SP675
SP675 --> SP676["SP-676 Submissions"]
SP672 --> SP677["SP-677 Cross-tenant copy"]
9Detailed steps per phase
SP-670 Spike
- Confirm a Worker route on
/media/*fires on Cloudflare for SaaS custom hostnames, not only on wildcard hosts. - Decide the bucket: reuse the slide bucket or add a media bucket per env.
- Generate the HMAC secret and place it in Infisical and as a wrangler secret.
- Write the route and secret decisions back into this plan.
SP-671 Media Worker
- Parse the
sp_mediacookie and verify the HMAC with 60 s slack on exp. - Match the cookie tenant against the key tenant prefix. Return 401 on no or bad cookie, 403 on tenant mismatch.
- Read the object through the R2 binding. Return 404 when it is missing.
- Add edge cache lookup and store, and support Range requests for audio.
- Handle
POST /media/session: verify the token and set the cookie. - Write wrangler config and a CI deploy job.
- Cover it with vitest and miniflare tests.
SP-672 Backend
- Add the session token endpoint with a membership check that signs
{tenant_id, user_id, exp}. - Update
apps/backend/app/services/storage.py:tenant_object_key,public_blob_url,quiz_image_key_from_url,quiz_audio_key_from_url, and thecopy_objectscontainer, so the gated write path returns/media/URLs. - Switch quiz image and audio uploads to the private bucket.
- Update
services/question.py: the base64 embed rewrite around lines 945 to 997 andclone_questions_for_duplicate_quiz. - Update
services/rich_text.pyvalidators and extractors, plus the key parsers and delete helpers. - Update the clone paths:
services/b2b_licensing.py_materialise_copy,services/tenant_marketplace.py_create_managed_replica, andservices/course.pyduplicate_course. - 37 backend test files reference the storage service. Only the quiz-related ones need edits.
SP-673 Frontend
- Refresh the cookie on Firebase token renewal plus a timer at two thirds of cookie life, paused when the tab is hidden.
- Reuse
apps/frontend/src/lib/scheduleUrlRefresh.ts, the same timer helper used for slide presigned URLs. - Add an error listener on image and audio elements: one-shot refresh and retry on a 401.
- Clear the cookie on logout.
- Wire local dev:
wrangler devbehind the Vite proxy or a dev-only backend route. The API base isVITE_API_BASE_URL, and requests sendX-Tenant-Host, so the backend resolves the tenant from that header.
SP-674 Migration
- Server-side copy of quiz objects from
https://static.skillpixel.vnto the private bucket under target path prefix/media/. - Rewrite URLs in question text, explanation, options, and resources. Use the inventory counts from section 1.
- Keep the 671 base64 questions untouched.
- Dry run first, write a rollback file, then run beta, then prod.
- Delete the public copies after a soak.
SP-675 Rollout
- Beta walkthrough on a wildcard host and a custom domain.
- Check Safari audio with Range requests on a real device.
- Watch PostHog for image and audio errors.
- Release to prod, then clean up the public bucket.
10Risks and open questions
| Risk | Detail |
|---|---|
| Cloudflare for SaaS routes | If the Worker route does not fire on SaaS custom hostnames, the fallback is a media subdomain per custom domain, which adds DNS work per tenant and about 2 days. SP-670 answers this on day one. |
| Bunny videos | The backend only signs uploads. Confirm token authentication is enabled on the Bunny library, otherwise video links are as shareable as images today. |
| Cache and revocation | A removed member keeps access until the cookie expires, at most 30 minutes. Acceptable. |
| Range and Safari audio | Test on real Safari. |
| Local dev | Needs a decision: wrangler dev or a dev-only backend route. DECISION NEEDED |
11Decisions locked DECIDED
- Tenant-level gate.
- First-party cookie on the tenant host.
- Worker sets the cookie.
- Auth before cache.
- Path-only stored URLs.
- Uploads unchanged.
- Marketing assets stay public.
- Base64 questions untouched.