Move quiz media to a private bucket and hand out short-lived presigned URLs.
Presigned R2 URLs 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 move quiz images and audio to a private bucket, store path-only markers in the DB, and have the backend rewrite those markers into presigned R2 URLs that live a few hours whenever it serves a quiz. The browser loads straight from R2. No proxy, no cookie, no Worker. Marketing assets stay public on purpose.
| Estimate | Value |
|---|---|
| Engineer days | 5 to 7 |
| Calendar for one developer | 1.5 to 2 weeks |
Worker dropped REJECTED
Rev 1 used a Cloudflare Worker at /media/* with a signed cookie. Dropped on 2026-09-03. The Worker runs before the edge cache, so every image load counts against the free tier of 100,000 requests per day, and Workers Paid would be needed for any real rollout. It also added a second deploy pipeline, a shared HMAC secret in two places, and an open question about routes on Cloudflare for SaaS custom hostnames. The presigned path has zero new infra, reuses the slide lesson pattern, and is a stronger gate because the backend already checks enrollment before it returns a quiz.
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 presign helper only uses the prefix to build the key it signs, and the enrollment check happens before that.
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
The backend rewrites path-only markers into presigned R2 URLs at read time. The browser loads straight from R2. Uploads do not change except the bucket.
Pieces
- Private R2 bucket. Public access off. The default is to reuse the existing slide bucket, which is already private and wired as
r2_slide_bucket. SP-670 confirms. - Markers in the DB. Stored URLs become path-only markers:
/media/tenants/{tenant_id}/quiz-images/{quiz_nanoid}/{file}.png. Nothing in the DB carries a host or a signature. The/media/prefix is only a marker the rewrite helper looks for. - Read-time rewrite. When the backend serves question content, one helper replaces every marker in question HTML, explanation, option values, and resources with a presigned GET URL for the private bucket. Signing is a local HMAC over the key and expiry, no network call, so 20 URLs per quiz cost nothing. The response also carries
expires_at. - Enrollment gate for free. The quiz endpoints already refuse callers who are not enrolled. The presigned URL only exists inside a response that passed that check, so the gate is per course, not per tenant.
- Editor round-trip. The instructor editor receives presigned URLs. On save the backend normalises them back to markers. The DB never stores a signed URL.
- Refresh. The frontend refetches the questions list shortly before
expires_at, and once on any image or audio load error. It reuses the timer helper already used for slide pages. - Uploads. Presigned PUT still goes to R2, only the target bucket changes.
Student opens a quiz
sequenceDiagram
participant B as Browser
participant API as Backend API
participant DB as DB
participant R2 as R2 bucket
Note over B: useQuiz fires three calls in parallel, bearer + X-Tenant-Host on each
B->>API: GET /api/v1/quizzes/{lesson_nanoid}
API-->>B: quiz info, no media
B->>API: GET /api/v1/quizzes/{lesson_nanoid}/attempts
API-->>B: attempts list, no media
B->>API: GET /api/v1/quizzes/{quiz_nanoid}/questions
API->>API: tenant + enrollment check
API->>DB: load questions, markers
API->>API: presign each /media/ marker, 4 h lifetime
API-->>B: questions JSON with presigned URLs + expires_at
B->>R2: GET signed URL, image or audio
R2-->>B: 200 bytes, Range supported
Note over B,API: attempt detail GET /api/v1/quizzes/{lesson_nanoid}/attempts/{attempt_nanoid} carries explanations, presigned the same way
Where a URL can be
flowchart LR
U["Upload, presigned PUT"] --> K["private bucket key"]
K --> M["DB stores /media/ marker"]
M --> R["read: backend presigns"]
R --> L["browser loads from R2"]
L --> S["editor save: backend normalises presigned or legacy URL back to marker"]
S --> M
P["Public bucket: covers and branding, never enters this loop"]
Leaked link
sequenceDiagram
participant S as Student
participant O as Outsider
participant R2 as R2 bucket
S->>O: copies a signed URL
O->>R2: opens it inside the lifetime
R2-->>O: 200, accepted, same as any screenshot
O->>R2: opens it after the lifetime
R2-->>O: 403
Refresh
sequenceDiagram
participant F as Frontend
participant API as Backend
Note over F: timer near expires_at
F->>API: GET /api/v1/quizzes/{quiz_nanoid}/questions
API-->>F: new URLs
Note over F: tab was asleep, image GET returns 403
F->>API: refetch questions once
API-->>F: new URLs
Note over F: re-render, image loads
5Presigned URL spec
| Field | Value |
|---|---|
| Bucket | Private, default reuse the slide bucket |
| Marker form | /media/tenants/{tenant_id}/{feature}/{nanoid}/{file} |
| Lifetime | 4 hours, R2 cap 7 days |
| Signing | SigV4 presign by the storage service, local, no network |
| Response field | expires_at on the questions list and the attempt detail responses |
| Refresh | Refetch at about 3.5 hours or on first media error |
| Range | Supported by R2 directly |
| Cost | R2 Class B read ops, 10 million per month free, egress free |
6Why not the Worker or a backend proxy
Worker with signed cookie REJECTED
Counts every image against the Worker free tier, needs Workers Paid for any real rollout, adds a deploy pipeline and a shared secret, and had an unresolved question about routes on Cloudflare for SaaS custom hostnames.
Backend streaming proxy REJECTED
Every image byte would pass through Cloud Run, so you pay Cloud Run egress and CPU, cold starts hurt on beta, and image tags would still need a cookie on the API host, which is cross-site on custom domains and Safari blocks it.
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 5 to 7 engineer days for phases 0 to 5. Read path and write path can run in parallel.
| Phase | Ticket | What | Effort | Depends on |
|---|---|---|---|---|
| 0 | SP-670 | Setup: private bucket choice, presign lifetime, Range check, cross-bucket copy check, CORS on the private bucket | 0.5 day | none |
| 1 | SP-671 | Backend read path: marker to presigned URL helper, applied to every quiz and question response, expires_at on the payload | 1 to 1.5 days | SP-670 |
| 2 | SP-672 | Backend write path: private bucket upload for quiz images and audio, validators accept marker, presigned and legacy forms, editor save normalises to markers, delete and clone paths use the private bucket | 1.5 to 2 days | SP-670 |
| 3 | SP-673 | Frontend: refetch the questions list before expires_at and on media error, instructor editor same | 1 day | SP-671, SP-672 |
| 4 | SP-674 | Migration: cross-bucket copy of quiz objects, rewrite stored URLs to markers, 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, two-minute lifetime test, edit-and-save marker check, prod release, public bucket cleanup | 0.5 to 1 day | SP-673, SP-674 |
| Follow-up | SP-676 | Gate student submissions with the same presigned path | about 2 days | SP-675 |
| Related | SP-677 | Hard-copy R2 assets on cross-tenant course copies | about 2 days | SP-672 |
Dependency graph
flowchart LR
SP670["SP-670 Setup"] --> SP671["SP-671 Read path"]
SP670 --> SP672["SP-672 Write path"]
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 Setup
- Reuse the slide bucket or create a media bucket.
- Pick the 4 h lifetime.
- Confirm Range on a presigned GET from Safari.
- Check
copy_objectsinapps/backend/app/services/storage.pycopies across buckets. It uses one bucket for source and target today. - Confirm CORS on the private bucket allows GET from tenant origins including custom domains.
SP-671 Read path
- Write the marker-to-presigned helper next to the existing presign code in
services/storage.py. - Apply it to the student endpoints that return question content:
GET /api/v1/quizzes/{quiz_nanoid}/questionsandGET /api/v1/quizzes/{lesson_nanoid}/attempts/{attempt_nanoid}. The quiz info and attempts list endpoints carry no media. - Apply it to the instructor question endpoints in
routers/v1/instructor.py, and checkrouters/v1/chat.py, which also reads questions. - Add
expires_atto the questions list and attempt detail responses. - Unit tests with image, audio, and option images.
SP-672 Write path
- Private-bucket write path returning a marker.
- Switch the base64 embed rewrite in
services/question.pyaround lines 945 to 997 and the audio upload. - Validators in
services/rich_text.pyand key parsers inservices/storage.pyaccept marker, presigned, and legacy static-host forms. - Save path normalises to markers.
delete_quiz_imagesanddelete_quiz_audiotarget the private bucket. clone_questions_for_duplicate_quizandcopy_objectstarget the private bucket for duplicate, licensing,_materialise_copyinservices/b2b_licensing.py, and marketplace,_create_managed_replicainservices/tenant_marketplace.py.- Update the quiz-related tests among the 37 test files that reference the storage service.
SP-673 Frontend
- In
apps/frontend/src/apis/student/useQuiz.ts, reusescheduleUrlRefresh.tswithexpires_atto callLessonsService.listQuestionsagain before expiry. Quiz info and attempts do not need a refetch. - Add an error listener on
imgandaudioin the quiz renderers, refetch the questions list once, deduplicated. - Attempt review: refetch
getQuizAttemptDetailon media error the same way. - Instructor editor refetches on error.
- Confirm save sends the editor URL as is and the backend normalises it.
SP-674 Migration
- List and cross-bucket copy quiz-images and quiz-audio keys under
tenants/. - Rewrite question, explanation, options, and resources from
https://static.skillpixel.vn/tenants/...to/media/tenants/...markers. - Dry run with counts against the inventory in section 1.
- Write a rollback file, then run beta, then prod.
- Delete public copies after a one-week soak. Leave the SP-671 base64 questions alone.
SP-675 Rollout
- Beta on a wildcard host and a custom domain.
- Two-minute lifetime test. A copied link after expiry gives 403.
- Edit and save keeps a marker in the DB.
- Prod release with the migration in the same window.
- Watch PostHog for 24 h. Confirm the SP-640 storage job counts the private bucket, then clean up.
10Risks and open questions
| Risk | Detail |
|---|---|
| Editor round-trip | If a presigned URL slips into the DB, it dies after 4 hours. The save-path normaliser and a test that asserts markers in the DB are the guard. No decision needed, this is a must. |
| Every read path | Missing one endpoint that returns question content means a raw marker reaches the browser and the image is blank. Grep for every question serializer in SP-671. |
| Cross-bucket copy | copy_objects uses one bucket today. Small change, but check before the migration. |
| CORS on the private bucket | The slide bucket may only allow the current origins. Custom domains need to be included. SP-667 added custom-domain origins to the public bucket at activation, the private bucket needs the same hook. |
| 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. |
| Shared link inside the lifetime | Accepted. A screenshot leaks the same thing. |
11Decisions locked DECIDED
- Presigned R2 URLs from the backend, no Worker, no proxy.
- Markers in the DB, never signed URLs.
- Lifetime 4 hours.
- Default reuse the slide bucket.
- Enrollment check at mint time.
- Uploads unchanged except the bucket.
- Marketing assets stay public.
- Base64 questions untouched.