- Set basePath in next.config.ts for serving under /hp-prod-tracker
- Create apiUrl() helper to prepend basePath to fetch calls
- Update all 28 fetch("/api/...") calls across 16 files
- Add GCS storage migration plan doc
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
6.4 KiB
GCS Storage Migration Plan
Problem
All uploaded images, videos, HLS segments, and thumbnails are stored on local disk at /data/uploads/. Video files (up to 500MB each) and HLS transcoded segments will fill the disk quickly.
Solution
Move file storage to a Google Cloud Storage (GCS) bucket with signed URLs for secure, direct-from-browser access. Processing (sharp, ffmpeg) stays local — only storage moves to GCS.
Architecture
Current Flow
Upload → sharp/ffmpeg process → write to /data/uploads → serve via /api/uploads/[...path] proxy
GCS Flow
Upload → sharp/ffmpeg process → upload to GCS bucket → store GCS object key in DB
Frontend → request signed URL from API → load directly from GCS (no server proxy)
What Changes
| Component | Current | GCS |
|---|---|---|
| Image storage | /data/uploads/revisions/{id}/ |
gs://hp-tracker-media/revisions/{id}/ |
| Video storage | Same local disk | Same GCS bucket |
| HLS segments | Local subdirectory | GCS bucket (uploaded after transcode) |
| File serving | /api/uploads/[...path] streams from disk |
Signed URLs → browser loads direct from GCS |
| Thumbnails | Local disk | GCS bucket |
| Temp processing | N/A | Local /tmp for ffmpeg, deleted after upload to GCS |
Files to Modify
Backend (4 files)
-
src/lib/services/upload-service.ts(main changes)- Replace
writeFile()calls with GCSbucket.upload()orfile.save() - Replace local URL generation (
/api/uploads/revisions/...) with GCS object keys - Add
getSignedUrl()helper for generating time-limited read URLs - Delete functions: replace
unlink/rmwithbucket.file().delete() - Lines affected: 86-149 (images), 200-290 (videos), 154-168 (image delete), 325-341 (video delete)
- Replace
-
src/lib/services/video-service.ts- After
transcodeToHLS()completes, upload all.tssegments +index.m3u8to GCS - Rewrite playlist URLs in
.m3u8to reference GCS object paths (not local paths) - Delete local temp files after successful GCS upload
- Lines affected: 137-189 (HLS transcode), 101-128 (thumbnail extraction)
- After
-
src/lib/services/annotation-service.ts- Upload frame thumbnails to GCS instead of local disk
- Lines affected: 105-149
-
src/app/api/uploads/[...path]/route.ts- Replace file streaming with signed URL redirect, OR
- Convert to a signed-URL generator:
GET /api/uploads/sign?key=revisions/xxx/file.mp4→ returns{ url: "https://storage.googleapis.com/..." } - This preserves the auth check (only authenticated users get signed URLs)
Frontend (2 files, minor)
src/components/review/video-player.tsx/src/hooks/use-video-player.ts- HLS.js needs to load
.m3u8from a signed URL - If playlist contains relative segment URLs, those resolve against the playlist URL (GCS handles this)
- May need
xhrSetupconfig in HLS.js to handle CORS
- HLS.js needs to load
Database (no schema change)
- The
attachmentsJSON field already stores URLs as strings - Just change from
/api/uploads/revisions/...to GCS object keys likerevisions/{id}/{file} - Signed URLs are generated on-the-fly when the frontend requests them
HLS Playlist Strategy
The trickiest part. Two options:
Option A: Rewrite playlist at upload time (simpler)
- After transcoding, rewrite
index.m3u8so segment references use full GCS paths - Upload the rewritten playlist to GCS
- Frontend gets one signed URL for the playlist, segments resolve as relative GCS URLs
- Downside: Segment URLs in the playlist aren't signed (bucket must allow public read for
.tsfiles, or use uniform bucket-level access)
Option B: Signing proxy endpoint (more secure)
- Keep
index.m3u8with relative segment names - Create
/api/uploads/hls-playlist?key=revisions/{id}/video_hls/index.m3u8endpoint - This reads the playlist from GCS, replaces each segment filename with a signed URL, returns the modified playlist
- HLS.js loads this endpoint, gets signed URLs for every segment
- Downside: Every playlist fetch hits the server (but playlists are tiny)
Recommendation: Option A with a private bucket + signed URLs for the playlist. GCS signed URLs for the playlist will also cover the segments since HLS.js resolves segments relative to the playlist URL origin.
GCP Prerequisites
-
GCS Bucket:
hp-tracker-media(or similar)- Location: same region as your server for low latency
- Storage class: Standard
- Access control: Uniform (not fine-grained)
- Public access: Prevented (all access via signed URLs)
-
CORS Configuration on the bucket:
[ { "origin": ["https://hptracker.yourdomain.com"], "method": ["GET", "HEAD"], "responseHeader": ["Content-Type", "Content-Range", "Accept-Ranges"], "maxAgeSeconds": 3600 } ] -
Service Account:
- Role:
Storage Object Adminon the bucket - Download JSON key file
- Mount as Docker secret or env var
GOOGLE_APPLICATION_CREDENTIALS
- Role:
-
Environment Variables (add to
.envanddocker-compose.yml):GCS_BUCKET=hp-tracker-media GCS_PROJECT_ID=your-gcp-project-id GOOGLE_APPLICATION_CREDENTIALS=/app/secrets/gcs-key.json
New Dependencies
npm install @google-cloud/storage
Migration Steps (for existing data)
- Deploy the new code with GCS support
- Run a one-time migration script to upload existing
/data/uploads/files to GCS - Update all
revision.attachmentsJSON in the database to use GCS object keys - Verify everything works
- Remove old local files and shrink the Docker volume
Estimated Effort
| Phase | Work | Days |
|---|---|---|
| Image uploads + thumbnails | Replace writeFile with GCS upload, signed URL helper | 1-2 |
| Video uploads + HLS | Upload segments to GCS, playlist handling | 1-2 |
| Serving endpoint | Convert to signed URL generator | 0.5 |
| Annotation frames | Small change, same pattern as images | 0.5 |
| Deployment + bucket setup | GCS bucket, CORS, service account, Docker secrets | 0.5 |
| Data migration script | Upload existing files, update DB | 0.5 |
| Total | 4-6 days |
Rollback Plan
Keep the current local storage code behind a feature flag:
const USE_GCS = !!process.env.GCS_BUCKET;
If GCS is not configured, fall back to local disk (current behavior). This allows gradual rollout and easy rollback.