Skip to content

Commit fbf5228

Browse files
authored
fix: user photos survive redeploys — boot-time upload self-heal (#397)
## The bug User avatars and cover images disappeared for everyone on **every deploy**. This started after migrating to a replacement Railway service. ## Root cause Avatars/covers are stored on the upload volume (not R2-primary); the DB only holds the relative path (`/uploads/...`). On a service whose uploads land on **ephemeral container disk** — no persistent volume attached, or `ALLOW_EPHEMERAL_UPLOADS=true` — each redeploy starts with an empty uploads dir while the rows still point at the files, so every image 404s platform-wide. The volume→R2 mirror already existed, but recovery was a **manual script** and the first backup pass only fired 24h after boot, so nothing self-healed. ## Fix (self-healing, defense in depth) - **`uploadVolumeRestore.js`** (new): shared restore core — lists the R2 mirror and writes back any file missing locally (skip-if-exists, traversal-guarded, **streams to a `.tmp` then atomic-renames** so an OOM/eviction mid-write can't leave a truncated file). `restoreOnBoot()` runs it non-blocking on startup (heartbeat-wrapped) so a fresh/wiped container re-hydrates its photos with **no operator action**. A healthy persistent volume pays only the R2 LIST cost. - **`restoreVolumeFromR2.js`** (manual DR CLI) now delegates to that core — the traversal guards live in one place, not two copies. - **First backup pass ~2 min after boot** (`UPLOAD_BACKUP_FIRST_PASS_DELAY_MS`) instead of a full interval later. - **`storage.js`**: ephemeral-storage-in-production now logs an error + Sentry alert (`storage.ephemeral_in_production`) instead of a silent info line. - `secretValidator` + `.env.example` document the two new env vars. The durable config (persistent `/data` volume + all four R2 vars) is an **operator action** documented in `RUNBOOK_DB_RESTORE.md` ("Photos vanish on EVERY redeploy"). The code makes it self-healing once R2 is configured; files that only ever lived on the wiped ephemeral disk can't be recovered and need re-upload. ## Tests - New `uploadVolumeRestore.test.js`: restore, skip-if-exists, force, traversal refusal, pagination, dry-run, boot-enable policy. - Full backend suite green (**3514 passed**); reviewed by code-reviewer (2 findings — heap-OOM + non-atomic write — both fixed before commit). ## Summary by Sourcery Add a shared upload-volume restore core and boot-time self-healing to ensure user-uploaded files are automatically rehydrated from the R2 mirror after redeploys, and surface misconfigured ephemeral storage and backup timing more loudly. Bug Fixes: - Ensure user avatars, covers, and other uploads are restored from the R2 mirror on service startup so they no longer disappear after redeploys when the uploads directory has been wiped or remounted. Enhancements: - Extract upload-volume restore logic into a reusable job module shared by the disaster-recovery CLI and boot-time self-heal, including traversal guards, streaming writes, and atomic file replacement. - Run the first upload-volume backup pass shortly after boot instead of waiting a full interval, reducing the window where new uploads exist only on local disk. - Raise a prominent error log and Sentry alert when running with ephemeral upload storage in production instead of only logging at info level. Documentation: - Document the new upload backup/restore configuration flags and the photo-loss scenario in the release log and secret/env documentation. Tests: - Add unit tests for the upload-volume restore job covering restore behavior, skip/force semantics, traversal protection, pagination, dry-run mode, and boot-enable policy. - Update storage validation tests to assert the new error-level logging and Sentry alert when ephemeral storage is enabled in production.
1 parent 8c14f52 commit fbf5228

10 files changed

Lines changed: 508 additions & 129 deletions

File tree

backend/.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,3 +383,13 @@ R2_BUCKET_UPLOAD_BACKUP=
383383
# Optional overrides — defaults to 24h cadence + 10 uploads/sec.
384384
UPLOAD_BACKUP_INTERVAL_MS=
385385
UPLOAD_BACKUP_RATE_LIMIT_PER_SEC=
386+
# Delay before the FIRST backup pass after boot (ms). Default 120000 (2 min).
387+
# Bounds how long a freshly-uploaded photo can sit only on local disk before
388+
# it reaches R2 — important on services that redeploy frequently.
389+
UPLOAD_BACKUP_FIRST_PASS_DELAY_MS=
390+
# Boot-time self-heal: on startup, pull any avatar/cover/attachment files
391+
# missing locally back from the R2 mirror (skip-if-exists, non-blocking).
392+
# Makes a wiped/ephemeral volume recover automatically instead of needing a
393+
# manual restoreVolumeFromR2.js run. Default ON in production (when R2 is
394+
# configured), OFF in dev. Set to "false" to disable, "true" to force on.
395+
UPLOAD_RESTORE_ON_BOOT=

backend/scripts/restoreVolumeFromR2.js

Lines changed: 24 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,17 @@
11
/**
2-
* restoreVolumeFromR2.js — disaster-recovery for /data/uploads.
2+
* restoreVolumeFromR2.js — disaster-recovery CLI for /data/uploads.
33
*
4-
* Pairs with backend/src/lib/jobs/uploadVolumeBackup.js.
4+
* Pairs with backend/src/lib/jobs/uploadVolumeBackup.js (the volume → R2
5+
* mirror). The restore logic — listing, traversal guards, skip-if-exists —
6+
* lives in src/lib/jobs/uploadVolumeRestore.js so the boot-time self-heal
7+
* and this manual CLI share exactly one implementation. This file is the
8+
* operator-facing wrapper: it parses flags, validates config, runs one pass,
9+
* and reports.
510
*
6-
* When to run: the Railway volume is gone, corrupted, or replaced. The
7-
* DB rows still point at /uploads/... paths; without restoring the
8-
* files every <img> 404s. Run this on the new instance BEFORE flipping
9-
* traffic back on.
10-
*
11-
* What it does:
12-
* 1. Lists every object under the `upload-volume-backup/` prefix in
13-
* R2_BUCKET_UPLOAD_BACKUP.
14-
* 2. For each one, writes the file back into UPLOADS_DIR preserving
15-
* the original relative path (`avatars/123.jpg` etc.).
16-
* 3. Refuses to overwrite existing files by default. Pass --force
17-
* to overwrite (use ONLY when the volume is intentionally being
18-
* wiped + repopulated; otherwise you risk replacing live newer
19-
* content with stale backups).
20-
* 4. Skips R2 listing pagination boundaries cleanly — handles the
21-
* ContinuationToken pattern S3 returns for buckets > 1000 keys.
11+
* When to run: the Railway volume is gone, corrupted, or replaced and the
12+
* boot-time restore didn't (or couldn't) cover it — e.g. you want a forced
13+
* overwrite or a dry-run audit. The DB rows still point at /uploads/...
14+
* paths; without the files every <img> 404s.
2215
*
2316
* Usage:
2417
* # Inspect what would happen (no writes):
@@ -27,23 +20,21 @@
2720
* # Actual restore (writes new files only, skip-if-exists):
2821
* node backend/scripts/restoreVolumeFromR2.js
2922
*
30-
* # Wipe + repopulate (use after volume reformat):
23+
* # Wipe + repopulate (use ONLY after a volume reformat — --force
24+
* # overwrites existing files and can replace live newer content with
25+
* # stale backups):
3126
* node backend/scripts/restoreVolumeFromR2.js --force
3227
*
3328
* Required env: R2_BUCKET_UPLOAD_BACKUP + R2_ACCOUNT_ID +
3429
* R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY.
3530
*
3631
* Exit codes: 0 success, 1 missing config / R2 unavailable, 2 partial
37-
* failure (some objects could not be restored — report shows which).
32+
* failure (some objects could not be restored — log shows which).
3833
*/
39-
const fs = require('node:fs')
4034
const path = require('node:path')
41-
const { ListObjectsV2Command, GetObjectCommand } = require('@aws-sdk/client-s3')
4235

4336
require('dotenv').config({ path: path.resolve(__dirname, '../.env') })
4437

45-
const { KEY_PREFIX } = require('../src/lib/jobs/uploadVolumeBackup')
46-
4738
async function main() {
4839
const args = process.argv.slice(2)
4940
const dryRun = args.includes('--dry-run')
@@ -55,120 +46,30 @@ async function main() {
5546
process.exit(1)
5647
}
5748

58-
// Lazy-require the R2 client so we share the same singleton + config
59-
// checks as the runtime app.
49+
// Share the same credential check as the runtime app.
6050
const { isR2Configured } = require('../src/lib/r2Storage')
6151
if (!isR2Configured()) {
6252
console.error('FATAL: R2 credentials are not configured.')
6353
process.exit(1)
6454
}
6555

66-
// Build a dedicated S3 client for this script rather than reaching
67-
// into r2Storage internals. Same credentials, same endpoint — keeps
68-
// r2Storage's getClient() private and avoids tying script lifetimes
69-
// to the runtime singleton's connection pool.
70-
const { S3Client } = require('@aws-sdk/client-s3')
71-
const client = new S3Client({
72-
region: 'auto',
73-
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
74-
credentials: {
75-
accessKeyId: process.env.R2_ACCESS_KEY_ID,
76-
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
77-
},
78-
})
79-
8056
const { UPLOADS_DIR } = require('../src/lib/storage')
57+
const { runRestorePass } = require('../src/lib/jobs/uploadVolumeRestore')
58+
8159
console.log(`Restoring from R2 bucket "${bucket}" → ${UPLOADS_DIR}`)
8260
console.log(
8361
`Mode: ${dryRun ? 'DRY RUN' : force ? 'FORCE (overwrite existing)' : 'SAFE (skip-if-exists)'}`,
8462
)
8563

86-
let restored = 0
87-
let skipped = 0
88-
let failed = 0
89-
let scanned = 0
90-
let continuationToken
91-
92-
do {
93-
const list = await client.send(
94-
new ListObjectsV2Command({
95-
Bucket: bucket,
96-
Prefix: KEY_PREFIX,
97-
ContinuationToken: continuationToken,
98-
}),
99-
)
100-
101-
for (const obj of list.Contents || []) {
102-
scanned += 1
103-
const relativePath = obj.Key.slice(KEY_PREFIX.length)
104-
105-
// Defense in depth: R2 is external storage. A compromised R2 credential
106-
// or misconfigured bucket policy could inject keys like
107-
// 'upload-volume-backup/../../etc/cron.d/payload', which path.join would
108-
// happily resolve outside UPLOADS_DIR. Refuse any relative path that
109-
// contains '..' segments or is absolute, and verify the resolved
110-
// destination stays under UPLOADS_DIR.
111-
if (
112-
!relativePath ||
113-
relativePath.startsWith('/') ||
114-
relativePath.startsWith('\\') ||
115-
path.isAbsolute(relativePath) ||
116-
relativePath.split(/[\\/]/).includes('..')
117-
) {
118-
console.warn(`REFUSED unsafe key (traversal attempt): ${obj.Key}`)
119-
failed += 1
120-
continue
121-
}
122-
123-
const destination = path.join(UPLOADS_DIR, relativePath)
124-
125-
const resolvedDest = path.resolve(destination)
126-
const resolvedRoot = path.resolve(UPLOADS_DIR)
127-
if (resolvedDest !== resolvedRoot && !resolvedDest.startsWith(resolvedRoot + path.sep)) {
128-
console.warn(`REFUSED escape from UPLOADS_DIR: ${obj.Key}${resolvedDest}`)
129-
failed += 1
130-
continue
131-
}
132-
133-
if (fs.existsSync(destination) && !force) {
134-
skipped += 1
135-
continue
136-
}
137-
if (dryRun) {
138-
console.log(`[dry-run] would restore ${obj.Key}${destination}`)
139-
restored += 1
140-
continue
141-
}
142-
143-
try {
144-
fs.mkdirSync(path.dirname(destination), { recursive: true })
145-
const get = await client.send(new GetObjectCommand({ Bucket: bucket, Key: obj.Key }))
146-
// The Body is a stream — collect to a buffer for the write.
147-
// For multi-GB videos a streaming pipe would be better; we
148-
// don't expect that volume here (avatars + small attachments).
149-
const chunks = []
150-
for await (const chunk of get.Body) chunks.push(chunk)
151-
fs.writeFileSync(destination, Buffer.concat(chunks))
152-
restored += 1
153-
if (restored % 100 === 0) {
154-
console.log(`Restored ${restored} files (skipped ${skipped}, failed ${failed})...`)
155-
}
156-
} catch (err) {
157-
console.error(`FAILED ${obj.Key}: ${err?.message || err}`)
158-
failed += 1
159-
}
160-
}
161-
162-
continuationToken = list.IsTruncated ? list.NextContinuationToken : undefined
163-
} while (continuationToken)
64+
const summary = await runRestorePass({ uploadsDir: UPLOADS_DIR, bucket, force, dryRun })
16465

16566
console.log('')
16667
console.log('=== Restore summary ===')
167-
console.log(`Scanned: ${scanned}`)
168-
console.log(`Restored: ${restored}`)
169-
console.log(`Skipped: ${skipped} (already on disk; use --force to overwrite)`)
170-
console.log(`Failed: ${failed}`)
171-
process.exit(failed > 0 ? 2 : 0)
68+
console.log(`Scanned: ${summary.scanned}`)
69+
console.log(`Restored: ${summary.restored}`)
70+
console.log(`Skipped: ${summary.skipped} (already on disk; use --force to overwrite)`)
71+
console.log(`Failed: ${summary.failed}`)
72+
process.exit(summary.failed > 0 ? 2 : 0)
17273
}
17374

17475
main().catch((err) => {

backend/src/index.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,19 @@ async function startServer() {
905905
// RUNBOOK_DB_RESTORE.md "Upload Volume Recovery" section.
906906
const { startUploadVolumeBackup } = require('./lib/jobs/uploadVolumeBackup')
907907
const { UPLOADS_DIR } = require('./lib/storage')
908+
909+
// Self-heal: pull any missing avatar/cover/attachment files back from
910+
// the R2 mirror on boot (skip-if-exists, non-blocking). When uploads
911+
// sit on a non-persistent volume — a detached/remounted Railway volume
912+
// or an ephemeral rebuild — a redeploy wipes the directory while the DB
913+
// rows still point at /uploads/..., breaking every image platform-wide.
914+
// Re-hydrating from R2 each boot makes that recoverable automatically
915+
// instead of needing a manual restoreVolumeFromR2.js run. Runs BEFORE
916+
// the backup is scheduled so a fresh container restores first, then
917+
// mirrors. A healthy persistent volume pays only the R2 LIST cost.
918+
const { restoreOnBoot } = require('./lib/jobs/uploadVolumeRestore')
919+
restoreOnBoot({ uploadsDir: UPLOADS_DIR })
920+
908921
startUploadVolumeBackup({ uploadsDir: UPLOADS_DIR })
909922

910923
log.info({ port: PORT }, `Server running on http://localhost:${PORT}`)

backend/src/lib/jobs/uploadVolumeBackup.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ function startUploadVolumeBackup({ uploadsDir }) {
202202
'upload-volume backup scheduled',
203203
)
204204

205-
const handle = setInterval(() => {
205+
const runPass = () => {
206206
// Lazy-require to avoid a boot-time require cycle through the
207207
// job runner.
208208
const { runWithHeartbeat } = require('./heartbeat')
@@ -211,7 +211,20 @@ function startUploadVolumeBackup({ uploadsDir }) {
211211
() => runBackupPass({ uploadsDir, bucket, rateLimitPerSec }),
212212
{ slaMs: intervalMs / 2 },
213213
)
214-
}, intervalMs)
214+
}
215+
216+
// First pass shortly after boot rather than a full interval later. A
217+
// short-lived container (frequent redeploys) could otherwise be torn
218+
// down before its first nightly pass ever fired, so a freshly-uploaded
219+
// photo would never reach R2 and would be lost on the next deploy.
220+
// Default 2 min: long enough for boot to settle (and for the boot
221+
// restore to finish first), short enough to bound the loss window.
222+
const firstPassDelayMs =
223+
Number.parseInt(process.env.UPLOAD_BACKUP_FIRST_PASS_DELAY_MS, 10) || 2 * 60 * 1000
224+
const firstPass = setTimeout(runPass, firstPassDelayMs)
225+
firstPass.unref()
226+
227+
const handle = setInterval(runPass, intervalMs)
215228
handle.unref()
216229
return handle
217230
}

0 commit comments

Comments
 (0)