SkillPixel LMS: Gated lesson media on R2

Gate tenant lesson media with backend-issued presigned R2 URLs, reading from a private R2 bucket. Jira master SP-669

Status PLANNED · rev 2 · 2026-09-03 · Khang Nguyen · Roadmap · Domain provisioning plan

Move quiz media to a private bucket and hand out short-lived presigned URLs.

Legend FACTtrue today, verified DECIDEDalready agreed PLANNEDin this plan, not built DECISION NEEDEDneeds a call REJECTEDconsidered, not doing
Summary

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.

EstimateValue
Engineer days5 to 7
Calendar for one developer1.5 to 2 weeks
Rev 2

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.

ContentCountPublic R2 today?Plan
Questions with images4,225Yes, all under tenants/ keys, served from static.skillpixel.vnGate
Questions with audio121YesGate
Questions with inline base64 images671No, stored in the DBOut of scope
Lesson attachments, resources JSON89No, all external links, zero uploaded filesGate new uploads when a file upload flow exists
Reading lessons with images3NegligibleGate new uploads
Course covers17, 10 tenant keys plus 7 legacy keysYesStay public
Slide lessons0Already in the private slide bucket with 5-minute presigned GET URLsUnchanged
Video lessons36Bunny CDN, not R2Check Bunny token authentication is on
Student submissions: assignment, practice, grading artefactsnot countedYesGate in follow-up SP-676
The tenant IP in the public bucket is quiz images and quiz audio. That is the first cut. Student submissions are student data and arguably matter more. They are a follow-up only because the quiz path is smaller and proves the pattern.

2Threat model

Does not fix

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.

Does fix

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
    
A signed link is as shareable as a screenshot for a few hours. That is the accepted trade.

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

FieldValue
BucketPrivate, default reuse the slide bucket
Marker form/media/tenants/{tenant_id}/{feature}/{nanoid}/{file}
Lifetime4 hours, R2 cap 7 days
SigningSigV4 presign by the storage service, local, no network
Response fieldexpires_at on the questions list and the attempt detail responses
RefreshRefetch at about 3.5 hours or on first media error
RangeSupported by R2 directly
CostR2 Class B read ops, 10 million per month free, egress free
A signed URL works for anyone who has it until it expires. Course-level access is checked when the URL is minted, not when it is fetched.

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.

What we give up with presigned URLs: no shared edge cache, slightly bigger quiz responses, browser cache only within the lifetime. R2 egress is free so this is latency, not money. Quizzes are not image galleries people revisit all day.

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.

PhaseTicketWhatEffortDepends on
0SP-670Setup: private bucket choice, presign lifetime, Range check, cross-bucket copy check, CORS on the private bucket0.5 daynone
1SP-671Backend read path: marker to presigned URL helper, applied to every quiz and question response, expires_at on the payload1 to 1.5 daysSP-670
2SP-672Backend 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 bucket1.5 to 2 daysSP-670
3SP-673Frontend: refetch the questions list before expires_at and on media error, instructor editor same1 daySP-671, SP-672
4SP-674Migration: cross-bucket copy of quiz objects, rewrite stored URLs to markers, dry run, rollback file, beta then prod, delete public copies after a soak1 to 1.5 daysSP-672
5SP-675Rollout: beta walkthrough on wildcard and custom domain, two-minute lifetime test, edit-and-save marker check, prod release, public bucket cleanup0.5 to 1 daySP-673, SP-674
Follow-upSP-676Gate student submissions with the same presigned pathabout 2 daysSP-675
RelatedSP-677Hard-copy R2 assets on cross-tenant course copiesabout 2 daysSP-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

  1. Reuse the slide bucket or create a media bucket.
  2. Pick the 4 h lifetime.
  3. Confirm Range on a presigned GET from Safari.
  4. Check copy_objects in apps/backend/app/services/storage.py copies across buckets. It uses one bucket for source and target today.
  5. Confirm CORS on the private bucket allows GET from tenant origins including custom domains.

SP-671 Read path

  1. Write the marker-to-presigned helper next to the existing presign code in services/storage.py.
  2. Apply it to the student endpoints that return question content: GET /api/v1/quizzes/{quiz_nanoid}/questions and GET /api/v1/quizzes/{lesson_nanoid}/attempts/{attempt_nanoid}. The quiz info and attempts list endpoints carry no media.
  3. Apply it to the instructor question endpoints in routers/v1/instructor.py, and check routers/v1/chat.py, which also reads questions.
  4. Add expires_at to the questions list and attempt detail responses.
  5. Unit tests with image, audio, and option images.

SP-672 Write path

  1. Private-bucket write path returning a marker.
  2. Switch the base64 embed rewrite in services/question.py around lines 945 to 997 and the audio upload.
  3. Validators in services/rich_text.py and key parsers in services/storage.py accept marker, presigned, and legacy static-host forms.
  4. Save path normalises to markers. delete_quiz_images and delete_quiz_audio target the private bucket.
  5. clone_questions_for_duplicate_quiz and copy_objects target the private bucket for duplicate, licensing, _materialise_copy in services/b2b_licensing.py, and marketplace, _create_managed_replica in services/tenant_marketplace.py.
  6. Update the quiz-related tests among the 37 test files that reference the storage service.

SP-673 Frontend

  1. In apps/frontend/src/apis/student/useQuiz.ts, reuse scheduleUrlRefresh.ts with expires_at to call LessonsService.listQuestions again before expiry. Quiz info and attempts do not need a refetch.
  2. Add an error listener on img and audio in the quiz renderers, refetch the questions list once, deduplicated.
  3. Attempt review: refetch getQuizAttemptDetail on media error the same way.
  4. Instructor editor refetches on error.
  5. Confirm save sends the editor URL as is and the backend normalises it.

SP-674 Migration

  1. List and cross-bucket copy quiz-images and quiz-audio keys under tenants/.
  2. Rewrite question, explanation, options, and resources from https://static.skillpixel.vn/tenants/... to /media/tenants/... markers.
  3. Dry run with counts against the inventory in section 1.
  4. Write a rollback file, then run beta, then prod.
  5. Delete public copies after a one-week soak. Leave the SP-671 base64 questions alone.

SP-675 Rollout

  1. Beta on a wildcard host and a custom domain.
  2. Two-minute lifetime test. A copied link after expiry gives 403.
  3. Edit and save keeps a marker in the DB.
  4. Prod release with the migration in the same window.
  5. Watch PostHog for 24 h. Confirm the SP-640 storage job counts the private bucket, then clean up.

10Risks and open questions

RiskDetail
Editor round-tripIf 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 pathMissing 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 copycopy_objects uses one bucket today. Small change, but check before the migration.
CORS on the private bucketThe 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 videosThe 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 lifetimeAccepted. 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.