fbd4b992f5
* fix(privacy): make privacy policy page dark mode friendly Replace the hardcoded light gradient background with bg-background and add dark:prose-invert to the prose blocks so body text is readable on dark cards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cloud-backup): sync archives to Dropbox alongside Google Drive Introduce a CloudStorageProvider interface so performSync builds the archive set once and talks to storage only through it. Google Drive keeps its existing behaviour; Dropbox is a second implementation, so the compliance-relevant half (fingerprints, per-year layout, size fallback, progressive persistence) cannot drift between targets. Dropbox uses App folder access, matching the drive.file scope's "only what the app created" guarantee. Uploads are single-shot under 8 MB and chunked upload sessions above, every write verified against Dropbox's content_hash. Call arguments are ASCII-escaped per UTF-16 code unit so Swedish file names survive the Dropbox-API-Arg header. Each provider owns its extension_data keys, schedule, failure counter and alert throttle, so a dead Dropbox token cannot pause a healthy Drive backup. The google_drive_* keys and the /oauth/callback path are untouched: both are wire format for already-connected companies. isConfigured() gates /connect only. A deployment that loses its OAuth credentials must not trap users with a connection they cannot remove or a schedule they cannot switch off. Requires DROPBOX_APP_KEY and DROPBOX_APP_SECRET; the provider row renders disabled without them. No migration: state is extension_data JSON throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: remove merge-conflict markers committed in DECISIONS.md The merge that brought main into this branch staged DECISIONS.md while it still carried conflict markers, so cdc3a513 shipped an unresolved hunk (compliance swarm ISO 27001 A.8.32). DECISIONS.md is an append-only log, so both sides are kept: main's systemdokumentation entry followed by this branch's Dropbox entries. No decision was dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import { createClient } from '@supabase/supabase-js'
|
|
import { NextResponse } from 'next/server'
|
|
import { withCronContext } from '@/lib/api/with-cron-context'
|
|
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
|
import { performSync, saveExtensionData } from '@/extensions/general/cloud-backup/lib/sync'
|
|
import { isScheduleDue } from '@/extensions/general/cloud-backup/lib/schedule'
|
|
import { CLOUD_PROVIDERS } from '@/extensions/general/cloud-backup/lib/provider-registry'
|
|
import {
|
|
sendBackupFailureAlert,
|
|
shouldSendBackupAlert,
|
|
type BackupAlertKind,
|
|
} from '@/extensions/general/cloud-backup/lib/backup-alert'
|
|
import type { CloudStorageProvider } from '@/extensions/general/cloud-backup/lib/cloud-provider'
|
|
import type {
|
|
CloudConnection,
|
|
CloudSchedule,
|
|
} from '@/extensions/general/cloud-backup/types'
|
|
|
|
/**
|
|
* GET /api/extensions/cloud-backup/auto-sync/cron
|
|
*
|
|
* Runs hourly. Finds every (company, provider) pair whose auto-sync is due
|
|
* (daily slot has passed and no attempt has run since it: see `isScheduleDue`)
|
|
* and triggers a full backup for each via the shared `performSync()` helper.
|
|
* Pairs left over when a run hits its time budget stay due and are picked up
|
|
* by the next hourly run instead of losing the day.
|
|
*
|
|
* Providers are scheduled independently: a company can back up to Google Drive
|
|
* nightly and to Dropbox weekly, and a Dropbox failure never marks the Drive
|
|
* backup unhealthy. Each provider keeps its own schedule record, failure
|
|
* counter and alert throttle.
|
|
*
|
|
* Failures increment `consecutive_failures` on that provider's schedule; alert
|
|
* emails go out on dead tokens (once per incident) and repeated failures
|
|
* (threshold in `backup-alert.ts`), throttled per company and provider.
|
|
*
|
|
* Uses the service role client: no user session, no RLS. Each row in
|
|
* `extension_data` carries its own `user_id` (the user who configured the
|
|
* schedule), which we use as the "actor" when writing back the sync result.
|
|
*/
|
|
export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_request, ctx) => {
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
|
|
if (!supabaseUrl || !supabaseServiceKey) {
|
|
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
|
|
requestId: ctx.requestId,
|
|
details: { reason: 'Missing Supabase configuration' },
|
|
})
|
|
}
|
|
|
|
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
|
const now = new Date()
|
|
const origin = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
|
|
|
// Every provider's schedules are fetched, but only providers this deployment
|
|
// has credentials for are run: without them every sync would fail on the
|
|
// token refresh and burn the failure counter. Schedules belonging to an
|
|
// unconfigured provider are counted and logged rather than dropped silently,
|
|
// because that is a deployment mistake someone needs to see.
|
|
const providerByScheduleKey = new Map<string, CloudStorageProvider>(
|
|
CLOUD_PROVIDERS.map((p) => [p.keys.schedule, p])
|
|
)
|
|
|
|
const { data: rows, error } = await supabase
|
|
.from('extension_data')
|
|
.select('company_id, user_id, key, value')
|
|
.eq('extension_id', 'cloud-backup')
|
|
.in('key', [...providerByScheduleKey.keys()])
|
|
|
|
if (error) {
|
|
ctx.log.error('failed to fetch schedules', error, {
|
|
message: error.message,
|
|
code: error.code,
|
|
})
|
|
return errorResponse(error, ctx.log, { requestId: ctx.requestId })
|
|
}
|
|
|
|
if (!rows || rows.length === 0) {
|
|
return NextResponse.json({ message: 'No schedules configured', processed: 0 })
|
|
}
|
|
|
|
interface Candidate {
|
|
companyId: string
|
|
userId: string
|
|
provider: CloudStorageProvider
|
|
schedule: CloudSchedule
|
|
}
|
|
|
|
const candidates: Candidate[] = []
|
|
const unconfigured = new Map<string, number>()
|
|
for (const row of rows) {
|
|
const provider = providerByScheduleKey.get(row.key as string)
|
|
if (!provider) continue
|
|
const schedule = row.value as CloudSchedule | null
|
|
if (!isScheduleDue(schedule, now)) continue
|
|
if (!provider.isConfigured()) {
|
|
unconfigured.set(provider.id, (unconfigured.get(provider.id) ?? 0) + 1)
|
|
continue
|
|
}
|
|
candidates.push({
|
|
companyId: row.company_id as string,
|
|
userId: row.user_id as string,
|
|
provider,
|
|
schedule: schedule as CloudSchedule,
|
|
})
|
|
}
|
|
|
|
if (unconfigured.size > 0) {
|
|
// Companies are expecting a backup that this deployment cannot perform.
|
|
ctx.log.error(
|
|
'due backups skipped: provider has no OAuth credentials in this environment',
|
|
undefined,
|
|
{ skipped: Object.fromEntries(unconfigured) }
|
|
)
|
|
}
|
|
|
|
if (candidates.length === 0) {
|
|
return NextResponse.json({
|
|
message: 'No companies due this hour',
|
|
checked: rows.length,
|
|
processed: 0,
|
|
})
|
|
}
|
|
|
|
// Connections flagged needs_reauth carry a permanently dead refresh token
|
|
// (the provider returned 400 invalid_grant): skip them instead of retrying
|
|
// every night. They stay visible in the UI until the user reconnects.
|
|
const connectionKeys = [
|
|
...new Set(candidates.map((c) => c.provider.keys.connection)),
|
|
]
|
|
const { data: connectionRows, error: connectionError } = await supabase
|
|
.from('extension_data')
|
|
.select('company_id, key, value')
|
|
.eq('extension_id', 'cloud-backup')
|
|
.in('key', connectionKeys)
|
|
.in('company_id', [...new Set(candidates.map((c) => c.companyId))])
|
|
|
|
if (connectionError) {
|
|
// Fail open: without connection data we cannot tell who needs reauth,
|
|
// so fall back to attempting everyone (performSync re-flags dead tokens).
|
|
ctx.log.warn('failed to fetch connections for reauth check', {
|
|
message: connectionError.message,
|
|
})
|
|
}
|
|
|
|
// Keyed by company + connection key: one company can hold a healthy Drive
|
|
// connection and a dead Dropbox one at the same time.
|
|
const connectionByCompanyAndKey = new Map<string, CloudConnection>()
|
|
for (const r of connectionRows ?? []) {
|
|
const value = r.value as CloudConnection | null
|
|
if (value) connectionByCompanyAndKey.set(`${r.company_id}:${r.key}`, value)
|
|
}
|
|
|
|
const startTime = Date.now()
|
|
const TIME_BUDGET_MS = 250_000 // 4m10s: leaves 50s margin below Vercel's 300s Pro limit
|
|
|
|
const results: {
|
|
companyId: string
|
|
provider: string
|
|
status: 'success' | 'error' | 'skipped'
|
|
error?: string
|
|
}[] = []
|
|
|
|
/**
|
|
* Send a failure alert if warranted and return the new last_alert_at.
|
|
* Best-effort: alert failures are logged inside sendBackupFailureAlert.
|
|
*/
|
|
const maybeAlert = async (params: {
|
|
companyId: string
|
|
userId: string
|
|
providerLabel: string
|
|
kind: BackupAlertKind
|
|
consecutiveFailures: number
|
|
errorMessage: string | null
|
|
lastAlertAt: string | null | undefined
|
|
}): Promise<string | null> => {
|
|
const prior = params.lastAlertAt ?? null
|
|
if (
|
|
!shouldSendBackupAlert({
|
|
kind: params.kind,
|
|
consecutiveFailures: params.consecutiveFailures,
|
|
lastAlertAt: prior,
|
|
now: new Date(),
|
|
})
|
|
) {
|
|
return prior
|
|
}
|
|
const sent = await sendBackupFailureAlert(supabase, {
|
|
companyId: params.companyId,
|
|
userId: params.userId,
|
|
providerLabel: params.providerLabel,
|
|
kind: params.kind,
|
|
consecutiveFailures: params.consecutiveFailures,
|
|
errorMessage: params.errorMessage,
|
|
origin,
|
|
})
|
|
return sent.sent ? new Date().toISOString() : prior
|
|
}
|
|
|
|
for (const candidate of candidates) {
|
|
if (Date.now() - startTime > TIME_BUDGET_MS) {
|
|
ctx.log.info('time budget reached', {
|
|
processedSoFar: results.length,
|
|
skipped: candidates.length - results.length,
|
|
})
|
|
break
|
|
}
|
|
|
|
const { companyId, userId, provider, schedule } = candidate
|
|
const scheduleKey = provider.keys.schedule
|
|
|
|
const connection = connectionByCompanyAndKey.get(
|
|
`${companyId}:${provider.keys.connection}`
|
|
)
|
|
if (connection?.status === 'needs_reauth') {
|
|
// Do not touch last_auto_sync_* here: the schedule keeps showing the
|
|
// failure from the night the dead token was detected. But make sure the
|
|
// incident has been alerted once: a token can go dead via a manual sync
|
|
// (which never emails) and would otherwise stay silent forever.
|
|
const alertedSinceIncident =
|
|
schedule.last_alert_at &&
|
|
connection.needs_reauth_at &&
|
|
new Date(schedule.last_alert_at).getTime() >=
|
|
new Date(connection.needs_reauth_at).getTime()
|
|
if (!alertedSinceIncident) {
|
|
const lastAlertAt = await maybeAlert({
|
|
companyId,
|
|
userId,
|
|
providerLabel: provider.label,
|
|
kind: 'needs_reauth',
|
|
consecutiveFailures: schedule.consecutive_failures ?? 0,
|
|
errorMessage: null,
|
|
lastAlertAt: schedule.last_alert_at,
|
|
})
|
|
if (lastAlertAt !== (schedule.last_alert_at ?? null)) {
|
|
await saveExtensionData(supabase, companyId, userId, scheduleKey, {
|
|
...schedule,
|
|
last_alert_at: lastAlertAt,
|
|
}).catch((persistErr) => {
|
|
ctx.log.error('failed to persist alert state', persistErr as Error, {
|
|
companyId,
|
|
provider: provider.id,
|
|
})
|
|
})
|
|
}
|
|
}
|
|
results.push({
|
|
companyId,
|
|
provider: provider.id,
|
|
status: 'skipped',
|
|
error: 'needs_reauth',
|
|
})
|
|
continue
|
|
}
|
|
|
|
try {
|
|
const syncResult = await performSync({
|
|
supabase,
|
|
companyId,
|
|
userId,
|
|
origin,
|
|
includeDocuments: true,
|
|
allowDocumentFallback: true,
|
|
provider,
|
|
})
|
|
|
|
const consecutiveFailures = syncResult.ok
|
|
? 0
|
|
: (schedule.consecutive_failures ?? 0) + 1
|
|
const safeSyncError = syncResult.ok ? null : getErrorMessage(syncResult.message)
|
|
let lastAlertAt = schedule.last_alert_at ?? null
|
|
if (!syncResult.ok) {
|
|
lastAlertAt = await maybeAlert({
|
|
companyId,
|
|
userId,
|
|
providerLabel: provider.label,
|
|
kind: syncResult.reason === 'needs_reauth' ? 'needs_reauth' : 'repeated_failures',
|
|
consecutiveFailures,
|
|
errorMessage: safeSyncError,
|
|
lastAlertAt,
|
|
})
|
|
}
|
|
|
|
const updated: CloudSchedule = {
|
|
...schedule,
|
|
last_auto_sync_at: new Date().toISOString(),
|
|
last_auto_sync_status: syncResult.ok ? 'success' : 'error',
|
|
last_auto_sync_error: safeSyncError,
|
|
consecutive_failures: consecutiveFailures,
|
|
last_alert_at: lastAlertAt,
|
|
}
|
|
await saveExtensionData(supabase, companyId, userId, scheduleKey, updated)
|
|
|
|
results.push({
|
|
companyId,
|
|
provider: provider.id,
|
|
status: syncResult.ok ? 'success' : 'error',
|
|
error: safeSyncError ?? undefined,
|
|
})
|
|
} catch (err) {
|
|
const safeMessage = getErrorMessage(err)
|
|
ctx.log.error('cloud backup sync failed for company', err as Error, {
|
|
companyId,
|
|
provider: provider.id,
|
|
})
|
|
|
|
const consecutiveFailures = (schedule.consecutive_failures ?? 0) + 1
|
|
const lastAlertAt = await maybeAlert({
|
|
companyId,
|
|
userId,
|
|
providerLabel: provider.label,
|
|
kind: 'repeated_failures',
|
|
consecutiveFailures,
|
|
errorMessage: safeMessage.slice(0, 200),
|
|
lastAlertAt: schedule.last_alert_at,
|
|
})
|
|
|
|
const updated: CloudSchedule = {
|
|
...schedule,
|
|
last_auto_sync_at: new Date().toISOString(),
|
|
last_auto_sync_status: 'error',
|
|
last_auto_sync_error: safeMessage.slice(0, 200),
|
|
consecutive_failures: consecutiveFailures,
|
|
last_alert_at: lastAlertAt,
|
|
}
|
|
await saveExtensionData(supabase, companyId, userId, scheduleKey, updated).catch(
|
|
(persistErr) => {
|
|
ctx.log.error('failed to persist failure state', persistErr as Error, {
|
|
companyId,
|
|
provider: provider.id,
|
|
})
|
|
},
|
|
)
|
|
|
|
results.push({
|
|
companyId,
|
|
provider: provider.id,
|
|
status: 'error',
|
|
error: safeMessage,
|
|
})
|
|
}
|
|
}
|
|
|
|
const successCount = results.filter((r) => r.status === 'success').length
|
|
const errorCount = results.filter((r) => r.status === 'error').length
|
|
const skippedCount = results.filter((r) => r.status === 'skipped').length
|
|
|
|
ctx.log.info('cloud backup cron summary', {
|
|
processed: results.length,
|
|
succeeded: successCount,
|
|
failed: errorCount,
|
|
skipped: skippedCount,
|
|
})
|
|
|
|
return NextResponse.json({
|
|
checked: rows.length,
|
|
candidates: candidates.length,
|
|
processed: results.length,
|
|
successes: successCount,
|
|
errors: errorCount,
|
|
skipped: skippedCount,
|
|
results,
|
|
})
|
|
})
|