mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 03:55:38 -04:00
fix: harden backup restore, first-user race, and several reliability issues
- Block all non-health API requests with 503 while a database restore is in progress (`withDatabaseAccessBlocked` in `@sofa/db/client`); pause and resume cron jobs around the restore window - Replace the two-hook first-user admin promotion with an atomic `claimInitialAdmin` query that uses a DB-level unique constraint so concurrent sign-ups during the first-run window can't each see `userCount === 0` - Fix `refreshAvailability` to always call `replaceAvailabilityTransaction` (clearing stale rows) even when TMDB returns no US providers, instead of returning early and leaving old data in place - Fix `performUpdateCheck` to read `release_url` (snake_case) from the public API response instead of `releaseUrl` - Fix `createJob` import handler to catch `SQLITE_CONSTRAINT_UNIQUE` and surface it as an `IMPORT_ALREADY_RUNNING` conflict error instead of a 500 - Relax public API telemetry schema to accept `string | number` for `users` and `titles` fields - Add tests for availability clearing, import deduplication, and `claimInitialAdmin`
This commit is contained in:
+16
-16
@@ -4,7 +4,7 @@ import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getProvider, getProviderConfig } from "./providers";
|
||||
import { getImporter, getImporterConfig } from "./importers";
|
||||
|
||||
const GITHUB_RELEASES_URL = "https://api.github.com/repos/jakejarvis/sofa/releases/latest";
|
||||
|
||||
@@ -54,8 +54,8 @@ app.post(
|
||||
instanceId: z.string().min(1),
|
||||
version: z.string().min(1),
|
||||
arch: z.string().optional(),
|
||||
users: z.number().optional(),
|
||||
titles: z.number().optional(),
|
||||
users: z.union([z.number(), z.string()]).optional(),
|
||||
titles: z.union([z.number(), z.string()]).optional(),
|
||||
features: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
),
|
||||
@@ -123,16 +123,16 @@ app.post(
|
||||
console.warn("checkRateLimit error (import-device-code):", e);
|
||||
}
|
||||
|
||||
const { provider: providerName } = c.req.valid("param");
|
||||
const { provider } = c.req.valid("param");
|
||||
|
||||
const provider = getProvider(providerName);
|
||||
const config = getProviderConfig(providerName);
|
||||
if (!provider || !config.clientId) {
|
||||
return c.json({ error: `${providerName} is not configured` }, 503);
|
||||
const importer = getImporter(provider);
|
||||
const config = getImporterConfig(provider);
|
||||
if (!importer || !config.clientId) {
|
||||
return c.json({ error: `${provider} is not configured` }, 503);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await provider.getDeviceCode(config.clientId, config.clientSecret);
|
||||
const result = await importer.getDeviceCode(config.clientId, config.clientSecret);
|
||||
return c.json(result);
|
||||
} catch (e) {
|
||||
return c.json(
|
||||
@@ -172,17 +172,17 @@ app.post(
|
||||
console.warn("checkRateLimit error (import-poll):", e);
|
||||
}
|
||||
|
||||
const { provider: providerName } = c.req.valid("param");
|
||||
const { provider } = c.req.valid("param");
|
||||
const { device_code } = c.req.valid("json");
|
||||
|
||||
const provider = getProvider(providerName);
|
||||
const config = getProviderConfig(providerName);
|
||||
if (!provider || !config.clientId) {
|
||||
return c.json({ error: `${providerName} is not configured` }, 503);
|
||||
const importer = getImporter(provider);
|
||||
const config = getImporterConfig(provider);
|
||||
if (!importer || !config.clientId) {
|
||||
return c.json({ error: `${provider} is not configured` }, 503);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await provider.pollForToken(config.clientId, config.clientSecret, device_code);
|
||||
const result = await importer.pollForToken(config.clientId, config.clientSecret, device_code);
|
||||
|
||||
if (result.status !== "authorized") {
|
||||
return c.json({ status: result.status });
|
||||
@@ -190,7 +190,7 @@ app.post(
|
||||
|
||||
// Fetch user data and return it inline
|
||||
try {
|
||||
const data = await provider.fetchUserData(result.accessToken, config.clientId);
|
||||
const data = await importer.fetchUserData(result.accessToken, config.clientId);
|
||||
return c.json({ status: "authorized", data });
|
||||
} catch (e) {
|
||||
// Auth succeeded but data fetch failed. Return a distinct status so
|
||||
|
||||
@@ -2,16 +2,16 @@ import { simkl } from "./simkl";
|
||||
import { trakt } from "./trakt";
|
||||
import type { ImportProvider } from "./types";
|
||||
|
||||
const providers: Record<string, ImportProvider> = {
|
||||
const importers: Record<string, ImportProvider> = {
|
||||
trakt,
|
||||
simkl,
|
||||
};
|
||||
|
||||
export function getProvider(name: string): ImportProvider | undefined {
|
||||
return providers[name];
|
||||
export function getImporter(name: string): ImportProvider | undefined {
|
||||
return importers[name];
|
||||
}
|
||||
|
||||
export function getProviderConfig(name: string): {
|
||||
export function getImporterConfig(name: string): {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
} {
|
||||
@@ -306,6 +306,18 @@ export function startJobs() {
|
||||
log.info(`Started ${jobs.size} jobs`);
|
||||
}
|
||||
|
||||
export function pauseJobs() {
|
||||
for (const job of jobs.values()) {
|
||||
job.pause();
|
||||
}
|
||||
}
|
||||
|
||||
export function resumeJobs() {
|
||||
for (const job of jobs.values()) {
|
||||
job.resume();
|
||||
}
|
||||
}
|
||||
|
||||
export function stopJobs() {
|
||||
for (const job of jobs.values()) {
|
||||
job.stop();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CACHE_DIR } from "@sofa/config";
|
||||
import { ensureBackupDir } from "@sofa/core/backup";
|
||||
import { ensureImageDirs, imageCacheEnabled } from "@sofa/core/image-cache";
|
||||
import { registerJobScheduleProvider } from "@sofa/core/system-health";
|
||||
import { closeDatabase } from "@sofa/db/client";
|
||||
import { closeDatabase, isDatabaseAccessBlocked } from "@sofa/db/client";
|
||||
import { runMigrations } from "@sofa/db/migrate";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
@@ -54,6 +54,13 @@ app.use(
|
||||
}),
|
||||
);
|
||||
|
||||
app.use("*", async (c, next) => {
|
||||
if (isDatabaseAccessBlocked() && c.req.path !== "/api/health") {
|
||||
return c.json({ error: "Service unavailable during database restore" }, 503);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// Non-RPC routes
|
||||
app.route("/api/health", healthRoutes);
|
||||
app.route("/api/auth", authRoutes);
|
||||
|
||||
@@ -20,7 +20,7 @@ import { getSystemHealth } from "@sofa/core/system-health";
|
||||
import { isTelemetryEnabled } from "@sofa/core/telemetry";
|
||||
import { getCachedUpdateCheck, isUpdateCheckEnabled } from "@sofa/core/update-check";
|
||||
|
||||
import { rescheduleBackup, triggerJob as triggerCronJob } from "../../cron";
|
||||
import { pauseJobs, rescheduleBackup, resumeJobs, triggerJob as triggerCronJob } from "../../cron";
|
||||
import { os } from "../context";
|
||||
import { admin } from "../middleware";
|
||||
|
||||
@@ -60,6 +60,7 @@ export const backupsRestore = os.admin.backups.restore
|
||||
// Stream upload to disk to avoid buffering the entire file in memory
|
||||
await ensureBackupDir();
|
||||
const tmpPath = path.join(BACKUP_DIR, `.upload-${Date.now()}-${crypto.randomUUID()}.db`);
|
||||
pauseJobs();
|
||||
try {
|
||||
await Bun.write(tmpPath, file);
|
||||
await restoreFromBackup(tmpPath);
|
||||
@@ -73,6 +74,8 @@ export const backupsRestore = os.admin.backups.restore
|
||||
message: msg,
|
||||
data: { code: AppErrorCode.BACKUP_RESTORE_FAILED },
|
||||
});
|
||||
} finally {
|
||||
resumeJobs();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -124,16 +124,27 @@ export const createJob = os.imports.createJob.use(authed).handler(async ({ input
|
||||
}
|
||||
}
|
||||
|
||||
const job = insertImportJob({
|
||||
userId: context.user.id,
|
||||
source: data.source,
|
||||
status: "pending",
|
||||
payload: JSON.stringify(data),
|
||||
importWatches: options.importWatches,
|
||||
importWatchlist: options.importWatchlist,
|
||||
importRatings: options.importRatings,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
let job;
|
||||
try {
|
||||
job = insertImportJob({
|
||||
userId: context.user.id,
|
||||
source: data.source,
|
||||
status: "pending",
|
||||
payload: JSON.stringify(data),
|
||||
importWatches: options.importWatches,
|
||||
importWatchlist: options.importWatchlist,
|
||||
importRatings: options.importRatings,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error && "code" in err && err.code === "SQLITE_CONSTRAINT_UNIQUE") {
|
||||
throw new ORPCError("CONFLICT", {
|
||||
message: "An import is already in progress",
|
||||
data: { code: AppErrorCode.IMPORT_ALREADY_RUNNING },
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Fire-and-forget processing
|
||||
processImportJob(job.id).catch((err) => {
|
||||
|
||||
Reference in New Issue
Block a user