Add/db and speed (#1243)
* 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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1a7152a7af
commit
fbd4b992f5
@@ -611,3 +611,10 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-27] Removed the granular "BFNAR 2013:2 punkt 9.x" citations from public/docs/systemdokumentation-mall.md instead of renumbering them to kap 8: the swedish-accounting-compliance skill and our own route comments both place systemdokumentation in kap 8, the template asserted kapitel 9 with a full point-by-point mapping (9.1, 9.2, 9.3-9.5, 9.6-9.8, 9.9, 9.16), and neither could be verified against BFN's actual text from here. A downloadable statutory template is the wrong place to guess a legal citation, and a wrong precise cite is worse than a correct general one, so the doc now cites BFL 5 kap. 11 § and BFNAR 2013:2 without pinning points. Restore the points once someone confirms the chapter against the standard.
|
||||
[2026-07-27] Abonnemang masthead uses the halftone Stockholm skyline, not key-adding-machine and no new asset: the wide engraving crops cleanly into a banner strip (waterline pinned to the bottom edge, same physics as the onboarding backdrop) while the machine vignette read noisy and lopsided at banner crop; screenshots of both compared before choosing.
|
||||
[2026-07-27] The requested Claude/OpenAI logos went into the API tab's "Anslut MCP-klient" group as a quiet works-with strip, using the marketing site's monochrome halftone marks instead of the raw colored brand tiles: full-color trademark tiles would fight the editorial monochrome chrome, and the halftone versions already exist as shared brand assets.
|
||||
[2026-07-27] Dropbox added as a second cloud-backup target behind a CloudStorageProvider interface rather than by copying the Drive sync: the archive-building half of performSync (fingerprints, per-year layout, size fallback, progressive persistence) is where the compliance-relevant behaviour lives, and a fork of it would drift silently.
|
||||
[2026-07-27] Dropbox uses App folder access, not full Dropbox: it matches the drive.file scope's "only what the app created" story, which is the privacy claim the backup card makes, and it is what Dropbox production approval expects for a backup app.
|
||||
[2026-07-27] Each provider keeps its own extension_data keys (google_drive_* / dropbox_*) and its own schedule, failure counter and alert throttle: Emil chose independent per-provider schedules, and it means a dead Dropbox token cannot pause a healthy Drive backup.
|
||||
[2026-07-27] The Google storage keys and the /oauth/callback path were left untouched: they are wire format (already-connected companies' records, plus a redirect URI registered with Google), so Dropbox got additive keys and its own /oauth/dropbox/callback.
|
||||
[2026-07-27] isConfigured() gates /connect only, not /disconnect, /schedule or /sync: a deployment losing its OAuth credentials must not trap users with a connection they cannot remove or a schedule they cannot switch off.
|
||||
[2026-07-27] Dropbox web links point at /home/Apps unless DROPBOX_APP_FOLDER_NAME is set: app-folder scoped calls cannot discover where the app folder sits in the user's account, and a link into the wrong folder reads as a lost backup, so the app-name deep link is opt-in rather than guessed.
|
||||
[2026-07-27] performSync keeps `provider` optional, defaulting to Google Drive: it preserves the pre-Dropbox call shape (and its test suite) as the documented legacy path while all three production call sites pass a provider explicitly.
|
||||
|
||||
@@ -12,7 +12,7 @@ export function generateMetadata(): Metadata {
|
||||
export default function PrivacyPolicyPage() {
|
||||
const { appName, legalEntity, privacyEmail } = getBranding()
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white py-12 px-4">
|
||||
<div className="min-h-screen bg-background py-12 px-4">
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">
|
||||
@@ -27,7 +27,7 @@ export default function PrivacyPolicyPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>1. Personuppgiftsansvarig</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-sm max-w-none">
|
||||
<CardContent className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p>
|
||||
{legalEntity} ("vi", "oss") är personuppgiftsansvarig för behandlingen av dina
|
||||
personuppgifter i samband med användningen av {appName}. Vi behandlar dina uppgifter i
|
||||
@@ -40,7 +40,7 @@ export default function PrivacyPolicyPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>2. Vilka uppgifter vi behandlar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-sm max-w-none">
|
||||
<CardContent className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p>Vi behandlar följande kategorier av personuppgifter:</p>
|
||||
<ul>
|
||||
<li><strong>Kontouppgifter:</strong> E-postadress (för inloggning)</li>
|
||||
@@ -59,7 +59,7 @@ export default function PrivacyPolicyPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>3. Rättslig grund (GDPR Art. 6)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-sm max-w-none">
|
||||
<CardContent className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Avtal (Art. 6.1b):</strong> Behandling som är nödvändig för att fullgöra våra
|
||||
@@ -85,7 +85,7 @@ export default function PrivacyPolicyPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>4. Underbiträden</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-sm max-w-none">
|
||||
<CardContent className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p>
|
||||
Vi använder följande underbiträden för att tillhandahålla tjänsten. Uppgifterna nedan anger
|
||||
vilka uppgifter som delas med respektive underbiträde, syftet samt var behandlingen sker
|
||||
@@ -191,7 +191,7 @@ export default function PrivacyPolicyPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>5. Tredjelandsöverföring</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-sm max-w-none">
|
||||
<CardContent className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p>
|
||||
Vissa underbiträden är baserade i USA. För dessa överföringar används EU-kommissionens
|
||||
standardavtalsklausuler (SCCs) som skyddsmekanism i enlighet med GDPR kapitel V.
|
||||
@@ -206,7 +206,7 @@ export default function PrivacyPolicyPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>6. Lagringstid</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-sm max-w-none">
|
||||
<CardContent className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Bokföringsmaterial:</strong> 7 år från räkenskapsårets slut, i enlighet
|
||||
@@ -237,7 +237,7 @@ export default function PrivacyPolicyPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>7. Dina rättigheter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-sm max-w-none">
|
||||
<CardContent className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p>Du har följande rättigheter enligt GDPR:</p>
|
||||
<ul>
|
||||
<li><strong>Tillgång (Art. 15):</strong> Du kan begära en kopia av alla dina personuppgifter.</li>
|
||||
@@ -261,7 +261,7 @@ export default function PrivacyPolicyPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>8. Kontaktuppgifter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-sm max-w-none">
|
||||
<CardContent className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<p>
|
||||
För frågor om behandlingen av dina personuppgifter, kontakta oss:
|
||||
</p>
|
||||
|
||||
@@ -50,9 +50,9 @@ function makeRequest() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The route issues two queries against extension_data: schedules
|
||||
* (key = google_drive_schedule) and connections (key = google_drive_connection,
|
||||
* with an .in() filter). Route rows to the right result by the `key` eq filter.
|
||||
* The route issues two queries against extension_data: every provider's
|
||||
* schedule keys, then the matching connection keys. Both use `.in('key', ...)`,
|
||||
* so route rows to the right result by inspecting the key list.
|
||||
*/
|
||||
function makeSupabaseStub(
|
||||
scheduleRows: unknown[],
|
||||
@@ -63,16 +63,18 @@ function makeSupabaseStub(
|
||||
} = {}
|
||||
) {
|
||||
const from = vi.fn().mockImplementation(() => {
|
||||
let key: string | null = null
|
||||
let kind: 'schedule' | 'connection' = 'schedule'
|
||||
const chain: any = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
in: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockImplementation((column: string, value: string) => {
|
||||
if (column === 'key') key = value
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
in: vi.fn().mockImplementation((column: string, values: string[]) => {
|
||||
if (column === 'key') {
|
||||
kind = values.some((v) => v.endsWith('_connection')) ? 'connection' : 'schedule'
|
||||
}
|
||||
return chain
|
||||
}),
|
||||
then: (resolve: (v: unknown) => void) => {
|
||||
if (key === 'google_drive_connection') {
|
||||
if (kind === 'connection') {
|
||||
return resolve({
|
||||
data: options.connectionRows ?? [],
|
||||
error: options.connectionError ?? null,
|
||||
@@ -93,6 +95,7 @@ function scheduleRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
key: 'google_drive_schedule',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: 12,
|
||||
@@ -104,6 +107,10 @@ function scheduleRow(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function connectionRow(companyId: string, value: unknown, key = 'google_drive_connection') {
|
||||
return { company_id: companyId, key, value }
|
||||
}
|
||||
|
||||
function okSyncResult() {
|
||||
return {
|
||||
ok: true as const,
|
||||
@@ -126,6 +133,12 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co'
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.test'
|
||||
// Without credentials the cron deliberately skips a provider, so the
|
||||
// Google flow has to look configured for these tests to exercise it.
|
||||
process.env.GOOGLE_CLIENT_ID = 'client-id'
|
||||
process.env.GOOGLE_CLIENT_SECRET = 'client-secret'
|
||||
delete process.env.DROPBOX_APP_KEY
|
||||
delete process.env.DROPBOX_APP_SECRET
|
||||
mockVerifyCronSecret.mockReturnValue(null)
|
||||
mockSendBackupFailureAlert.mockResolvedValue({ sent: true })
|
||||
})
|
||||
@@ -338,10 +351,7 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([scheduleRow()], {
|
||||
connectionRows: [
|
||||
{
|
||||
company_id: 'c-1',
|
||||
value: { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' },
|
||||
},
|
||||
connectionRow('c-1', { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' }),
|
||||
],
|
||||
})
|
||||
)
|
||||
@@ -352,7 +362,7 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
expect(body.skipped).toBe(1)
|
||||
expect(body.results).toEqual([
|
||||
{ companyId: 'c-1', status: 'skipped', error: 'needs_reauth' },
|
||||
{ companyId: 'c-1', provider: 'google_drive', status: 'skipped', error: 'needs_reauth' },
|
||||
])
|
||||
// The incident had not been alerted yet: one alert, persisted on the schedule.
|
||||
expect(mockSendBackupFailureAlert).toHaveBeenCalledWith(
|
||||
@@ -372,10 +382,7 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
[scheduleRow({ last_alert_at: '2026-07-10T04:00:00.000Z' })],
|
||||
{
|
||||
connectionRows: [
|
||||
{
|
||||
company_id: 'c-1',
|
||||
value: { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' },
|
||||
},
|
||||
connectionRow('c-1', { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' }),
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -398,11 +405,8 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
],
|
||||
{
|
||||
connectionRows: [
|
||||
{
|
||||
company_id: 'c-dead',
|
||||
value: { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' },
|
||||
},
|
||||
{ company_id: 'c-live', value: { status: 'active' } },
|
||||
connectionRow('c-dead', { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' }),
|
||||
connectionRow('c-live', { status: 'active' }),
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -420,6 +424,78 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
expect(body.successes).toBe(1)
|
||||
})
|
||||
|
||||
it('runs both providers for the same company as independent jobs', async () => {
|
||||
process.env.DROPBOX_APP_KEY = 'app-key'
|
||||
process.env.DROPBOX_APP_SECRET = 'app-secret'
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([
|
||||
scheduleRow(),
|
||||
{ ...scheduleRow(), key: 'dropbox_schedule' },
|
||||
])
|
||||
)
|
||||
mockPerformSync.mockResolvedValue(okSyncResult())
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(mockPerformSync).toHaveBeenCalledTimes(2)
|
||||
const targets = mockPerformSync.mock.calls.map((c) => c[0].provider?.id)
|
||||
expect(targets).toEqual(['google_drive', 'dropbox'])
|
||||
// Each writes back to its own schedule record.
|
||||
const writtenKeys = mockSaveExtensionData.mock.calls.map((c) => c[3])
|
||||
expect(writtenKeys).toEqual(['google_drive_schedule', 'dropbox_schedule'])
|
||||
expect(body.successes).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps one provider running when the other holds a dead token', async () => {
|
||||
process.env.DROPBOX_APP_KEY = 'app-key'
|
||||
process.env.DROPBOX_APP_SECRET = 'app-secret'
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub(
|
||||
[scheduleRow(), { ...scheduleRow(), key: 'dropbox_schedule' }],
|
||||
{
|
||||
connectionRows: [
|
||||
connectionRow(
|
||||
'c-1',
|
||||
{ status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' },
|
||||
'dropbox_connection'
|
||||
),
|
||||
connectionRow('c-1', { status: 'active' }),
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
mockPerformSync.mockResolvedValue(okSyncResult())
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
// A dead Dropbox token must not stop the healthy Drive backup.
|
||||
expect(mockPerformSync).toHaveBeenCalledTimes(1)
|
||||
expect(mockPerformSync.mock.calls[0][0].provider?.id).toBe('google_drive')
|
||||
expect(body.successes).toBe(1)
|
||||
expect(body.skipped).toBe(1)
|
||||
expect(mockSendBackupFailureAlert).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ kind: 'needs_reauth', providerLabel: 'Dropbox' })
|
||||
)
|
||||
})
|
||||
|
||||
it('skips a due schedule whose provider has no credentials here', async () => {
|
||||
// Dropbox stays unconfigured (see beforeEach): the schedule exists but the
|
||||
// deployment cannot honour it, so it must not burn the failure counter.
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([{ ...scheduleRow(), key: 'dropbox_schedule' }])
|
||||
)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
expect(mockSaveExtensionData).not.toHaveBeenCalled()
|
||||
expect(body.processed).toBe(0)
|
||||
})
|
||||
|
||||
it('fails open and attempts the sync when the connection lookup errors', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([scheduleRow()], {
|
||||
|
||||
@@ -3,35 +3,37 @@ 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,
|
||||
CONNECTION_KEY,
|
||||
SCHEDULE_KEY,
|
||||
saveExtensionData,
|
||||
} from '@/extensions/general/cloud-backup/lib/sync'
|
||||
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 {
|
||||
GoogleDriveConnection,
|
||||
GoogleDriveSchedule,
|
||||
CloudConnection,
|
||||
CloudSchedule,
|
||||
} from '@/extensions/general/cloud-backup/types'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/cloud-backup/auto-sync/cron
|
||||
*
|
||||
* Runs hourly. Finds all companies whose auto-sync is due (daily slot has
|
||||
* passed and no attempt has run since it: see `isScheduleDue`) and triggers a
|
||||
* full Drive backup for each via the shared `performSync()` helper. Companies
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* Failures increment `consecutive_failures` on the schedule; alert emails go
|
||||
* out on dead tokens (once per incident) and repeated failures (threshold in
|
||||
* `backup-alert.ts`), throttled per company.
|
||||
* 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
|
||||
@@ -52,11 +54,20 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
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, value')
|
||||
.select('company_id, user_id, key, value')
|
||||
.eq('extension_id', 'cloud-backup')
|
||||
.eq('key', SCHEDULE_KEY)
|
||||
.in('key', [...providerByScheduleKey.keys()])
|
||||
|
||||
if (error) {
|
||||
ctx.log.error('failed to fetch schedules', error, {
|
||||
@@ -70,9 +81,40 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
return NextResponse.json({ message: 'No schedules configured', processed: 0 })
|
||||
}
|
||||
|
||||
const candidates = rows.filter((r) =>
|
||||
isScheduleDue(r.value as GoogleDriveSchedule | null, now)
|
||||
)
|
||||
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({
|
||||
@@ -83,17 +125,17 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
}
|
||||
|
||||
// Connections flagged needs_reauth carry a permanently dead refresh token
|
||||
// (Google returned 400 invalid_grant): skip them instead of retrying every
|
||||
// night. They stay visible in the UI until the user reconnects.
|
||||
// (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, value')
|
||||
.select('company_id, key, value')
|
||||
.eq('extension_id', 'cloud-backup')
|
||||
.eq('key', CONNECTION_KEY)
|
||||
.in(
|
||||
'company_id',
|
||||
candidates.map((r) => r.company_id as string)
|
||||
)
|
||||
.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,
|
||||
@@ -103,10 +145,12 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
})
|
||||
}
|
||||
|
||||
const connectionByCompany = new Map<string, GoogleDriveConnection>()
|
||||
// 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 GoogleDriveConnection | null
|
||||
if (value) connectionByCompany.set(r.company_id as string, value)
|
||||
const value = r.value as CloudConnection | null
|
||||
if (value) connectionByCompanyAndKey.set(`${r.company_id}:${r.key}`, value)
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
@@ -114,6 +158,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
|
||||
const results: {
|
||||
companyId: string
|
||||
provider: string
|
||||
status: 'success' | 'error' | 'skipped'
|
||||
error?: string
|
||||
}[] = []
|
||||
@@ -125,6 +170,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
const maybeAlert = async (params: {
|
||||
companyId: string
|
||||
userId: string
|
||||
providerLabel: string
|
||||
kind: BackupAlertKind
|
||||
consecutiveFailures: number
|
||||
errorMessage: string | null
|
||||
@@ -144,6 +190,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
const sent = await sendBackupFailureAlert(supabase, {
|
||||
companyId: params.companyId,
|
||||
userId: params.userId,
|
||||
providerLabel: params.providerLabel,
|
||||
kind: params.kind,
|
||||
consecutiveFailures: params.consecutiveFailures,
|
||||
errorMessage: params.errorMessage,
|
||||
@@ -152,7 +199,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
return sent.sent ? new Date().toISOString() : prior
|
||||
}
|
||||
|
||||
for (const row of candidates) {
|
||||
for (const candidate of candidates) {
|
||||
if (Date.now() - startTime > TIME_BUDGET_MS) {
|
||||
ctx.log.info('time budget reached', {
|
||||
processedSoFar: results.length,
|
||||
@@ -161,11 +208,12 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
break
|
||||
}
|
||||
|
||||
const companyId = row.company_id as string
|
||||
const userId = row.user_id as string
|
||||
const schedule = row.value as GoogleDriveSchedule
|
||||
const { companyId, userId, provider, schedule } = candidate
|
||||
const scheduleKey = provider.keys.schedule
|
||||
|
||||
const connection = connectionByCompany.get(companyId)
|
||||
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
|
||||
@@ -180,21 +228,30 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
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, SCHEDULE_KEY, {
|
||||
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 })
|
||||
ctx.log.error('failed to persist alert state', persistErr as Error, {
|
||||
companyId,
|
||||
provider: provider.id,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
results.push({ companyId, status: 'skipped', error: 'needs_reauth' })
|
||||
results.push({
|
||||
companyId,
|
||||
provider: provider.id,
|
||||
status: 'skipped',
|
||||
error: 'needs_reauth',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -206,6 +263,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
origin,
|
||||
includeDocuments: true,
|
||||
allowDocumentFallback: true,
|
||||
provider,
|
||||
})
|
||||
|
||||
const consecutiveFailures = syncResult.ok
|
||||
@@ -217,6 +275,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
lastAlertAt = await maybeAlert({
|
||||
companyId,
|
||||
userId,
|
||||
providerLabel: provider.label,
|
||||
kind: syncResult.reason === 'needs_reauth' ? 'needs_reauth' : 'repeated_failures',
|
||||
consecutiveFailures,
|
||||
errorMessage: safeSyncError,
|
||||
@@ -224,7 +283,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
})
|
||||
}
|
||||
|
||||
const updated: GoogleDriveSchedule = {
|
||||
const updated: CloudSchedule = {
|
||||
...schedule,
|
||||
last_auto_sync_at: new Date().toISOString(),
|
||||
last_auto_sync_status: syncResult.ok ? 'success' : 'error',
|
||||
@@ -232,10 +291,11 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
consecutive_failures: consecutiveFailures,
|
||||
last_alert_at: lastAlertAt,
|
||||
}
|
||||
await saveExtensionData(supabase, companyId, userId, SCHEDULE_KEY, updated)
|
||||
await saveExtensionData(supabase, companyId, userId, scheduleKey, updated)
|
||||
|
||||
results.push({
|
||||
companyId,
|
||||
provider: provider.id,
|
||||
status: syncResult.ok ? 'success' : 'error',
|
||||
error: safeSyncError ?? undefined,
|
||||
})
|
||||
@@ -243,19 +303,21 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
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: GoogleDriveSchedule = {
|
||||
const updated: CloudSchedule = {
|
||||
...schedule,
|
||||
last_auto_sync_at: new Date().toISOString(),
|
||||
last_auto_sync_status: 'error',
|
||||
@@ -263,13 +325,21 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
consecutive_failures: consecutiveFailures,
|
||||
last_alert_at: lastAlertAt,
|
||||
}
|
||||
await saveExtensionData(supabase, companyId, userId, SCHEDULE_KEY, updated).catch(
|
||||
await saveExtensionData(supabase, companyId, userId, scheduleKey, updated).catch(
|
||||
(persistErr) => {
|
||||
ctx.log.error('failed to persist failure state', persistErr as Error, { companyId })
|
||||
ctx.log.error('failed to persist failure state', persistErr as Error, {
|
||||
companyId,
|
||||
provider: provider.id,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
results.push({ companyId, status: 'error', error: safeMessage })
|
||||
results.push({
|
||||
companyId,
|
||||
provider: provider.id,
|
||||
status: 'error',
|
||||
error: safeMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,17 +8,35 @@ import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-exten
|
||||
|
||||
// Local mirror of the cloud-backup status shape: core must not import from
|
||||
// @/extensions/, so the fields we read are declared here.
|
||||
interface BackupStatus {
|
||||
interface BackupProviderStatus {
|
||||
provider: string
|
||||
connected: boolean
|
||||
needs_reauth: boolean
|
||||
schedule: { last_auto_sync_status: 'success' | 'error' | null } | null
|
||||
}
|
||||
|
||||
interface BackupStatus {
|
||||
providers?: BackupProviderStatus[]
|
||||
// Pre-multi-provider shape, describing Google Drive alone.
|
||||
connected: boolean
|
||||
needs_reauth: boolean
|
||||
schedule: { last_auto_sync_status: 'success' | 'error' | null } | null
|
||||
}
|
||||
|
||||
/** Brand names stay untranslated; the sentence around them is localised. */
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
google_drive: 'Google Drive',
|
||||
dropbox: 'Dropbox',
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning shown on the dashboard ONLY when the Google Drive backup is failing
|
||||
* Warning shown on the dashboard ONLY when a connected cloud backup is failing
|
||||
* (dead token or errored auto-sync). A backup that silently stops is worse
|
||||
* than none; this makes the failure visible where the user actually is.
|
||||
* Renders nothing when the extension is off, disconnected, or healthy.
|
||||
*
|
||||
* With more than one destination connected, a failure on either one surfaces:
|
||||
* a working Drive backup does not make a broken Dropbox backup acceptable.
|
||||
*/
|
||||
export default function BackupHealthBanner() {
|
||||
const t = useTranslations('extensions')
|
||||
@@ -40,19 +58,39 @@ export default function BackupHealthBanner() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!status?.connected) return null
|
||||
const failing =
|
||||
status.needs_reauth || status.schedule?.last_auto_sync_status === 'error'
|
||||
if (!failing) return null
|
||||
if (!status) return null
|
||||
|
||||
const providers: BackupProviderStatus[] = status.providers ?? [
|
||||
{
|
||||
provider: 'google_drive',
|
||||
connected: status.connected,
|
||||
needs_reauth: status.needs_reauth,
|
||||
schedule: status.schedule,
|
||||
},
|
||||
]
|
||||
|
||||
const failing = providers.filter(
|
||||
(p) =>
|
||||
p.connected &&
|
||||
(p.needs_reauth || p.schedule?.last_auto_sync_status === 'error')
|
||||
)
|
||||
if (failing.length === 0) return null
|
||||
|
||||
// One sentence covering everything that is broken, so two dead connections
|
||||
// do not stack two banners on the dashboard.
|
||||
const names = failing
|
||||
.map((p) => PROVIDER_LABELS[p.provider] ?? p.provider)
|
||||
.join(' + ')
|
||||
const allNeedReauth = failing.every((p) => p.needs_reauth)
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-medium">
|
||||
{status.needs_reauth
|
||||
? t('ext_cloud_backup_banner_reauth')
|
||||
: t('ext_cloud_backup_banner_failing')}
|
||||
{allNeedReauth
|
||||
? t('ext_cloud_backup_banner_reauth', { provider: names })
|
||||
: t('ext_cloud_backup_banner_failing', { provider: names })}
|
||||
</p>
|
||||
<Link
|
||||
href="/import#cloud-backup"
|
||||
|
||||
@@ -11,8 +11,8 @@ export default function CloudBackupWorkspace(_props: WorkspaceComponentProps) {
|
||||
<Cloud className="h-12 w-12 text-muted-foreground/40 mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground">Molnsynkronisering</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-md">
|
||||
Koppla ditt Google Drive-konto under Importera/Exportera för att synka arkiv till
|
||||
din egen molnlagring.
|
||||
Koppla ditt Google Drive- eller Dropbox-konto under Importera/Exportera för att
|
||||
synka arkiv till din egen molnlagring.
|
||||
</p>
|
||||
<Button asChild variant="outline" className="mt-4">
|
||||
<Link href="/import#cloud-backup">
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { cloudBackupExtension } from '../index'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
import type { CloudBackupStatus } from '../types'
|
||||
|
||||
const BASE = 'https://test.local/api/extensions/ext/cloud-backup'
|
||||
|
||||
function findRoute(method: string, path: string) {
|
||||
const route = cloudBackupExtension.apiRoutes?.find(
|
||||
(r) => r.method === method && r.path === path
|
||||
)
|
||||
expect(route, `${method} ${path} must be registered`).toBeDefined()
|
||||
return route!
|
||||
}
|
||||
|
||||
function makeRequest(path: string, method = 'POST', body?: unknown): Request {
|
||||
return new Request(`${BASE}${path}`, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/** Settings backed by a map, so a test can assert which keys were touched. */
|
||||
function makeContext(seed: Record<string, unknown> = {}) {
|
||||
const store = new Map<string, unknown>(Object.entries(seed))
|
||||
const set = vi.fn().mockImplementation(async (key: string, value: unknown) => {
|
||||
store.set(key, value)
|
||||
})
|
||||
const clear = vi.fn().mockImplementation(async (key: string) => {
|
||||
store.delete(key)
|
||||
})
|
||||
const ctx = {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'cloud-backup',
|
||||
requestId: 'req_test',
|
||||
supabase: {},
|
||||
emit: vi.fn().mockResolvedValue(undefined),
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
settings: {
|
||||
get: vi.fn().mockImplementation(async (key: string) => store.get(key) ?? null),
|
||||
set,
|
||||
clear,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any as ExtensionContext
|
||||
return { ctx, store, set, clear }
|
||||
}
|
||||
|
||||
function makeConnection(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
refresh_token_encrypted: 'enc',
|
||||
account_email: 'user@example.com',
|
||||
connected_at: '2026-01-01T00:00:00.000Z',
|
||||
root_folder_id: null,
|
||||
company_folder_id: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const ENV_KEYS = [
|
||||
'GOOGLE_CLIENT_ID',
|
||||
'GOOGLE_CLIENT_SECRET',
|
||||
'DROPBOX_APP_KEY',
|
||||
'DROPBOX_APP_SECRET',
|
||||
// The OAuth state parameter is encrypted with a key derived from this.
|
||||
'SUPABASE_SERVICE_ROLE_KEY',
|
||||
] as const
|
||||
const originalEnv: Record<string, string | undefined> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
for (const key of ENV_KEYS) originalEnv[key] = process.env[key]
|
||||
process.env.GOOGLE_CLIENT_ID = 'google-id'
|
||||
process.env.GOOGLE_CLIENT_SECRET = 'google-secret'
|
||||
process.env.DROPBOX_APP_KEY = 'dropbox-key'
|
||||
process.env.DROPBOX_APP_SECRET = 'dropbox-secret'
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role-key'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (originalEnv[key] === undefined) delete process.env[key]
|
||||
else process.env[key] = originalEnv[key]
|
||||
}
|
||||
})
|
||||
|
||||
describe('provider routing', () => {
|
||||
it('rejects an unknown provider rather than defaulting to a destination', async () => {
|
||||
const route = findRoute('POST', '/connect')
|
||||
const { ctx } = makeContext()
|
||||
|
||||
const res = await route.handler(makeRequest('/connect?provider=onedrive'), ctx)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
await expect(res.json()).resolves.toEqual({ error: 'unknown_provider' })
|
||||
})
|
||||
|
||||
it('treats a missing provider as Google Drive, as pre-Dropbox clients meant', async () => {
|
||||
const route = findRoute('POST', '/connect')
|
||||
const { ctx } = makeContext()
|
||||
|
||||
const res = await route.handler(makeRequest('/connect'), ctx)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const { url } = (await res.json()) as { url: string }
|
||||
expect(url).toContain('accounts.google.com')
|
||||
})
|
||||
|
||||
it('sends the user to Dropbox with an app-folder scope set', async () => {
|
||||
const route = findRoute('POST', '/connect')
|
||||
const { ctx } = makeContext()
|
||||
|
||||
const res = await route.handler(makeRequest('/connect?provider=dropbox'), ctx)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const { url } = (await res.json()) as { url: string }
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.origin + parsed.pathname).toBe('https://www.dropbox.com/oauth2/authorize')
|
||||
// Without offline access Dropbox issues no refresh token and the nightly
|
||||
// cron dies a few hours after connecting.
|
||||
expect(parsed.searchParams.get('token_access_type')).toBe('offline')
|
||||
expect(parsed.searchParams.get('scope')).toBe(
|
||||
'files.content.write files.content.read account_info.read'
|
||||
)
|
||||
expect(parsed.searchParams.get('redirect_uri')).toBe(
|
||||
'https://test.local/api/extensions/ext/cloud-backup/oauth/dropbox/callback'
|
||||
)
|
||||
expect(parsed.searchParams.get('state')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('refuses to start a flow the deployment has no credentials for', async () => {
|
||||
delete process.env.DROPBOX_APP_KEY
|
||||
delete process.env.DROPBOX_APP_SECRET
|
||||
const route = findRoute('POST', '/connect')
|
||||
const { ctx } = makeContext()
|
||||
|
||||
const res = await route.handler(makeRequest('/connect?provider=dropbox'), ctx)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
await expect(res.json()).resolves.toEqual({ error: 'provider_not_configured' })
|
||||
})
|
||||
|
||||
it('registers a separate OAuth callback per provider', () => {
|
||||
expect(findRoute('GET', '/oauth/callback')).toBeDefined()
|
||||
expect(findRoute('GET', '/oauth/dropbox/callback')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /status', () => {
|
||||
it('reports every provider independently', async () => {
|
||||
const route = findRoute('GET', '/status')
|
||||
const { ctx } = makeContext({
|
||||
google_drive_connection: makeConnection({ account_email: 'a@example.com' }),
|
||||
dropbox_connection: makeConnection({
|
||||
account_email: 'b@example.com',
|
||||
status: 'needs_reauth',
|
||||
}),
|
||||
})
|
||||
|
||||
const res = await route.handler(makeRequest('/status', 'GET'), ctx)
|
||||
const { data } = (await res.json()) as { data: CloudBackupStatus }
|
||||
|
||||
expect(data.providers.map((p) => p.provider)).toEqual(['google_drive', 'dropbox'])
|
||||
const google = data.providers[0]
|
||||
const dropbox = data.providers[1]
|
||||
expect(google).toMatchObject({
|
||||
connected: true,
|
||||
needs_reauth: false,
|
||||
account_email: 'a@example.com',
|
||||
})
|
||||
expect(dropbox).toMatchObject({
|
||||
connected: true,
|
||||
needs_reauth: true,
|
||||
account_email: 'b@example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('flags a provider the deployment cannot use so the UI can disable the row', async () => {
|
||||
delete process.env.DROPBOX_APP_KEY
|
||||
delete process.env.DROPBOX_APP_SECRET
|
||||
const route = findRoute('GET', '/status')
|
||||
const { ctx } = makeContext()
|
||||
|
||||
const res = await route.handler(makeRequest('/status', 'GET'), ctx)
|
||||
const { data } = (await res.json()) as { data: CloudBackupStatus }
|
||||
|
||||
expect(data.providers.find((p) => p.provider === 'google_drive')?.configured).toBe(true)
|
||||
expect(data.providers.find((p) => p.provider === 'dropbox')?.configured).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps mirroring Google Drive at the top level for pre-Dropbox clients', async () => {
|
||||
const route = findRoute('GET', '/status')
|
||||
const { ctx } = makeContext({
|
||||
google_drive_connection: makeConnection({ account_email: 'a@example.com' }),
|
||||
})
|
||||
|
||||
const res = await route.handler(makeRequest('/status', 'GET'), ctx)
|
||||
const { data } = (await res.json()) as { data: CloudBackupStatus }
|
||||
|
||||
expect(data.connected).toBe(true)
|
||||
expect(data.account_email).toBe('a@example.com')
|
||||
})
|
||||
|
||||
it('does not report a Dropbox connection as a Google one at the top level', async () => {
|
||||
const route = findRoute('GET', '/status')
|
||||
const { ctx } = makeContext({ dropbox_connection: makeConnection() })
|
||||
|
||||
const res = await route.handler(makeRequest('/status', 'GET'), ctx)
|
||||
const { data } = (await res.json()) as { data: CloudBackupStatus }
|
||||
|
||||
// The legacy fields describe Google Drive alone: a dashboard banner reading
|
||||
// them must not claim the Drive backup is healthy because Dropbox is.
|
||||
expect(data.connected).toBe(false)
|
||||
expect(data.account_email).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /disconnect', () => {
|
||||
it('clears only the targeted provider records', async () => {
|
||||
const route = findRoute('POST', '/disconnect')
|
||||
const { ctx, store } = makeContext({
|
||||
google_drive_connection: makeConnection(),
|
||||
google_drive_last_sync: { at: 'x', folder_id: 'f' },
|
||||
google_drive_schedule: { enabled: true },
|
||||
dropbox_connection: makeConnection(),
|
||||
dropbox_last_sync: { at: 'y', folder_id: '/g' },
|
||||
dropbox_schedule: { enabled: true },
|
||||
})
|
||||
|
||||
const res = await route.handler(makeRequest('/disconnect?provider=dropbox'), ctx)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect([...store.keys()].sort()).toEqual([
|
||||
'google_drive_connection',
|
||||
'google_drive_last_sync',
|
||||
'google_drive_schedule',
|
||||
])
|
||||
})
|
||||
|
||||
it('still works when the deployment lost its credentials', async () => {
|
||||
// A user must never be trapped with a connection they cannot remove.
|
||||
delete process.env.DROPBOX_APP_KEY
|
||||
delete process.env.DROPBOX_APP_SECRET
|
||||
const route = findRoute('POST', '/disconnect')
|
||||
const { ctx, store } = makeContext({ dropbox_connection: makeConnection() })
|
||||
|
||||
const res = await route.handler(makeRequest('/disconnect?provider=dropbox'), ctx)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(store.has('dropbox_connection')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('schedule routes are per provider', () => {
|
||||
it('writes the schedule under the targeted provider key', async () => {
|
||||
const route = findRoute('PUT', '/schedule')
|
||||
const { ctx, store } = makeContext()
|
||||
|
||||
const res = await route.handler(
|
||||
makeRequest('/schedule?provider=dropbox', 'PUT', { enabled: true, hour_local: 4 }),
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(store.has('dropbox_schedule')).toBe(true)
|
||||
expect(store.has('google_drive_schedule')).toBe(false)
|
||||
})
|
||||
|
||||
it('reads each provider schedule separately', async () => {
|
||||
const route = findRoute('GET', '/schedule')
|
||||
const { ctx } = makeContext({
|
||||
google_drive_schedule: { enabled: true, hour_utc: 3, hour_local: 5 },
|
||||
dropbox_schedule: { enabled: false, hour_utc: 9, hour_local: 11 },
|
||||
})
|
||||
|
||||
const google = await route.handler(makeRequest('/schedule', 'GET'), ctx)
|
||||
const dropbox = await route.handler(
|
||||
makeRequest('/schedule?provider=dropbox', 'GET'),
|
||||
ctx
|
||||
)
|
||||
|
||||
await expect(google.json()).resolves.toMatchObject({
|
||||
data: { enabled: true, hour_local: 5 },
|
||||
})
|
||||
await expect(dropbox.json()).resolves.toMatchObject({
|
||||
data: { enabled: false, hour_local: 11 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,26 +11,42 @@ import {
|
||||
DestructiveConfirmDialog,
|
||||
useDestructiveConfirm,
|
||||
} from '@/components/ui/destructive-confirm-dialog'
|
||||
import { AlertTriangle, Cloud, ExternalLink, Loader2, RefreshCw, Unplug } from 'lucide-react'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Box,
|
||||
Cloud,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Unplug,
|
||||
} from 'lucide-react'
|
||||
import type {
|
||||
CloudBackupStatus,
|
||||
GoogleDriveLastSync,
|
||||
GoogleDriveSchedule,
|
||||
CloudLastSync,
|
||||
CloudProviderId,
|
||||
CloudProviderStatus,
|
||||
CloudSchedule,
|
||||
} from '../types'
|
||||
|
||||
const API_BASE = '/api/extensions/ext/cloud-backup'
|
||||
|
||||
/**
|
||||
* Provider presentation. The label is the brand name and stays untranslated in
|
||||
* both locales; everything around it is a `{provider}` placeholder so the
|
||||
* copy reads naturally whichever destination the row describes.
|
||||
*/
|
||||
const PROVIDER_META: Record<CloudProviderId, { label: string; icon: typeof Cloud }> = {
|
||||
google_drive: { label: 'Google Drive', icon: Cloud },
|
||||
dropbox: { label: 'Dropbox', icon: Box },
|
||||
}
|
||||
|
||||
export default function CloudBackupCard() {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('extensions')
|
||||
const { toast } = useToast()
|
||||
const searchParams = useSearchParams()
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
|
||||
const [status, setStatus] = useState<CloudBackupStatus | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false)
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
@@ -56,13 +72,18 @@ export default function CloudBackupCard() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle OAuth callback redirect params.
|
||||
// Handle OAuth callback redirect params. `provider` tells us which row the
|
||||
// user just came back from; absent means a redirect issued before Dropbox
|
||||
// existed, which can only have been Google Drive.
|
||||
useEffect(() => {
|
||||
const result = searchParams.get('cloud_backup')
|
||||
if (!result) return
|
||||
const providerId = (searchParams.get('provider') as CloudProviderId) || 'google_drive'
|
||||
const providerLabel = PROVIDER_META[providerId]?.label ?? providerId
|
||||
|
||||
if (result === 'connected' || result === 'connected_first') {
|
||||
toast({
|
||||
title: t('ext_cloud_backup_connected_title'),
|
||||
title: t('ext_cloud_backup_connected_title', { provider: providerLabel }),
|
||||
description: t(
|
||||
result === 'connected_first'
|
||||
? 'ext_cloud_backup_connected_first_description'
|
||||
@@ -81,7 +102,7 @@ export default function CloudBackupCard() {
|
||||
} else if (result === 'error') {
|
||||
const reason = searchParams.get('reason') || t('ext_cloud_backup_unknown_error')
|
||||
toast({
|
||||
title: t('ext_cloud_backup_connect_failed'),
|
||||
title: t('ext_cloud_backup_connect_failed', { provider: providerLabel }),
|
||||
description: reason,
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -89,14 +110,61 @@ export default function CloudBackupCard() {
|
||||
// Clean the URL so refresh doesn't re-fire the toast.
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('cloud_backup')
|
||||
url.searchParams.delete('provider')
|
||||
url.searchParams.delete('reason')
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}, [loadStatus, searchParams, t, toast])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<p className="text-sm text-muted-foreground">{t('ext_cloud_backup_loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{(status?.providers ?? []).map((providerStatus) => (
|
||||
<ProviderRow
|
||||
key={providerStatus.provider}
|
||||
status={providerStatus}
|
||||
onChanged={loadStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProviderRowProps {
|
||||
status: CloudProviderStatus
|
||||
onChanged: () => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
* One destination: identity on the left, its own connection state, schedule
|
||||
* and actions on the right. Every request carries `?provider=`, so the two
|
||||
* rows never touch each other's records.
|
||||
*/
|
||||
function ProviderRow({ status, onChanged }: ProviderRowProps) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('extensions')
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false)
|
||||
|
||||
const providerId = status.provider
|
||||
const meta = PROVIDER_META[providerId]
|
||||
const provider = meta?.label ?? providerId
|
||||
const Icon = meta?.icon ?? Cloud
|
||||
const qs = `?provider=${encodeURIComponent(providerId)}`
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
setIsConnecting(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/connect`, { method: 'POST' })
|
||||
const res = await fetch(`${API_BASE}/connect${qs}`, { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || t('ext_cloud_backup_connect_start_failed'))
|
||||
@@ -105,24 +173,24 @@ export default function CloudBackupCard() {
|
||||
window.location.href = url
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('ext_cloud_backup_connect_failed'),
|
||||
title: t('ext_cloud_backup_connect_failed', { provider }),
|
||||
description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}, [t, toast])
|
||||
}, [provider, qs, t, toast])
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
setIsDisconnecting(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/disconnect`, { method: 'POST' })
|
||||
const res = await fetch(`${API_BASE}/disconnect${qs}`, { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || t('ext_cloud_backup_disconnect_failed'))
|
||||
}
|
||||
toast({ title: t('ext_cloud_backup_disconnected') })
|
||||
await loadStatus()
|
||||
toast({ title: t('ext_cloud_backup_disconnected', { provider }) })
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('ext_cloud_backup_disconnect_failed'),
|
||||
@@ -132,7 +200,7 @@ export default function CloudBackupCard() {
|
||||
} finally {
|
||||
setIsDisconnecting(false)
|
||||
}
|
||||
}, [loadStatus, t, toast])
|
||||
}, [onChanged, provider, qs, t, toast])
|
||||
|
||||
type SyncOutcome =
|
||||
| { result: 'ok' | 'error' }
|
||||
@@ -142,7 +210,7 @@ export default function CloudBackupCard() {
|
||||
async (allowDocumentFallback: boolean): Promise<SyncOutcome> => {
|
||||
setIsSyncing(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/sync`, {
|
||||
const res = await fetch(`${API_BASE}/sync${qs}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -166,14 +234,14 @@ export default function CloudBackupCard() {
|
||||
}
|
||||
}
|
||||
if (body.error === 'needs_reauth') {
|
||||
// Refresh status so the card switches to the reconnect state.
|
||||
await loadStatus()
|
||||
throw new Error(t('ext_cloud_backup_reauth_description'))
|
||||
// Refresh status so the row switches to the reconnect state.
|
||||
await onChanged()
|
||||
throw new Error(t('ext_cloud_backup_reauth_description', { provider }))
|
||||
}
|
||||
throw new Error(body.error || t('ext_cloud_backup_sync_failed'))
|
||||
}
|
||||
const { data } = (await res.json()) as {
|
||||
data: GoogleDriveLastSync & {
|
||||
data: CloudLastSync & {
|
||||
web_view_link: string
|
||||
uploaded_count?: number
|
||||
skipped_count?: number
|
||||
@@ -189,7 +257,7 @@ export default function CloudBackupCard() {
|
||||
(f) => f.kind !== 'readme' && f.included_documents === false
|
||||
)
|
||||
toast({
|
||||
title: t('ext_cloud_backup_uploaded'),
|
||||
title: t('ext_cloud_backup_uploaded', { provider }),
|
||||
description: `${t('ext_cloud_backup_files_updated', {
|
||||
count: data.uploaded_count ?? 0,
|
||||
})} (${formatMb(data.total_size_bytes ?? data.file_size_bytes ?? 0)})${
|
||||
@@ -197,7 +265,7 @@ export default function CloudBackupCard() {
|
||||
}`,
|
||||
})
|
||||
}
|
||||
await loadStatus()
|
||||
await onChanged()
|
||||
return { result: 'ok' }
|
||||
} catch (err) {
|
||||
toast({
|
||||
@@ -210,7 +278,7 @@ export default function CloudBackupCard() {
|
||||
setIsSyncing(false)
|
||||
}
|
||||
},
|
||||
[loadStatus, t, toast]
|
||||
[onChanged, provider, qs, t, toast]
|
||||
)
|
||||
|
||||
const handleSync = useCallback(async () => {
|
||||
@@ -235,34 +303,36 @@ export default function CloudBackupCard() {
|
||||
{/* Identity */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
|
||||
<Cloud className="h-[18px] w-[18px] text-foreground/60" />
|
||||
<Icon className="h-[18px] w-[18px] text-foreground/60" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[15px] font-semibold leading-tight">Google Drive</h3>
|
||||
<h3 className="text-[15px] font-semibold leading-tight">{provider}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||
{t('ext_cloud_backup_card_tagline')}
|
||||
{t('ext_cloud_backup_card_tagline', { provider })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2 leading-relaxed">
|
||||
{t('ext_cloud_backup_legal_note')}
|
||||
{t('ext_cloud_backup_legal_note', { provider })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{t('ext_cloud_backup_loading')}</p>
|
||||
) : status?.connected ? (
|
||||
{!status.configured ? (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t('ext_cloud_backup_not_configured', { provider })}
|
||||
</p>
|
||||
) : status.connected ? (
|
||||
<>
|
||||
{status.needs_reauth && (
|
||||
<div className="mb-6 flex items-start gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('ext_cloud_backup_reauth_title')}
|
||||
{t('ext_cloud_backup_reauth_title', { provider })}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
||||
{t('ext_cloud_backup_reauth_description')}
|
||||
{t('ext_cloud_backup_reauth_description', { provider })}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
@@ -276,8 +346,8 @@ export default function CloudBackupCard() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
{t('ext_cloud_backup_reauth_action')}
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{t('ext_cloud_backup_reauth_action', { provider })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -297,7 +367,10 @@ export default function CloudBackupCard() {
|
||||
</dt>
|
||||
<dd className="min-w-0 text-right">
|
||||
{status.last_sync ? (
|
||||
<LastSyncSummary lastSync={status.last_sync} />
|
||||
<LastSyncSummary
|
||||
lastSync={status.last_sync}
|
||||
providerId={providerId}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t('ext_cloud_backup_never')}
|
||||
@@ -309,9 +382,11 @@ export default function CloudBackupCard() {
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-border">
|
||||
<ScheduleSection
|
||||
providerId={providerId}
|
||||
provider={provider}
|
||||
schedule={status.schedule}
|
||||
needsReauth={status.needs_reauth}
|
||||
onUpdated={loadStatus}
|
||||
onUpdated={onChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -352,7 +427,11 @@ export default function CloudBackupCard() {
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t('ext_cloud_backup_connect_description')}
|
||||
{t(
|
||||
providerId === 'dropbox'
|
||||
? 'ext_cloud_backup_connect_description_dropbox'
|
||||
: 'ext_cloud_backup_connect_description_google'
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button onClick={handleConnect} disabled={isConnecting} className="w-full sm:w-auto">
|
||||
@@ -363,8 +442,8 @@ export default function CloudBackupCard() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
{t('ext_cloud_backup_connect')}
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
{t('ext_cloud_backup_connect', { provider })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -378,15 +457,26 @@ export default function CloudBackupCard() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-sync cell. New records list the per-fiscal-year files and link to the
|
||||
* Drive folder; legacy single-ZIP records link to the file.
|
||||
* Last-sync cell. Records written since the Dropbox target landed carry their
|
||||
* own `web_view_link`; older Drive records only have a folder id, so the Drive
|
||||
* URL is reconstructed. Legacy single-ZIP records link to the file itself.
|
||||
*/
|
||||
function LastSyncSummary({ lastSync }: { lastSync: GoogleDriveLastSync }) {
|
||||
function LastSyncSummary({
|
||||
lastSync,
|
||||
providerId,
|
||||
}: {
|
||||
lastSync: CloudLastSync
|
||||
providerId: CloudProviderId
|
||||
}) {
|
||||
const t = useTranslations('extensions')
|
||||
const files = lastSync.files
|
||||
const href = files
|
||||
? `https://drive.google.com/drive/folders/${lastSync.folder_id}`
|
||||
: `https://drive.google.com/file/d/${lastSync.file_id}/view`
|
||||
const href =
|
||||
lastSync.web_view_link ??
|
||||
(providerId === 'google_drive'
|
||||
? files
|
||||
? `https://drive.google.com/drive/folders/${lastSync.folder_id}`
|
||||
: `https://drive.google.com/file/d/${lastSync.file_id}/view`
|
||||
: 'https://www.dropbox.com/home/Apps')
|
||||
const sizeBytes = files
|
||||
? lastSync.total_size_bytes ?? 0
|
||||
: lastSync.file_size_bytes ?? 0
|
||||
@@ -439,18 +529,26 @@ function formatDateTime(iso: string): string {
|
||||
}
|
||||
|
||||
interface ScheduleSectionProps {
|
||||
schedule: GoogleDriveSchedule | null
|
||||
providerId: CloudProviderId
|
||||
provider: string
|
||||
schedule: CloudSchedule | null
|
||||
needsReauth: boolean
|
||||
onUpdated: () => Promise<void> | void
|
||||
}
|
||||
|
||||
function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionProps) {
|
||||
function ScheduleSection({
|
||||
providerId,
|
||||
provider,
|
||||
schedule,
|
||||
needsReauth,
|
||||
onUpdated,
|
||||
}: ScheduleSectionProps) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('extensions')
|
||||
|
||||
// Prefer the DST-stable Stockholm hour; fall back to converting the legacy
|
||||
// UTC hour through the browser's clock (Swedish users: same thing).
|
||||
const scheduleHour = (s: GoogleDriveSchedule | null): number =>
|
||||
const scheduleHour = (s: CloudSchedule | null): number =>
|
||||
typeof s?.hour_local === 'number'
|
||||
? s.hour_local
|
||||
: utcHourToLocalHour(typeof s?.hour_utc === 'number' ? s.hour_utc : 3)
|
||||
@@ -459,6 +557,10 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr
|
||||
const [localHour, setLocalHour] = useState(scheduleHour(schedule))
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Each provider renders its own controls, so the ids must not collide.
|
||||
const toggleId = `auto-sync-toggle-${providerId}`
|
||||
const hourId = `auto-sync-hour-${providerId}`
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(schedule?.enabled ?? false)
|
||||
setLocalHour(scheduleHour(schedule))
|
||||
@@ -469,14 +571,17 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr
|
||||
async (nextEnabled: boolean, nextLocalHour: number) => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/schedule`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: nextEnabled,
|
||||
hour_local: nextLocalHour,
|
||||
}),
|
||||
})
|
||||
const res = await fetch(
|
||||
`${API_BASE}/schedule?provider=${encodeURIComponent(providerId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: nextEnabled,
|
||||
hour_local: nextLocalHour,
|
||||
}),
|
||||
}
|
||||
)
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || t('ext_cloud_backup_schedule_save_failed'))
|
||||
@@ -492,7 +597,7 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr
|
||||
setIsSaving(false)
|
||||
}
|
||||
},
|
||||
[onUpdated, t, toast]
|
||||
[onUpdated, providerId, t, toast]
|
||||
)
|
||||
|
||||
const handleToggle = useCallback(
|
||||
@@ -516,15 +621,15 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Label htmlFor="auto-sync-toggle" className="text-sm font-medium">
|
||||
<Label htmlFor={toggleId} className="text-sm font-medium">
|
||||
{t('ext_cloud_backup_auto_sync_title')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t('ext_cloud_backup_auto_sync_description')}
|
||||
{t('ext_cloud_backup_auto_sync_description', { provider })}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-sync-toggle"
|
||||
id={toggleId}
|
||||
checked={enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isSaving}
|
||||
@@ -533,11 +638,11 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr
|
||||
|
||||
{enabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="auto-sync-hour" className="text-xs text-muted-foreground">
|
||||
<Label htmlFor={hourId} className="text-xs text-muted-foreground">
|
||||
{t('ext_cloud_backup_time_label')}
|
||||
</Label>
|
||||
<select
|
||||
id="auto-sync-hour"
|
||||
id={hourId}
|
||||
value={localHour}
|
||||
onChange={handleHourChange}
|
||||
disabled={isSaving}
|
||||
|
||||
@@ -1,43 +1,30 @@
|
||||
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
import { NextResponse, after } from 'next/server'
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
exchangeCodeForTokens,
|
||||
fetchUserEmail,
|
||||
getOAuthEnv,
|
||||
revokeToken,
|
||||
} from './lib/google-oauth'
|
||||
import {
|
||||
createOAuthState,
|
||||
decryptToken,
|
||||
encryptToken,
|
||||
verifyOAuthState,
|
||||
} from './lib/crypto'
|
||||
import {
|
||||
performSync,
|
||||
CONNECTION_KEY,
|
||||
LAST_SYNC_KEY,
|
||||
SCHEDULE_KEY,
|
||||
} from './lib/sync'
|
||||
import { performSync } from './lib/sync'
|
||||
import { stockholmHourToUtcHour } from './lib/schedule'
|
||||
import { CLOUD_PROVIDERS, providerFromRequest } from './lib/provider-registry'
|
||||
import { googleDriveProvider } from './lib/google-provider'
|
||||
import { dropboxProvider } from './lib/dropbox-provider'
|
||||
import type { CloudStorageProvider } from './lib/cloud-provider'
|
||||
import type {
|
||||
CloudBackupStatus,
|
||||
GoogleDriveConnection,
|
||||
GoogleDriveLastSync,
|
||||
GoogleDriveSchedule,
|
||||
CloudConnection,
|
||||
CloudLastSync,
|
||||
CloudProviderStatus,
|
||||
CloudSchedule,
|
||||
} from './types'
|
||||
|
||||
function jsonError(message: string, status = 500): Response {
|
||||
return NextResponse.json({ error: message }, { status })
|
||||
}
|
||||
|
||||
async function loadConnection(
|
||||
ctx: ExtensionContext
|
||||
): Promise<GoogleDriveConnection | null> {
|
||||
return ctx.settings.get<GoogleDriveConnection>(CONNECTION_KEY)
|
||||
}
|
||||
|
||||
const DEFAULT_SCHEDULE: GoogleDriveSchedule = {
|
||||
const DEFAULT_SCHEDULE: CloudSchedule = {
|
||||
enabled: false,
|
||||
hour_utc: 3,
|
||||
hour_local: 5, // 05:00 Swedish time, DST-stable: low-traffic default
|
||||
@@ -46,10 +33,150 @@ const DEFAULT_SCHEDULE: GoogleDriveSchedule = {
|
||||
last_auto_sync_error: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the destination a request targets. An unknown `?provider=` value is
|
||||
* an error rather than a silent fallback: writing a company's backup to the
|
||||
* wrong destination because of a typo must never happen quietly.
|
||||
*
|
||||
* `requireConfigured` is for `/connect` alone, which cannot succeed without
|
||||
* OAuth credentials. Every other route stays open even when a deployment loses
|
||||
* its credentials: a user must always be able to disconnect an account or
|
||||
* switch off a schedule, and locking those behind an env var would trap them.
|
||||
*/
|
||||
function resolveProvider(
|
||||
request: Request,
|
||||
options: { requireConfigured?: boolean } = {}
|
||||
): { provider: CloudStorageProvider } | { error: Response } {
|
||||
const provider = providerFromRequest(request)
|
||||
if (!provider) {
|
||||
return { error: jsonError('unknown_provider', 400) }
|
||||
}
|
||||
if (options.requireConfigured && !provider.isConfigured()) {
|
||||
return { error: jsonError('provider_not_configured', 400) }
|
||||
}
|
||||
return { provider }
|
||||
}
|
||||
|
||||
async function readProviderStatus(
|
||||
ctx: ExtensionContext,
|
||||
provider: CloudStorageProvider
|
||||
): Promise<CloudProviderStatus> {
|
||||
const connection = await ctx.settings.get<CloudConnection>(provider.keys.connection)
|
||||
const lastSync = await ctx.settings.get<CloudLastSync>(provider.keys.lastSync)
|
||||
const schedule = await ctx.settings.get<CloudSchedule>(provider.keys.schedule)
|
||||
return {
|
||||
provider: provider.id,
|
||||
configured: provider.isConfigured(),
|
||||
connected: !!connection,
|
||||
needs_reauth: connection?.status === 'needs_reauth',
|
||||
account_email: connection?.account_email ?? null,
|
||||
connected_at: connection?.connected_at ?? null,
|
||||
last_sync: lastSync ?? null,
|
||||
schedule: schedule ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared OAuth callback body. Each provider needs its own route because the
|
||||
* redirect URI is registered with the provider, but the exchange, the state
|
||||
* check and the first-backup kickoff are identical.
|
||||
*/
|
||||
async function handleOAuthCallback(
|
||||
request: Request,
|
||||
ctx: ExtensionContext | undefined,
|
||||
provider: CloudStorageProvider
|
||||
): Promise<Response> {
|
||||
if (!ctx) return jsonError('Missing context', 500)
|
||||
const url = new URL(request.url)
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const errorParam = url.searchParams.get('error')
|
||||
const origin = url.origin
|
||||
const redirect = (status: string, reason?: string) => {
|
||||
const target = new URL('/settings/backup', origin)
|
||||
target.searchParams.set('cloud_backup', status)
|
||||
target.searchParams.set('provider', provider.id)
|
||||
if (reason) target.searchParams.set('reason', reason)
|
||||
return NextResponse.redirect(target)
|
||||
}
|
||||
|
||||
if (errorParam) {
|
||||
return redirect('error', errorParam)
|
||||
}
|
||||
if (!code || !state) {
|
||||
return redirect('error', 'missing_params')
|
||||
}
|
||||
|
||||
const verified = verifyOAuthState(state)
|
||||
if (!verified) {
|
||||
return redirect('error', 'invalid_state')
|
||||
}
|
||||
if (verified.userId !== ctx.userId || verified.companyId !== ctx.companyId) {
|
||||
return redirect('error', 'state_mismatch')
|
||||
}
|
||||
|
||||
try {
|
||||
const { refreshToken, accountLabel } = await provider.exchangeCode(origin, code)
|
||||
|
||||
const connection: CloudConnection = {
|
||||
refresh_token_encrypted: encryptToken(refreshToken),
|
||||
account_email: accountLabel,
|
||||
connected_at: new Date().toISOString(),
|
||||
root_folder_id: null,
|
||||
company_folder_id: null,
|
||||
company_folder_path: null,
|
||||
}
|
||||
await ctx.settings.set(provider.keys.connection, connection)
|
||||
|
||||
// First-time connections get daily auto-sync on by default: a
|
||||
// backup that defaults to off protects nobody. Reconnects keep
|
||||
// whatever schedule the user had. Each provider is scheduled
|
||||
// independently, so connecting a second one does not disturb the first.
|
||||
const existingSchedule = await ctx.settings.get<CloudSchedule>(
|
||||
provider.keys.schedule
|
||||
)
|
||||
const firstConnect = !existingSchedule
|
||||
if (firstConnect) {
|
||||
await ctx.settings.set(provider.keys.schedule, {
|
||||
...DEFAULT_SCHEDULE,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Kick off the first backup after the redirect response is sent, so
|
||||
// the user lands back on the card immediately while the archive
|
||||
// builds in the background.
|
||||
const syncOrigin = process.env.NEXT_PUBLIC_APP_URL || origin
|
||||
after(async () => {
|
||||
try {
|
||||
await performSync({
|
||||
supabase: ctx.supabase,
|
||||
companyId: ctx.companyId,
|
||||
userId: ctx.userId,
|
||||
origin: syncOrigin,
|
||||
includeDocuments: true,
|
||||
allowDocumentFallback: true,
|
||||
provider,
|
||||
})
|
||||
} catch (err) {
|
||||
ctx.log.error(`initial ${provider.id} sync after connect failed`, err)
|
||||
}
|
||||
})
|
||||
|
||||
return redirect(firstConnect ? 'connected_first' : 'connected')
|
||||
} catch (err) {
|
||||
ctx.log.error(`${provider.id} oauth callback failed`, err)
|
||||
return redirect(
|
||||
'error',
|
||||
err instanceof Error ? err.message.slice(0, 80) : 'exchange_failed'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const cloudBackupExtension: Extension = {
|
||||
id: 'cloud-backup',
|
||||
name: 'Molnsynkronisering',
|
||||
version: '1.0.0',
|
||||
version: '1.1.0',
|
||||
sector: 'general',
|
||||
|
||||
// The canonical entry point for cloud-backup is now `/import#cloud-backup`
|
||||
@@ -62,17 +189,18 @@ export const cloudBackupExtension: Extension = {
|
||||
},
|
||||
|
||||
apiRoutes: [
|
||||
// Kick off OAuth: return the Google consent URL.
|
||||
// Kick off OAuth: return the provider's consent URL.
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/connect',
|
||||
handler: async (request, ctx) => {
|
||||
if (!ctx) return jsonError('Missing context', 500)
|
||||
const resolved = resolveProvider(request, { requireConfigured: true })
|
||||
if ('error' in resolved) return resolved.error
|
||||
try {
|
||||
const origin = new URL(request.url).origin
|
||||
const env = getOAuthEnv(origin)
|
||||
const state = createOAuthState(ctx.userId, ctx.companyId)
|
||||
const url = buildAuthorizationUrl(env, state)
|
||||
const url = resolved.provider.buildAuthorizationUrl(origin, state)
|
||||
return NextResponse.json({ url })
|
||||
} catch (err) {
|
||||
ctx.log.error('connect failed', err)
|
||||
@@ -84,115 +212,46 @@ export const cloudBackupExtension: Extension = {
|
||||
},
|
||||
},
|
||||
|
||||
// Google redirects here after the user consents.
|
||||
// Google redirects here after the user consents. The path is registered
|
||||
// with Google as an authorised redirect URI: never rename it.
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/oauth/callback',
|
||||
handler: async (request, ctx) => {
|
||||
if (!ctx) return jsonError('Missing context', 500)
|
||||
const url = new URL(request.url)
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const errorParam = url.searchParams.get('error')
|
||||
const origin = url.origin
|
||||
const redirect = (status: string, reason?: string) => {
|
||||
const target = new URL('/settings/backup', origin)
|
||||
target.searchParams.set('cloud_backup', status)
|
||||
if (reason) target.searchParams.set('reason', reason)
|
||||
return NextResponse.redirect(target)
|
||||
}
|
||||
|
||||
if (errorParam) {
|
||||
return redirect('error', errorParam)
|
||||
}
|
||||
if (!code || !state) {
|
||||
return redirect('error', 'missing_params')
|
||||
}
|
||||
|
||||
const verified = verifyOAuthState(state)
|
||||
if (!verified) {
|
||||
return redirect('error', 'invalid_state')
|
||||
}
|
||||
if (verified.userId !== ctx.userId || verified.companyId !== ctx.companyId) {
|
||||
return redirect('error', 'state_mismatch')
|
||||
}
|
||||
|
||||
try {
|
||||
const env = getOAuthEnv(origin)
|
||||
const tokens = await exchangeCodeForTokens(env, code)
|
||||
const email = await fetchUserEmail(tokens.access_token)
|
||||
|
||||
const connection: GoogleDriveConnection = {
|
||||
refresh_token_encrypted: encryptToken(tokens.refresh_token),
|
||||
account_email: email,
|
||||
connected_at: new Date().toISOString(),
|
||||
root_folder_id: null,
|
||||
company_folder_id: null,
|
||||
}
|
||||
await ctx.settings.set(CONNECTION_KEY, connection)
|
||||
|
||||
// First-time connections get daily auto-sync on by default: a
|
||||
// backup that defaults to off protects nobody. Reconnects keep
|
||||
// whatever schedule the user had.
|
||||
const existingSchedule =
|
||||
await ctx.settings.get<GoogleDriveSchedule>(SCHEDULE_KEY)
|
||||
const firstConnect = !existingSchedule
|
||||
if (firstConnect) {
|
||||
await ctx.settings.set(SCHEDULE_KEY, {
|
||||
...DEFAULT_SCHEDULE,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Kick off the first backup after the redirect response is sent, so
|
||||
// the user lands back on the card immediately while the archive
|
||||
// builds in the background.
|
||||
const syncOrigin = process.env.NEXT_PUBLIC_APP_URL || origin
|
||||
after(async () => {
|
||||
try {
|
||||
await performSync({
|
||||
supabase: ctx.supabase,
|
||||
companyId: ctx.companyId,
|
||||
userId: ctx.userId,
|
||||
origin: syncOrigin,
|
||||
includeDocuments: true,
|
||||
allowDocumentFallback: true,
|
||||
})
|
||||
} catch (err) {
|
||||
ctx.log.error('initial sync after connect failed', err)
|
||||
}
|
||||
})
|
||||
|
||||
return redirect(firstConnect ? 'connected_first' : 'connected')
|
||||
} catch (err) {
|
||||
ctx.log.error('oauth callback failed', err)
|
||||
return redirect(
|
||||
'error',
|
||||
err instanceof Error ? err.message.slice(0, 80) : 'exchange_failed'
|
||||
)
|
||||
}
|
||||
},
|
||||
path: googleDriveProvider.callbackPath,
|
||||
handler: (request, ctx) => handleOAuthCallback(request, ctx, googleDriveProvider),
|
||||
},
|
||||
|
||||
// Revoke the refresh token and clear the stored connection + schedule.
|
||||
// Dropbox equivalent, on its own registered redirect URI.
|
||||
{
|
||||
method: 'GET',
|
||||
path: dropboxProvider.callbackPath,
|
||||
handler: (request, ctx) => handleOAuthCallback(request, ctx, dropboxProvider),
|
||||
},
|
||||
|
||||
// Revoke the refresh token and clear the stored connection + schedule for
|
||||
// one provider. The other provider's records are untouched.
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/disconnect',
|
||||
handler: async (_request, ctx) => {
|
||||
handler: async (request, ctx) => {
|
||||
if (!ctx) return jsonError('Missing context', 500)
|
||||
const resolved = resolveProvider(request)
|
||||
if ('error' in resolved) return resolved.error
|
||||
const { provider } = resolved
|
||||
try {
|
||||
const connection = await loadConnection(ctx)
|
||||
const connection = await ctx.settings.get<CloudConnection>(
|
||||
provider.keys.connection
|
||||
)
|
||||
if (connection) {
|
||||
try {
|
||||
const refreshToken = decryptToken(connection.refresh_token_encrypted)
|
||||
await revokeToken(refreshToken)
|
||||
await provider.revoke(refreshToken, new URL(request.url).origin)
|
||||
} catch (err) {
|
||||
ctx.log.warn('token revoke failed (continuing)', err)
|
||||
}
|
||||
}
|
||||
await ctx.settings.clear(CONNECTION_KEY)
|
||||
await ctx.settings.clear(LAST_SYNC_KEY)
|
||||
await ctx.settings.clear(SCHEDULE_KEY)
|
||||
await ctx.settings.clear(provider.keys.connection)
|
||||
await ctx.settings.clear(provider.keys.lastSync)
|
||||
await ctx.settings.clear(provider.keys.schedule)
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (err) {
|
||||
ctx.log.error('disconnect failed', err)
|
||||
@@ -204,47 +263,59 @@ export const cloudBackupExtension: Extension = {
|
||||
},
|
||||
},
|
||||
|
||||
// Read-only status used by the UI to show connected/last-sync info.
|
||||
// Read-only status used by the UI to show connected/last-sync info for
|
||||
// every provider. The top-level fields mirror Google Drive so clients
|
||||
// written before Dropbox existed keep working.
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/status',
|
||||
handler: async (_request, ctx) => {
|
||||
if (!ctx) return jsonError('Missing context', 500)
|
||||
const connection = await loadConnection(ctx)
|
||||
const lastSync = await ctx.settings.get<GoogleDriveLastSync>(LAST_SYNC_KEY)
|
||||
const schedule = await ctx.settings.get<GoogleDriveSchedule>(SCHEDULE_KEY)
|
||||
const providers: CloudProviderStatus[] = []
|
||||
for (const provider of CLOUD_PROVIDERS) {
|
||||
providers.push(await readProviderStatus(ctx, provider))
|
||||
}
|
||||
const google =
|
||||
providers.find((p) => p.provider === googleDriveProvider.id) ?? null
|
||||
const status: CloudBackupStatus = {
|
||||
connected: !!connection,
|
||||
needs_reauth: connection?.status === 'needs_reauth',
|
||||
account_email: connection?.account_email ?? null,
|
||||
connected_at: connection?.connected_at ?? null,
|
||||
last_sync: lastSync ?? null,
|
||||
schedule: schedule ?? null,
|
||||
providers,
|
||||
connected: google?.connected ?? false,
|
||||
needs_reauth: google?.needs_reauth ?? false,
|
||||
account_email: google?.account_email ?? null,
|
||||
connected_at: google?.connected_at ?? null,
|
||||
last_sync: google?.last_sync ?? null,
|
||||
schedule: google?.schedule ?? null,
|
||||
}
|
||||
return NextResponse.json({ data: status })
|
||||
},
|
||||
},
|
||||
|
||||
// Read the auto-sync schedule. Returns the default (disabled) shape if
|
||||
// the user has never configured one.
|
||||
// Read one provider's auto-sync schedule. Returns the default (disabled)
|
||||
// shape if the user has never configured one.
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/schedule',
|
||||
handler: async (_request, ctx) => {
|
||||
handler: async (request, ctx) => {
|
||||
if (!ctx) return jsonError('Missing context', 500)
|
||||
const schedule = await ctx.settings.get<GoogleDriveSchedule>(SCHEDULE_KEY)
|
||||
const resolved = resolveProvider(request)
|
||||
if ('error' in resolved) return resolved.error
|
||||
const schedule = await ctx.settings.get<CloudSchedule>(
|
||||
resolved.provider.keys.schedule
|
||||
)
|
||||
return NextResponse.json({ data: schedule ?? DEFAULT_SCHEDULE })
|
||||
},
|
||||
},
|
||||
|
||||
// Update the auto-sync schedule. Preserves the fields the cron writes
|
||||
// (`last_auto_sync_*`, failure counter, alert throttle): those are not
|
||||
// user-editable.
|
||||
// Update one provider's auto-sync schedule. Preserves the fields the cron
|
||||
// writes (`last_auto_sync_*`, failure counter, alert throttle): those are
|
||||
// not user-editable.
|
||||
{
|
||||
method: 'PUT',
|
||||
path: '/schedule',
|
||||
handler: async (request, ctx) => {
|
||||
if (!ctx) return jsonError('Missing context', 500)
|
||||
const resolved = resolveProvider(request)
|
||||
if ('error' in resolved) return resolved.error
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
enabled?: boolean
|
||||
@@ -274,8 +345,9 @@ export const cloudBackupExtension: Extension = {
|
||||
return jsonError('hour_local must be an integer between 0 and 23', 400)
|
||||
}
|
||||
|
||||
const existing = await ctx.settings.get<GoogleDriveSchedule>(SCHEDULE_KEY)
|
||||
const updated: GoogleDriveSchedule = {
|
||||
const key = resolved.provider.keys.schedule
|
||||
const existing = await ctx.settings.get<CloudSchedule>(key)
|
||||
const updated: CloudSchedule = {
|
||||
...existing,
|
||||
enabled: body.enabled,
|
||||
hour_utc: hourUtc,
|
||||
@@ -284,7 +356,7 @@ export const cloudBackupExtension: Extension = {
|
||||
last_auto_sync_status: existing?.last_auto_sync_status ?? null,
|
||||
last_auto_sync_error: existing?.last_auto_sync_error ?? null,
|
||||
}
|
||||
await ctx.settings.set(SCHEDULE_KEY, updated)
|
||||
await ctx.settings.set(key, updated)
|
||||
return NextResponse.json({ data: updated })
|
||||
} catch (err) {
|
||||
ctx.log.error('update schedule failed', err)
|
||||
@@ -296,12 +368,15 @@ export const cloudBackupExtension: Extension = {
|
||||
},
|
||||
},
|
||||
|
||||
// Generate the archive set and sync it to Drive. Returns the sync summary.
|
||||
// Generate the archive set and sync it to one provider. Returns the sync
|
||||
// summary.
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/sync',
|
||||
handler: async (request, ctx) => {
|
||||
if (!ctx) return jsonError('Missing context', 500)
|
||||
const resolved = resolveProvider(request)
|
||||
if ('error' in resolved) return resolved.error
|
||||
try {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
include_documents?: boolean
|
||||
@@ -316,6 +391,7 @@ export const cloudBackupExtension: Extension = {
|
||||
origin,
|
||||
includeDocuments: body.include_documents !== false,
|
||||
allowDocumentFallback: body.allow_document_fallback === true,
|
||||
provider: resolved.provider,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
asciiJson,
|
||||
dropboxContentHash,
|
||||
sanitizeDropboxName,
|
||||
uploadDropboxFile,
|
||||
} from '../dropbox-client'
|
||||
|
||||
const CHUNK = 8 * 1024 * 1024
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
/** Dropbox's content_hash, computed independently of the implementation. */
|
||||
function expectedHash(bytes: Uint8Array): string {
|
||||
const blocks: Buffer[] = []
|
||||
for (let i = 0; i < bytes.length; i += 4 * 1024 * 1024) {
|
||||
blocks.push(
|
||||
createHash('sha256')
|
||||
.update(Buffer.from(bytes.subarray(i, Math.min(i + 4 * 1024 * 1024, bytes.length))))
|
||||
.digest()
|
||||
)
|
||||
}
|
||||
return createHash('sha256').update(Buffer.concat(blocks)).digest('hex')
|
||||
}
|
||||
|
||||
function makeBuffer(size: number, fill = 7): ArrayBuffer {
|
||||
return new Uint8Array(size).fill(fill).buffer
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('dropboxContentHash', () => {
|
||||
it('matches the block-wise definition for a multi-block payload', () => {
|
||||
const bytes = new Uint8Array(9 * 1024 * 1024).fill(3)
|
||||
expect(dropboxContentHash(bytes.buffer)).toBe(expectedHash(bytes))
|
||||
})
|
||||
|
||||
it('hashes an empty file as the hash of an empty concatenation', () => {
|
||||
expect(dropboxContentHash(new ArrayBuffer(0))).toBe(
|
||||
createHash('sha256').update(Buffer.alloc(0)).digest('hex')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('asciiJson', () => {
|
||||
it('escapes Swedish characters so the header stays ASCII', () => {
|
||||
const encoded = asciiJson({ path: '/Bolag/LÄSMIG.txt' })
|
||||
expect([...encoded].every((c) => c.codePointAt(0)! < 128)).toBe(true)
|
||||
expect(JSON.parse(encoded)).toEqual({ path: '/Bolag/LÄSMIG.txt' })
|
||||
})
|
||||
|
||||
it('escapes astral characters as surrogate pairs', () => {
|
||||
const encoded = asciiJson({ n: '🚀' })
|
||||
expect(encoded).toContain('\\ud83d')
|
||||
expect(encoded).toContain('\\ude80')
|
||||
expect(JSON.parse(encoded)).toEqual({ n: '🚀' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeDropboxName', () => {
|
||||
it('replaces characters Dropbox rejects in a path component', () => {
|
||||
expect(sanitizeDropboxName('Bolaget AB / Filial: X?')).toBe('Bolaget AB - Filial- X-')
|
||||
})
|
||||
|
||||
it('neutralises a backslash so a name cannot escape its folder', () => {
|
||||
expect(sanitizeDropboxName(`a${String.fromCharCode(92)}b`)).toBe('a-b')
|
||||
})
|
||||
|
||||
it('strips trailing dots and spaces', () => {
|
||||
expect(sanitizeDropboxName('Firma AB. ')).toBe('Firma AB')
|
||||
})
|
||||
|
||||
it('never returns an empty component', () => {
|
||||
expect(sanitizeDropboxName('///')).toBe('---')
|
||||
expect(sanitizeDropboxName('')).toBe('foretag')
|
||||
})
|
||||
|
||||
it('keeps Swedish characters: Dropbox accepts them, the header escapes them', () => {
|
||||
expect(sanitizeDropboxName('Åkeri Ödmjuk AB')).toBe('Åkeri Ödmjuk AB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadDropboxFile', () => {
|
||||
it('uses a single request for a payload that fits one chunk', async () => {
|
||||
const data = makeBuffer(1024)
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
name: 'Grunddata.zip',
|
||||
path_display: '/Bolag/Grunddata.zip',
|
||||
size: 1024,
|
||||
content_hash: dropboxContentHash(data),
|
||||
})
|
||||
)
|
||||
|
||||
const result = await uploadDropboxFile('token', '/Bolag/Grunddata.zip', data)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('https://content.dropboxapi.com/2/files/upload')
|
||||
expect(JSON.parse((init.headers as Record<string, string>)['Dropbox-API-Arg'])).toEqual({
|
||||
path: '/Bolag/Grunddata.zip',
|
||||
mode: 'overwrite',
|
||||
mute: true,
|
||||
autorename: false,
|
||||
})
|
||||
expect(result).toMatchObject({ path: '/Bolag/Grunddata.zip', size_bytes: 1024 })
|
||||
})
|
||||
|
||||
it('splits a large payload into a start/append/finish session', async () => {
|
||||
const data = makeBuffer(CHUNK * 2 + 10)
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ session_id: 'sess-1' }))
|
||||
.mockResolvedValueOnce(jsonResponse({}))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
name: 'Arkiv 2024.zip',
|
||||
path_display: '/Bolag/Arkiv 2024.zip',
|
||||
size: data.byteLength,
|
||||
content_hash: dropboxContentHash(data),
|
||||
})
|
||||
)
|
||||
|
||||
const result = await uploadDropboxFile('token', '/Bolag/Arkiv 2024.zip', data)
|
||||
|
||||
const endpoints = fetchMock.mock.calls.map(([url]) => String(url).split('/2')[1])
|
||||
expect(endpoints).toEqual([
|
||||
'/files/upload_session/start',
|
||||
'/files/upload_session/append_v2',
|
||||
'/files/upload_session/finish',
|
||||
])
|
||||
// The append cursor must sit exactly one chunk in, and finish two chunks in:
|
||||
// a wrong offset silently truncates or duplicates archive bytes.
|
||||
const appendArg = JSON.parse(
|
||||
(fetchMock.mock.calls[1][1].headers as Record<string, string>)['Dropbox-API-Arg']
|
||||
)
|
||||
expect(appendArg.cursor).toEqual({ session_id: 'sess-1', offset: CHUNK })
|
||||
const finishArg = JSON.parse(
|
||||
(fetchMock.mock.calls[2][1].headers as Record<string, string>)['Dropbox-API-Arg']
|
||||
)
|
||||
expect(finishArg.cursor).toEqual({ session_id: 'sess-1', offset: CHUNK * 2 })
|
||||
expect(finishArg.commit.mode).toBe('overwrite')
|
||||
expect(result.size_bytes).toBe(data.byteLength)
|
||||
})
|
||||
|
||||
it('closes a payload that is an exact multiple of the chunk size', async () => {
|
||||
const data = makeBuffer(CHUNK * 2)
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ session_id: 'sess-2' }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
name: 'Arkiv 2024.zip',
|
||||
path_display: '/Bolag/Arkiv 2024.zip',
|
||||
size: data.byteLength,
|
||||
content_hash: dropboxContentHash(data),
|
||||
})
|
||||
)
|
||||
|
||||
const result = await uploadDropboxFile('token', '/Bolag/Arkiv 2024.zip', data)
|
||||
|
||||
const endpoints = fetchMock.mock.calls.map(([url]) => String(url).split('/2')[1])
|
||||
expect(endpoints).toEqual([
|
||||
'/files/upload_session/start',
|
||||
'/files/upload_session/finish',
|
||||
])
|
||||
expect(result.size_bytes).toBe(data.byteLength)
|
||||
})
|
||||
|
||||
it('retries the whole upload once when the stored content hash disagrees', async () => {
|
||||
const data = makeBuffer(512)
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ name: 'x.zip', path_display: '/x.zip', size: 512, content_hash: 'wrong' })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
name: 'x.zip',
|
||||
path_display: '/x.zip',
|
||||
size: 512,
|
||||
content_hash: dropboxContentHash(data),
|
||||
})
|
||||
)
|
||||
|
||||
const result = await uploadDropboxFile('token', '/x.zip', data)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(result.content_hash).toBe(dropboxContentHash(data))
|
||||
})
|
||||
|
||||
it('throws rather than record a corrupted backup when the hash never matches', async () => {
|
||||
const data = makeBuffer(512)
|
||||
// A fresh Response per call: a body can only be read once, and the retry
|
||||
// issues a genuinely new request.
|
||||
fetchMock.mockImplementation(async () =>
|
||||
jsonResponse({ name: 'x.zip', path_display: '/x.zip', size: 512, content_hash: 'wrong' })
|
||||
)
|
||||
|
||||
await expect(uploadDropboxFile('token', '/x.zip', data)).rejects.toThrow(
|
||||
/checksum mismatch/
|
||||
)
|
||||
})
|
||||
|
||||
it('retries a 5xx and succeeds on a later attempt', async () => {
|
||||
const data = makeBuffer(256)
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response('boom', { status: 503 }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
name: 'x.zip',
|
||||
path_display: '/x.zip',
|
||||
size: 256,
|
||||
content_hash: dropboxContentHash(data),
|
||||
})
|
||||
)
|
||||
|
||||
const result = await uploadDropboxFile('token', '/x.zip', data)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(result.size_bytes).toBe(256)
|
||||
})
|
||||
|
||||
it('does not retry a 4xx: it can only fail the same way again', async () => {
|
||||
fetchMock.mockImplementation(
|
||||
async () => new Response('{"error_summary":"path/malformed_path/"}', { status: 400 })
|
||||
)
|
||||
|
||||
await expect(uploadDropboxFile('token', '/x.zip', makeBuffer(256))).rejects.toThrow(
|
||||
/malformed_path/
|
||||
)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
vi.mock('../dropbox-client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../dropbox-client')>()
|
||||
return { ...actual, uploadDropboxFile: vi.fn() }
|
||||
})
|
||||
|
||||
import { dropboxProvider } from '../dropbox-provider'
|
||||
import { uploadDropboxFile } from '../dropbox-client'
|
||||
import type { CloudConnection } from '../../types'
|
||||
|
||||
const mockUpload = vi.mocked(uploadDropboxFile)
|
||||
|
||||
function makeConnection(overrides: Partial<CloudConnection> = {}): CloudConnection {
|
||||
return {
|
||||
refresh_token_encrypted: 'enc',
|
||||
account_email: 'user@example.com',
|
||||
connected_at: '2026-01-01T00:00:00.000Z',
|
||||
root_folder_id: null,
|
||||
company_folder_id: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const ORIGINAL_APP_FOLDER = process.env.DROPBOX_APP_FOLDER_NAME
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
delete process.env.DROPBOX_APP_FOLDER_NAME
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_APP_FOLDER === undefined) delete process.env.DROPBOX_APP_FOLDER_NAME
|
||||
else process.env.DROPBOX_APP_FOLDER_NAME = ORIGINAL_APP_FOLDER
|
||||
})
|
||||
|
||||
describe('dropboxProvider storage keys', () => {
|
||||
it('owns keys distinct from Google Drive so the two never collide', () => {
|
||||
expect(dropboxProvider.keys).toEqual({
|
||||
connection: 'dropbox_connection',
|
||||
lastSync: 'dropbox_last_sync',
|
||||
schedule: 'dropbox_schedule',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses its own OAuth callback path', () => {
|
||||
expect(dropboxProvider.callbackPath).toBe('/oauth/dropbox/callback')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropboxProvider.prepareTarget', () => {
|
||||
it('derives the company folder path without calling Dropbox', async () => {
|
||||
const { target, connectionPatch } = await dropboxProvider.prepareTarget({
|
||||
accessToken: 'token',
|
||||
connection: makeConnection(),
|
||||
companyLabel: 'Testbolag AB (556000-0000)',
|
||||
})
|
||||
|
||||
expect(target.folderId).toBe('/Testbolag AB (556000-0000)')
|
||||
expect(connectionPatch).toEqual({
|
||||
company_folder_path: '/Testbolag AB (556000-0000)',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports no change when the stored path already matches', async () => {
|
||||
const { connectionPatch } = await dropboxProvider.prepareTarget({
|
||||
accessToken: 'token',
|
||||
connection: makeConnection({ company_folder_path: '/Testbolag AB (556000-0000)' }),
|
||||
companyLabel: 'Testbolag AB (556000-0000)',
|
||||
})
|
||||
|
||||
expect(connectionPatch).toBeNull()
|
||||
})
|
||||
|
||||
it('sanitises a company name that would produce an invalid path', async () => {
|
||||
const { target } = await dropboxProvider.prepareTarget({
|
||||
accessToken: 'token',
|
||||
connection: makeConnection(),
|
||||
companyLabel: 'Bolaget AB / Filial (556000-0000)',
|
||||
})
|
||||
|
||||
// A single leading slash: the label must not be able to add path segments.
|
||||
expect(target.folderId).toBe('/Bolaget AB - Filial (556000-0000)')
|
||||
expect(target.folderId.slice(1)).not.toContain('/')
|
||||
})
|
||||
|
||||
it('links to the Apps root when the app folder name is unknown', async () => {
|
||||
const { target } = await dropboxProvider.prepareTarget({
|
||||
accessToken: 'token',
|
||||
connection: makeConnection(),
|
||||
companyLabel: 'Testbolag AB',
|
||||
})
|
||||
|
||||
expect(target.webViewLink).toBe('https://www.dropbox.com/home/Apps')
|
||||
})
|
||||
|
||||
it('deep-links into the company folder when the app folder name is configured', async () => {
|
||||
process.env.DROPBOX_APP_FOLDER_NAME = 'Accounted'
|
||||
|
||||
const { target } = await dropboxProvider.prepareTarget({
|
||||
accessToken: 'token',
|
||||
connection: makeConnection(),
|
||||
companyLabel: 'Testbolag AB',
|
||||
})
|
||||
|
||||
expect(target.webViewLink).toBe(
|
||||
'https://www.dropbox.com/home/Apps/Accounted/Testbolag%20AB'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropboxProvider.putFile', () => {
|
||||
it('writes under the company folder and returns the path as the handle', async () => {
|
||||
mockUpload.mockResolvedValue({
|
||||
path: '/Testbolag AB/Arkiv 2024.zip',
|
||||
name: 'Arkiv 2024.zip',
|
||||
size_bytes: 42,
|
||||
})
|
||||
|
||||
const result = await dropboxProvider.putFile({
|
||||
accessToken: 'token',
|
||||
target: {
|
||||
folderId: '/Testbolag AB',
|
||||
webViewLink: 'https://www.dropbox.com/home/Apps',
|
||||
},
|
||||
name: 'Arkiv 2024.zip',
|
||||
data: new ArrayBuffer(42),
|
||||
contentType: 'application/zip',
|
||||
})
|
||||
|
||||
expect(mockUpload).toHaveBeenCalledWith(
|
||||
'token',
|
||||
'/Testbolag AB/Arkiv 2024.zip',
|
||||
expect.any(ArrayBuffer)
|
||||
)
|
||||
expect(result).toEqual({
|
||||
id: '/Testbolag AB/Arkiv 2024.zip',
|
||||
name: 'Arkiv 2024.zip',
|
||||
size_bytes: 42,
|
||||
})
|
||||
})
|
||||
|
||||
it('overwrites by path and ignores the previous handle', async () => {
|
||||
mockUpload.mockResolvedValue({
|
||||
path: '/Bolag/Grunddata.zip',
|
||||
name: 'Grunddata.zip',
|
||||
size_bytes: 8,
|
||||
})
|
||||
|
||||
await dropboxProvider.putFile({
|
||||
accessToken: 'token',
|
||||
target: { folderId: '/Bolag', webViewLink: 'https://www.dropbox.com/home/Apps' },
|
||||
name: 'Grunddata.zip',
|
||||
previousId: '/somewhere/else/Grunddata.zip',
|
||||
data: new ArrayBuffer(8),
|
||||
contentType: 'application/zip',
|
||||
})
|
||||
|
||||
expect(mockUpload.mock.calls[0][1]).toBe('/Bolag/Grunddata.zip')
|
||||
})
|
||||
|
||||
it('keeps Swedish file names intact: the header encoder handles them', async () => {
|
||||
mockUpload.mockResolvedValue({
|
||||
path: '/Bolag/LÄSMIG.txt',
|
||||
name: 'LÄSMIG.txt',
|
||||
size_bytes: 11,
|
||||
})
|
||||
|
||||
await dropboxProvider.putFile({
|
||||
accessToken: 'token',
|
||||
target: { folderId: '/Bolag', webViewLink: 'https://www.dropbox.com/home/Apps' },
|
||||
name: 'LÄSMIG.txt',
|
||||
data: new ArrayBuffer(11),
|
||||
contentType: 'text/plain',
|
||||
})
|
||||
|
||||
expect(mockUpload.mock.calls[0][1]).toBe('/Bolag/LÄSMIG.txt')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,284 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/reports/full-archive-export', () => ({
|
||||
generateFullArchive: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
|
||||
generateBaseDataArchive: vi.fn().mockResolvedValue(new ArrayBuffer(16)),
|
||||
ARCHIVE_OVERHEAD_BYTES: 8 * 1024 * 1024,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/reports/archive-readme', () => ({
|
||||
buildDriveFolderReadme: vi.fn().mockReturnValue('README TEXT'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: vi.fn(() => ({ storage: { from: vi.fn() } })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/branding/service', () => ({
|
||||
getBranding: () => ({ appName: 'Accounted', appUrl: 'https://app.test' }),
|
||||
}))
|
||||
|
||||
vi.mock('../crypto', () => ({
|
||||
decryptToken: vi.fn().mockReturnValue('plain-refresh-token'),
|
||||
}))
|
||||
|
||||
import { performSync } from '../sync'
|
||||
import { CloudTokenRefreshError, type CloudStorageProvider } from '../cloud-provider'
|
||||
import type { CloudConnection, CloudLastSync } from '../../types'
|
||||
|
||||
const PERIOD = { id: 'p-2024', period_start: '2024-01-01', period_end: '2024-12-31' }
|
||||
const ENTRY = { id: 'e-1', fiscal_period_id: 'p-2024', updated_at: '2024-06-01T00:00:00Z' }
|
||||
|
||||
function makeConnection(): CloudConnection {
|
||||
return {
|
||||
refresh_token_encrypted: 'encrypted-token',
|
||||
account_email: 'user@example.com',
|
||||
connected_at: '2026-01-01T00:00:00.000Z',
|
||||
root_folder_id: null,
|
||||
company_folder_id: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supabase stub that stores extension_data per key, so a test can assert which
|
||||
* provider's records a sync read and wrote.
|
||||
*/
|
||||
function makeSupabase(seed: Record<string, unknown> = {}) {
|
||||
const store = new Map<string, unknown>(Object.entries(seed))
|
||||
const writes: { key: string; value: unknown }[] = []
|
||||
|
||||
const upsert = vi.fn().mockImplementation((payload: any) => {
|
||||
store.set(payload.key, payload.value)
|
||||
writes.push({ key: payload.key, value: payload.value })
|
||||
return Promise.resolve({ error: null })
|
||||
})
|
||||
|
||||
const from = vi.fn().mockImplementation((table: string) => {
|
||||
let key: string | null = null
|
||||
const chain: any = {
|
||||
upsert,
|
||||
maybeSingle: vi.fn().mockImplementation(() => {
|
||||
if (table === 'extension_data') {
|
||||
const value = key ? store.get(key) : undefined
|
||||
return Promise.resolve({ data: value ? { value } : null })
|
||||
}
|
||||
if (table === 'company_settings') {
|
||||
return Promise.resolve({
|
||||
data: { company_name: 'Testbolag AB', org_number: '556000-0000' },
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ data: null })
|
||||
}),
|
||||
then: (resolve: (v: unknown) => void) => {
|
||||
if (table === 'fiscal_periods') return resolve({ data: [PERIOD], error: null })
|
||||
if (table === 'journal_entries') return resolve({ data: [ENTRY], error: null })
|
||||
if (table === 'document_attachments') return resolve({ data: [], error: null })
|
||||
if (table === 'audit_log') {
|
||||
return resolve({ data: [{ created_at: '2026-07-01T00:00:00Z' }], error: null })
|
||||
}
|
||||
return resolve({ data: [], error: null })
|
||||
},
|
||||
}
|
||||
for (const method of ['select', 'eq', 'neq', 'in', 'order', 'range', 'limit']) {
|
||||
chain[method] = vi.fn().mockImplementation((col?: string, val?: string) => {
|
||||
if (method === 'eq' && col === 'key') key = val ?? null
|
||||
return chain
|
||||
})
|
||||
}
|
||||
return chain
|
||||
})
|
||||
|
||||
return { supabase: { from } as any, store, writes, upsert }
|
||||
}
|
||||
|
||||
/** A provider that records what the sync engine asked it to do. */
|
||||
function makeFakeProvider(
|
||||
id: string,
|
||||
overrides: Partial<CloudStorageProvider> = {}
|
||||
): CloudStorageProvider {
|
||||
return {
|
||||
id: id as CloudStorageProvider['id'],
|
||||
label: id,
|
||||
keys: {
|
||||
connection: `${id}_connection`,
|
||||
lastSync: `${id}_last_sync`,
|
||||
schedule: `${id}_schedule`,
|
||||
},
|
||||
callbackPath: `/oauth/${id}/callback`,
|
||||
isConfigured: () => true,
|
||||
buildAuthorizationUrl: () => 'https://auth.test',
|
||||
exchangeCode: vi.fn(),
|
||||
revoke: vi.fn(),
|
||||
refreshAccessToken: vi.fn().mockResolvedValue('fresh-token'),
|
||||
prepareTarget: vi.fn().mockResolvedValue({
|
||||
target: { folderId: `folder-${id}`, webViewLink: `https://${id}.test/folder` },
|
||||
connectionPatch: null,
|
||||
}),
|
||||
putFile: vi.fn().mockImplementation(async ({ name, data }: any) => ({
|
||||
id: `${id}:${name}`,
|
||||
name,
|
||||
size_bytes: (data as ArrayBuffer).byteLength,
|
||||
})),
|
||||
...overrides,
|
||||
} as CloudStorageProvider
|
||||
}
|
||||
|
||||
function syncParams(supabase: any, provider: CloudStorageProvider) {
|
||||
return {
|
||||
supabase,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
origin: 'https://app.test',
|
||||
includeDocuments: true,
|
||||
provider,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('performSync provider routing', () => {
|
||||
it('reads and writes only the requested provider records', async () => {
|
||||
const provider = makeFakeProvider('dropbox')
|
||||
const { supabase, writes } = makeSupabase({
|
||||
dropbox_connection: makeConnection(),
|
||||
})
|
||||
|
||||
const result = await performSync(syncParams(supabase, provider))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
const touchedKeys = new Set(writes.map((w) => w.key))
|
||||
expect([...touchedKeys]).toEqual(['dropbox_last_sync'])
|
||||
// Nothing under the Google keys was written.
|
||||
expect([...touchedKeys].some((k) => k.startsWith('google_drive'))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns not_connected when only the OTHER provider is connected', async () => {
|
||||
const provider = makeFakeProvider('dropbox')
|
||||
const { supabase } = makeSupabase({
|
||||
google_drive_connection: makeConnection(),
|
||||
})
|
||||
|
||||
const result = await performSync(syncParams(supabase, provider))
|
||||
|
||||
expect(result).toMatchObject({ ok: false, reason: 'not_connected' })
|
||||
expect(provider.refreshAccessToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uploads the full file set through the provider and reports its folder link', async () => {
|
||||
const provider = makeFakeProvider('dropbox')
|
||||
const { supabase } = makeSupabase({ dropbox_connection: makeConnection() })
|
||||
|
||||
const result = await performSync(syncParams(supabase, provider))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.uploadedCount).toBe(3)
|
||||
expect(result.webViewLink).toBe('https://dropbox.test/folder')
|
||||
const names = vi.mocked(provider.putFile).mock.calls.map((c) => c[0].name)
|
||||
expect(names).toEqual(['Arkiv 2024.zip', 'Grunddata.zip', 'LÄSMIG.txt'])
|
||||
// Every upload targets the folder prepareTarget resolved.
|
||||
for (const call of vi.mocked(provider.putFile).mock.calls) {
|
||||
expect(call[0].target.folderId).toBe('folder-dropbox')
|
||||
}
|
||||
})
|
||||
|
||||
it('records the folder link on the snapshot so the UI can link without guessing', async () => {
|
||||
const provider = makeFakeProvider('dropbox')
|
||||
const { supabase, store } = makeSupabase({ dropbox_connection: makeConnection() })
|
||||
|
||||
await performSync(syncParams(supabase, provider))
|
||||
|
||||
const snapshot = store.get('dropbox_last_sync') as CloudLastSync
|
||||
expect(snapshot.web_view_link).toBe('https://dropbox.test/folder')
|
||||
expect(snapshot.folder_id).toBe('folder-dropbox')
|
||||
})
|
||||
|
||||
it('flags only the failing provider needs_reauth on a dead refresh token', async () => {
|
||||
const provider = makeFakeProvider('dropbox', {
|
||||
refreshAccessToken: vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new CloudTokenRefreshError('Dropbox', 400, '{"error":"invalid_grant"}')
|
||||
),
|
||||
})
|
||||
const { supabase, store, writes } = makeSupabase({
|
||||
dropbox_connection: makeConnection(),
|
||||
google_drive_connection: makeConnection(),
|
||||
})
|
||||
|
||||
const result = await performSync(syncParams(supabase, provider))
|
||||
|
||||
expect(result).toMatchObject({ ok: false, reason: 'needs_reauth' })
|
||||
expect((store.get('dropbox_connection') as CloudConnection).status).toBe('needs_reauth')
|
||||
// The healthy Google connection is left exactly as it was.
|
||||
expect((store.get('google_drive_connection') as CloudConnection).status).toBeUndefined()
|
||||
expect(writes.every((w) => w.key === 'dropbox_connection')).toBe(true)
|
||||
})
|
||||
|
||||
it('rethrows a transient refresh failure without flagging the connection', async () => {
|
||||
const provider = makeFakeProvider('dropbox', {
|
||||
refreshAccessToken: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new CloudTokenRefreshError('Dropbox', 500, 'Server Error')),
|
||||
})
|
||||
const { supabase, writes } = makeSupabase({ dropbox_connection: makeConnection() })
|
||||
|
||||
await expect(performSync(syncParams(supabase, provider))).rejects.toThrow(/500/)
|
||||
expect(writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('persists a connection patch the provider asked for', async () => {
|
||||
const provider = makeFakeProvider('dropbox', {
|
||||
prepareTarget: vi.fn().mockResolvedValue({
|
||||
target: { folderId: '/Bolag', webViewLink: 'https://dropbox.test/folder' },
|
||||
connectionPatch: { company_folder_path: '/Bolag' },
|
||||
}),
|
||||
})
|
||||
const { supabase, store } = makeSupabase({ dropbox_connection: makeConnection() })
|
||||
|
||||
await performSync(syncParams(supabase, provider))
|
||||
|
||||
expect((store.get('dropbox_connection') as CloudConnection).company_folder_path).toBe(
|
||||
'/Bolag'
|
||||
)
|
||||
})
|
||||
|
||||
it('skips unchanged files using that provider own last-sync record', async () => {
|
||||
const provider = makeFakeProvider('dropbox')
|
||||
const { supabase } = makeSupabase({ dropbox_connection: makeConnection() })
|
||||
|
||||
await performSync(syncParams(supabase, provider))
|
||||
vi.mocked(provider.putFile).mockClear()
|
||||
|
||||
// Second run against the snapshot the first run persisted.
|
||||
const second = await performSync(syncParams(supabase, provider))
|
||||
|
||||
expect(second.ok).toBe(true)
|
||||
if (!second.ok) return
|
||||
expect(second.uploadedCount).toBe(0)
|
||||
expect(second.skippedCount).toBe(3)
|
||||
expect(provider.putFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes the previous handle only while the file name is unchanged', async () => {
|
||||
const provider = makeFakeProvider('dropbox')
|
||||
const { supabase, store } = makeSupabase({ dropbox_connection: makeConnection() })
|
||||
|
||||
await performSync(syncParams(supabase, provider))
|
||||
|
||||
// Invalidate the period fingerprint so the next run re-uploads that file.
|
||||
const snapshot = store.get('dropbox_last_sync') as CloudLastSync
|
||||
snapshot.files![0].fingerprint = 'stale'
|
||||
store.set('dropbox_last_sync', snapshot)
|
||||
vi.mocked(provider.putFile).mockClear()
|
||||
|
||||
await performSync(syncParams(supabase, provider))
|
||||
|
||||
const call = vi.mocked(provider.putFile).mock.calls[0][0]
|
||||
expect(call.name).toBe('Arkiv 2024.zip')
|
||||
expect(call.previousId).toBe('dropbox:Arkiv 2024.zip')
|
||||
})
|
||||
})
|
||||
@@ -50,6 +50,12 @@ export interface BackupAlertInput {
|
||||
errorMessage: string | null
|
||||
/** App origin used to build the reconnect link. */
|
||||
origin: string
|
||||
/**
|
||||
* Destination that failed ("Google Drive", "Dropbox"). Named in the mail so
|
||||
* a user backing up to both knows which one to reconnect. Defaults to Google
|
||||
* Drive for callers written before the second provider existed.
|
||||
*/
|
||||
providerLabel?: string
|
||||
}
|
||||
|
||||
export async function sendBackupFailureAlert(
|
||||
@@ -68,19 +74,20 @@ export async function sendBackupFailureAlert(
|
||||
|
||||
const companyName = await fetchCompanyName(supabase, input.companyId)
|
||||
const link = `${input.origin}/import#cloud-backup`
|
||||
const providerLabel = input.providerLabel || 'Google Drive'
|
||||
|
||||
let subject: string
|
||||
let paragraphs: string[]
|
||||
if (input.kind === 'needs_reauth') {
|
||||
subject = 'Säkerhetskopieringen till Google Drive är pausad'
|
||||
subject = `Säkerhetskopieringen till ${providerLabel} är pausad`
|
||||
paragraphs = [
|
||||
`Den automatiska säkerhetskopieringen för ${companyName} är pausad: åtkomsten till ditt Google-konto har gått ut eller återkallats.`,
|
||||
'Koppla om Google Drive för att återuppta säkerhetskopieringen.',
|
||||
`Den automatiska säkerhetskopieringen för ${companyName} är pausad: åtkomsten till ditt ${providerLabel}-konto har gått ut eller återkallats.`,
|
||||
`Koppla om ${providerLabel} för att återuppta säkerhetskopieringen.`,
|
||||
]
|
||||
} else {
|
||||
subject = 'Säkerhetskopieringen till Google Drive misslyckas'
|
||||
subject = `Säkerhetskopieringen till ${providerLabel} misslyckas`
|
||||
paragraphs = [
|
||||
`Den automatiska säkerhetskopieringen för ${companyName} har misslyckats ${input.consecutiveFailures} nätter i rad.`,
|
||||
`Den automatiska säkerhetskopieringen för ${companyName} till ${providerLabel} har misslyckats ${input.consecutiveFailures} nätter i rad.`,
|
||||
input.errorMessage ? `Senaste fel: ${input.errorMessage}` : '',
|
||||
'Kontrollera anslutningen under Importera/Exportera.',
|
||||
].filter(Boolean)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Provider-agnostic contract for a cloud backup target.
|
||||
*
|
||||
* `performSync()` builds the same archive set regardless of destination and
|
||||
* talks to storage only through this interface. Everything provider-specific
|
||||
* (OAuth dialect, folder model, upload protocol, checksum verification) lives
|
||||
* behind an implementation: `google-provider.ts`, `dropbox-provider.ts`.
|
||||
*
|
||||
* Adding a provider therefore means writing one implementation and listing it
|
||||
* in `provider-registry.ts`, without touching the sync engine.
|
||||
*/
|
||||
import type { CloudConnection, CloudProviderId } from '../types'
|
||||
|
||||
/**
|
||||
* Thrown when a provider's token endpoint rejects a refresh attempt. Carries
|
||||
* the HTTP status and raw response body so callers can distinguish a
|
||||
* permanently dead refresh token (400 invalid_grant) from transient failures.
|
||||
*/
|
||||
export class CloudTokenRefreshError extends Error {
|
||||
readonly status: number
|
||||
readonly body: string
|
||||
|
||||
constructor(providerLabel: string, status: number, body: string) {
|
||||
super(`${providerLabel} token refresh failed: ${status} ${body}`)
|
||||
this.name = 'CloudTokenRefreshError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the provider reports the refresh token itself is dead (revoked,
|
||||
* expired, or the grant was invalidated). Retrying will never succeed;
|
||||
* the user must re-consent. Google and Dropbox both use OAuth 2.0's
|
||||
* `invalid_grant` for this.
|
||||
*/
|
||||
get isInvalidGrant(): boolean {
|
||||
return this.status === 400 && this.body.includes('invalid_grant')
|
||||
}
|
||||
}
|
||||
|
||||
/** The three `extension_data` keys a provider owns for a company. */
|
||||
export interface CloudStorageKeys {
|
||||
connection: string
|
||||
lastSync: string
|
||||
schedule: string
|
||||
}
|
||||
|
||||
/** Where one company's archives are written, in provider-native terms. */
|
||||
export interface CloudTarget {
|
||||
/** Drive: the company folder id. Dropbox: the company folder path. */
|
||||
folderId: string
|
||||
/** Link a human can open to reach the backup folder. */
|
||||
webViewLink: string
|
||||
}
|
||||
|
||||
export interface PreparedTarget {
|
||||
target: CloudTarget
|
||||
/**
|
||||
* Fields to merge into the stored connection because preparing the target
|
||||
* changed them (e.g. Drive re-created a folder the user trashed). Null when
|
||||
* nothing changed, so the common path writes no connection record.
|
||||
*/
|
||||
connectionPatch: Partial<CloudConnection> | null
|
||||
}
|
||||
|
||||
export interface PutFileResult {
|
||||
/** Provider-native handle: a Drive file id, or the Dropbox path. */
|
||||
id: string
|
||||
name: string
|
||||
size_bytes: number
|
||||
}
|
||||
|
||||
export interface PutFileParams {
|
||||
accessToken: string
|
||||
target: CloudTarget
|
||||
name: string
|
||||
/**
|
||||
* Handle recorded by the previous sync for this same file name. Providers
|
||||
* that address files by id use it to update in place (and to detect that
|
||||
* the user deleted the file); path-addressed providers ignore it.
|
||||
*/
|
||||
previousId?: string
|
||||
data: ArrayBuffer
|
||||
contentType: string
|
||||
}
|
||||
|
||||
export interface PrepareTargetParams {
|
||||
accessToken: string
|
||||
connection: CloudConnection
|
||||
/** `Testbolag AB (556000-0000)`: the per-company folder name. */
|
||||
companyLabel: string
|
||||
}
|
||||
|
||||
export interface CloudStorageProvider {
|
||||
readonly id: CloudProviderId
|
||||
/** Human label used in logs, errors and alert emails. */
|
||||
readonly label: string
|
||||
readonly keys: CloudStorageKeys
|
||||
/**
|
||||
* OAuth callback path relative to the extension's route root. Each provider
|
||||
* needs its own: the redirect URI is registered with the provider, so they
|
||||
* can never be merged after the fact.
|
||||
*/
|
||||
readonly callbackPath: string
|
||||
|
||||
/** False when this deployment has no OAuth credentials for the provider. */
|
||||
isConfigured(): boolean
|
||||
buildAuthorizationUrl(origin: string, state: string): string
|
||||
exchangeCode(
|
||||
origin: string,
|
||||
code: string
|
||||
): Promise<{ refreshToken: string; accountLabel: string }>
|
||||
/**
|
||||
* Best-effort: a failed revoke must not block a local disconnect.
|
||||
* `origin` resolves the same deployment credentials as the connect flow.
|
||||
*/
|
||||
revoke(refreshToken: string, origin: string): Promise<void>
|
||||
/** Throws {@link CloudTokenRefreshError} on rejection. */
|
||||
refreshAccessToken(refreshToken: string, origin: string): Promise<string>
|
||||
prepareTarget(params: PrepareTargetParams): Promise<PreparedTarget>
|
||||
/**
|
||||
* Write one file into the target, replacing any existing file of that name.
|
||||
* Implementations verify the stored bytes against the provider's own
|
||||
* checksum: a silently corrupted backup is worse than a failed one.
|
||||
*/
|
||||
putFile(params: PutFileParams): Promise<PutFileResult>
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Minimal Dropbox content client: just enough to write one archive into the
|
||||
* app folder and prove the stored bytes are the bytes we sent.
|
||||
*
|
||||
* Two upload paths, picked by size:
|
||||
* - files/upload for anything that fits one chunk (one round trip),
|
||||
* - files/upload_session/* in 8 MB chunks for the large archives, which
|
||||
* avoids holding a second copy of the payload for a multipart body.
|
||||
*
|
||||
* Both write with `mode: overwrite`, which makes uploads idempotent and lets
|
||||
* Dropbox keep its own version history (30 days on Basic, longer on paid
|
||||
* plans): the same rolling-history property the Drive target relies on.
|
||||
* Dropbox creates missing parent folders on write, so there is no folder
|
||||
* bootstrap step and nothing to revalidate.
|
||||
*
|
||||
* Every upload is verified against the `content_hash` Dropbox reports. A
|
||||
* silently corrupted backup is worse than a failed one.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const CONTENT_API = 'https://content.dropboxapi.com/2'
|
||||
|
||||
/** 8 MB upload chunks: comfortably inside Dropbox's 150 MB per-request cap. */
|
||||
const UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024
|
||||
const CHUNK_MAX_ATTEMPTS = 3
|
||||
|
||||
/** Dropbox's content_hash is defined over 4 MB blocks: not our chunk size. */
|
||||
const CONTENT_HASH_BLOCK_BYTES = 4 * 1024 * 1024
|
||||
|
||||
export interface DropboxUploadResult {
|
||||
/** App-folder-relative path, used as the file handle in sync state. */
|
||||
path: string
|
||||
name: string
|
||||
size_bytes: number
|
||||
content_hash?: string
|
||||
}
|
||||
|
||||
interface DropboxFileMetadata {
|
||||
name: string
|
||||
path_lower?: string
|
||||
path_display?: string
|
||||
size?: number
|
||||
content_hash?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropbox's documented content hash: SHA-256 of the concatenated SHA-256
|
||||
* digests of each 4 MB block. Computing it locally is the only way to verify
|
||||
* an upload, since Dropbox reports this and no other checksum.
|
||||
*/
|
||||
export function dropboxContentHash(data: ArrayBuffer): string {
|
||||
const buffer = Buffer.from(data)
|
||||
const blockHashes: Buffer[] = []
|
||||
for (let offset = 0; offset < buffer.length; offset += CONTENT_HASH_BLOCK_BYTES) {
|
||||
const block = buffer.subarray(
|
||||
offset,
|
||||
Math.min(offset + CONTENT_HASH_BLOCK_BYTES, buffer.length)
|
||||
)
|
||||
blockHashes.push(createHash('sha256').update(block).digest())
|
||||
}
|
||||
// An empty file hashes the empty concatenation, which is what Dropbox reports.
|
||||
return createHash('sha256').update(Buffer.concat(blockHashes)).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropbox passes call arguments in the `Dropbox-API-Arg` HTTP header, which
|
||||
* must be ASCII. Swedish file and folder names (`LÄSMIG.txt`, `Testbolag AB`)
|
||||
* would otherwise produce an invalid header and a 400: escape every non-ASCII
|
||||
* code unit as a \\uXXXX sequence, which Dropbox unescapes server-side.
|
||||
*/
|
||||
/** A single backslash, spelled without an escape the tooling can mangle. */
|
||||
const BACKSLASH = String.fromCharCode(92)
|
||||
const ESCAPE_PREFIX = BACKSLASH + 'u'
|
||||
|
||||
export function asciiJson(value: unknown): string {
|
||||
let out = ''
|
||||
// Iterate by code point so an astral character (an emoji in a company
|
||||
// name) is escaped as its two surrogate halves rather than mangled.
|
||||
for (const char of JSON.stringify(value)) {
|
||||
if (char.codePointAt(0)! < 128) {
|
||||
out += char
|
||||
continue
|
||||
}
|
||||
for (let i = 0; i < char.length; i++) {
|
||||
out += ESCAPE_PREFIX + char.charCodeAt(i).toString(16).padStart(4, '0')
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropbox rejects `/ \ : ? * " < > |` in a path component, along with control
|
||||
* characters and trailing dots or spaces. Company names are user-supplied, so
|
||||
* a name like `Bolaget AB / Filial` must not be able to produce an invalid
|
||||
* path (or escape into a sibling folder).
|
||||
*/
|
||||
export function sanitizeDropboxName(name: string): string {
|
||||
const ILLEGAL = '/:?*"<>|' + BACKSLASH
|
||||
let out = ''
|
||||
for (const char of name) {
|
||||
const code = char.codePointAt(0)!
|
||||
if (code < 0x20 || code === 0x7f) continue
|
||||
out += ILLEGAL.includes(char) ? '-' : char
|
||||
}
|
||||
const cleaned = out.trim().replace(/[. ]+$/, '').slice(0, 180)
|
||||
return cleaned || 'foretag'
|
||||
}
|
||||
|
||||
async function readError(res: Response): Promise<string> {
|
||||
const body = await res.text().catch(() => '')
|
||||
return `${res.status} ${body.slice(0, 200)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A response Dropbox will keep rejecting however many times we ask (bad path,
|
||||
* insufficient scope, dead token). Retrying it wastes the cron's time budget,
|
||||
* so it aborts the chunk loop immediately.
|
||||
*/
|
||||
class DropboxFatalError extends Error {}
|
||||
|
||||
/**
|
||||
* Upload `data` to `path`, replacing whatever is there.
|
||||
*
|
||||
* `path` is app-folder-relative and must start with `/`
|
||||
* (e.g. `/Testbolag AB (556000-0000)/Arkiv 2024.zip`).
|
||||
*/
|
||||
export async function uploadDropboxFile(
|
||||
accessToken: string,
|
||||
path: string,
|
||||
data: ArrayBuffer
|
||||
): Promise<DropboxUploadResult> {
|
||||
const expected = dropboxContentHash(data)
|
||||
let lastError: Error | null = null
|
||||
|
||||
// One full retry on a hash mismatch, mirroring the Drive target: a mismatch
|
||||
// is far more likely to be a truncated transfer than a corrupted source.
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const meta =
|
||||
data.byteLength <= UPLOAD_CHUNK_BYTES
|
||||
? await singleShotUpload(accessToken, path, data)
|
||||
: await sessionUpload(accessToken, path, data)
|
||||
|
||||
const result: DropboxUploadResult = {
|
||||
path: meta.path_display || meta.path_lower || path,
|
||||
name: meta.name,
|
||||
size_bytes: typeof meta.size === 'number' ? meta.size : data.byteLength,
|
||||
content_hash: meta.content_hash,
|
||||
}
|
||||
if (!result.content_hash || result.content_hash === expected) {
|
||||
return result
|
||||
}
|
||||
lastError = new Error(
|
||||
`Dropbox upload checksum mismatch: expected ${expected}, got ${result.content_hash}`
|
||||
)
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
function commitArg(path: string) {
|
||||
return {
|
||||
path,
|
||||
mode: 'overwrite' as const,
|
||||
// No desktop notification for an automated nightly backup.
|
||||
mute: true,
|
||||
// Never let Dropbox invent `Arkiv 2024 (1).zip`: overwrite is the contract
|
||||
// the fingerprint logic depends on.
|
||||
autorename: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function singleShotUpload(
|
||||
accessToken: string,
|
||||
path: string,
|
||||
data: ArrayBuffer
|
||||
): Promise<DropboxFileMetadata> {
|
||||
const res = await contentFetch(accessToken, '/files/upload', commitArg(path), data)
|
||||
return (await res.json()) as DropboxFileMetadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunked upload session. Transient failures are retried per chunk; the
|
||||
* session itself is not resumed across a failed run, because `performSync`
|
||||
* already persists per-file progress and simply re-uploads this one file.
|
||||
*/
|
||||
async function sessionUpload(
|
||||
accessToken: string,
|
||||
path: string,
|
||||
data: ArrayBuffer
|
||||
): Promise<DropboxFileMetadata> {
|
||||
const total = data.byteLength
|
||||
|
||||
// Chunks are sliced off the source rather than viewed into it: a view would
|
||||
// keep the whole 300 MB archive reachable from every in-flight request body.
|
||||
const firstChunk = data.slice(0, UPLOAD_CHUNK_BYTES)
|
||||
const startRes = await contentFetch(
|
||||
accessToken,
|
||||
'/files/upload_session/start',
|
||||
{ close: false },
|
||||
firstChunk
|
||||
)
|
||||
const { session_id: sessionId } = (await startRes.json()) as { session_id: string }
|
||||
let offset = firstChunk.byteLength
|
||||
|
||||
while (offset < total) {
|
||||
const end = Math.min(offset + UPLOAD_CHUNK_BYTES, total)
|
||||
const chunk = data.slice(offset, end)
|
||||
|
||||
if (end >= total) {
|
||||
const finishRes = await contentFetch(
|
||||
accessToken,
|
||||
'/files/upload_session/finish',
|
||||
{ cursor: { session_id: sessionId, offset }, commit: commitArg(path) },
|
||||
chunk
|
||||
)
|
||||
return (await finishRes.json()) as DropboxFileMetadata
|
||||
}
|
||||
|
||||
await contentFetch(
|
||||
accessToken,
|
||||
'/files/upload_session/append_v2',
|
||||
{ cursor: { session_id: sessionId, offset }, close: false },
|
||||
chunk
|
||||
)
|
||||
offset = end
|
||||
}
|
||||
|
||||
// Payload was an exact multiple of the chunk size: close with an empty commit.
|
||||
const finishRes = await contentFetch(
|
||||
accessToken,
|
||||
'/files/upload_session/finish',
|
||||
{ cursor: { session_id: sessionId, offset }, commit: commitArg(path) },
|
||||
new ArrayBuffer(0)
|
||||
)
|
||||
return (await finishRes.json()) as DropboxFileMetadata
|
||||
}
|
||||
|
||||
async function contentFetch(
|
||||
accessToken: string,
|
||||
endpoint: string,
|
||||
arg: unknown,
|
||||
body: ArrayBuffer
|
||||
): Promise<Response> {
|
||||
let lastErr: unknown = null
|
||||
for (let attempt = 0; attempt < CHUNK_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`${CONTENT_API}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Dropbox-API-Arg': asciiJson(arg),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body,
|
||||
})
|
||||
if (res.ok) return res
|
||||
// 429 (rate limited) and 5xx are the documented retryable responses;
|
||||
// everything else is our fault and will fail identically on retry.
|
||||
const detail = `Dropbox ${endpoint} failed: ${await readError(res)}`
|
||||
if (res.status === 429 || res.status >= 500) {
|
||||
lastErr = new Error(detail)
|
||||
continue
|
||||
}
|
||||
throw new DropboxFatalError(detail)
|
||||
} catch (err) {
|
||||
if (err instanceof DropboxFatalError) throw err
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof Error
|
||||
? lastErr
|
||||
: new Error(`Dropbox ${endpoint} failed after retries`)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Minimal Dropbox OAuth 2.0 client for the cloud-backup extension.
|
||||
*
|
||||
* Access type: **App folder**. Dropbox confines the app to its own
|
||||
* `Apps/<app name>/` directory, so every path we use is relative to that
|
||||
* folder and the app can never read the rest of the user's Dropbox. This
|
||||
* mirrors the privacy posture of the Google `drive.file` scope.
|
||||
*
|
||||
* `token_access_type=offline` is what makes Dropbox return a refresh token;
|
||||
* without it the grant yields a short-lived access token only and the nightly
|
||||
* cron would stop working after a few hours.
|
||||
*/
|
||||
|
||||
import {
|
||||
fetchWithTimeout,
|
||||
OAUTH_TIMEOUT_MS,
|
||||
OAUTH_REVOKE_TIMEOUT_MS,
|
||||
} from '@/lib/http/fetch-with-timeout'
|
||||
import { CloudTokenRefreshError } from './cloud-provider'
|
||||
|
||||
const AUTH_ENDPOINT = 'https://www.dropbox.com/oauth2/authorize'
|
||||
const TOKEN_ENDPOINT = 'https://api.dropboxapi.com/oauth2/token'
|
||||
const REVOKE_ENDPOINT = 'https://api.dropboxapi.com/2/auth/token/revoke'
|
||||
const ACCOUNT_ENDPOINT = 'https://api.dropboxapi.com/2/users/get_current_account'
|
||||
|
||||
/**
|
||||
* Write + read the app folder, and read the account identity for the "connected
|
||||
* as" line in the UI. Read access is requested because a restore-side feature
|
||||
* (and support diagnosing a partial backup) needs to list what is actually
|
||||
* there; without it the app is write-only and cannot verify its own output.
|
||||
*/
|
||||
const SCOPES = ['files.content.write', 'files.content.read', 'account_info.read']
|
||||
|
||||
export interface DropboxOAuthEnv {
|
||||
appKey: string
|
||||
appSecret: string
|
||||
redirectUri: string
|
||||
}
|
||||
|
||||
/** Callback path, kept next to the URL builder so the two cannot drift. */
|
||||
export const DROPBOX_CALLBACK_PATH = '/oauth/dropbox/callback'
|
||||
|
||||
export function isDropboxOAuthConfigured(): boolean {
|
||||
return Boolean(process.env.DROPBOX_APP_KEY && process.env.DROPBOX_APP_SECRET)
|
||||
}
|
||||
|
||||
export function getDropboxOAuthEnv(origin: string): DropboxOAuthEnv {
|
||||
const appKey = process.env.DROPBOX_APP_KEY
|
||||
const appSecret = process.env.DROPBOX_APP_SECRET
|
||||
if (!appKey || !appSecret) {
|
||||
throw new Error(
|
||||
'Dropbox OAuth is not configured: set DROPBOX_APP_KEY and DROPBOX_APP_SECRET'
|
||||
)
|
||||
}
|
||||
return {
|
||||
appKey,
|
||||
appSecret,
|
||||
redirectUri: `${origin}/api/extensions/ext/cloud-backup${DROPBOX_CALLBACK_PATH}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDropboxAuthorizationUrl(
|
||||
env: DropboxOAuthEnv,
|
||||
state: string
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: env.appKey,
|
||||
redirect_uri: env.redirectUri,
|
||||
response_type: 'code',
|
||||
token_access_type: 'offline',
|
||||
scope: SCOPES.join(' '),
|
||||
// Re-consent on every connect so a reconnect always yields a fresh refresh
|
||||
// token, matching the Google flow's `prompt=consent`.
|
||||
force_reapprove: 'true',
|
||||
state,
|
||||
})
|
||||
return `${AUTH_ENDPOINT}?${params.toString()}`
|
||||
}
|
||||
|
||||
export interface DropboxTokenExchangeResult {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
account_id?: string
|
||||
}
|
||||
|
||||
export async function exchangeDropboxCodeForTokens(
|
||||
env: DropboxOAuthEnv,
|
||||
code: string
|
||||
): Promise<DropboxTokenExchangeResult> {
|
||||
const body = new URLSearchParams({
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
client_id: env.appKey,
|
||||
client_secret: env.appSecret,
|
||||
redirect_uri: env.redirectUri,
|
||||
})
|
||||
const res = await fetchWithTimeout(
|
||||
TOKEN_ENDPOINT,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
},
|
||||
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Dropbox token exchange' }
|
||||
)
|
||||
if (!res.ok) {
|
||||
const errText = await res.text()
|
||||
throw new Error(`Dropbox token exchange failed: ${res.status} ${errText}`)
|
||||
}
|
||||
const json = (await res.json()) as DropboxTokenExchangeResult
|
||||
if (!json.refresh_token) {
|
||||
throw new Error(
|
||||
'No refresh token returned: the authorization request must use ' +
|
||||
'token_access_type=offline.'
|
||||
)
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when Dropbox's token endpoint rejects a refresh. Shares
|
||||
* {@link CloudTokenRefreshError}'s `invalid_grant` detection: Dropbox returns
|
||||
* `400 {"error": "invalid_grant"}` once the user disconnects the app.
|
||||
*/
|
||||
export class DropboxTokenRefreshError extends CloudTokenRefreshError {
|
||||
constructor(status: number, body: string) {
|
||||
super('Dropbox', status, body)
|
||||
this.name = 'DropboxTokenRefreshError'
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshDropboxAccessToken(
|
||||
env: DropboxOAuthEnv,
|
||||
refreshToken: string
|
||||
): Promise<{ access_token: string; expires_in: number }> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: env.appKey,
|
||||
client_secret: env.appSecret,
|
||||
})
|
||||
const res = await fetchWithTimeout(
|
||||
TOKEN_ENDPOINT,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
},
|
||||
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Dropbox token refresh' }
|
||||
)
|
||||
if (!res.ok) {
|
||||
const errText = await res.text()
|
||||
throw new DropboxTokenRefreshError(res.status, errText)
|
||||
}
|
||||
return (await res.json()) as { access_token: string; expires_in: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the grant. Dropbox revokes by *access* token (there is no
|
||||
* refresh-token revoke endpoint), so the caller mints a short-lived one first.
|
||||
* Best-effort: a failed revoke must not block the local disconnect.
|
||||
*/
|
||||
export async function revokeDropboxToken(accessToken: string): Promise<void> {
|
||||
try {
|
||||
await fetchWithTimeout(
|
||||
REVOKE_ENDPOINT,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
{ timeoutMs: OAUTH_REVOKE_TIMEOUT_MS, description: 'Dropbox token revoke' }
|
||||
)
|
||||
} catch {
|
||||
// Swallow timeouts and network errors so disconnect flows still complete locally.
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDropboxAccountEmail(accessToken: string): Promise<string> {
|
||||
// An RPC endpoint taking no arguments: send no body and no Content-Type,
|
||||
// otherwise Dropbox rejects the call as a malformed request.
|
||||
const res = await fetchWithTimeout(
|
||||
ACCOUNT_ENDPOINT,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Dropbox account fetch' }
|
||||
)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch Dropbox account info: ${res.status}`)
|
||||
}
|
||||
const json = (await res.json()) as { email?: string }
|
||||
return json.email || 'unknown@dropbox'
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Dropbox as a cloud backup target.
|
||||
*
|
||||
* The app is **app-folder** scoped, so every path here is relative to
|
||||
* `Apps/<app name>/` in the user's Dropbox and the app can never see anything
|
||||
* else they store there. That gives the same "we only touch what we created"
|
||||
* guarantee as the Google `drive.file` scope.
|
||||
*
|
||||
* Consequences for the sync engine, all handled here:
|
||||
* - There is no root folder to create: Dropbox's own app folder plays that
|
||||
* role, so company folders sit directly inside it (Drive needs the extra
|
||||
* `gnubok/` level because it writes into the user's whole Drive).
|
||||
* - Files are addressed by path, not id, and writes are `overwrite`. Nothing
|
||||
* can go stale, so `prepareTarget` makes no network call and the
|
||||
* "user deleted the file" recovery the Drive target needs has no analogue.
|
||||
*/
|
||||
import {
|
||||
buildDropboxAuthorizationUrl,
|
||||
exchangeDropboxCodeForTokens,
|
||||
fetchDropboxAccountEmail,
|
||||
getDropboxOAuthEnv,
|
||||
isDropboxOAuthConfigured,
|
||||
refreshDropboxAccessToken,
|
||||
revokeDropboxToken,
|
||||
DROPBOX_CALLBACK_PATH,
|
||||
} from './dropbox-oauth'
|
||||
import { sanitizeDropboxName, uploadDropboxFile } from './dropbox-client'
|
||||
import type {
|
||||
CloudStorageProvider,
|
||||
PreparedTarget,
|
||||
PrepareTargetParams,
|
||||
PutFileParams,
|
||||
PutFileResult,
|
||||
} from './cloud-provider'
|
||||
import type { CloudConnection } from '../types'
|
||||
|
||||
export const DROPBOX_CONNECTION_KEY = 'dropbox_connection'
|
||||
export const DROPBOX_LAST_SYNC_KEY = 'dropbox_last_sync'
|
||||
export const DROPBOX_SCHEDULE_KEY = 'dropbox_schedule'
|
||||
|
||||
/**
|
||||
* Link into the Dropbox web UI.
|
||||
*
|
||||
* App-folder scoped calls only ever see app-relative paths, so the API cannot
|
||||
* tell us where the app folder sits in the user's account. `Apps/<app name>`
|
||||
* is the answer, and the app name is chosen when the Dropbox app is
|
||||
* registered: a self-hoster registers their own. `DROPBOX_APP_FOLDER_NAME`
|
||||
* lets a deployment state it and get a deep link; without it we send the user
|
||||
* to `Apps/`, which is always correct and one click away. Never guess the
|
||||
* name: a link into the wrong folder reads as a lost backup.
|
||||
*/
|
||||
function folderLink(companyFolderPath: string): string {
|
||||
const appFolder = process.env.DROPBOX_APP_FOLDER_NAME
|
||||
const base = 'https://www.dropbox.com/home/Apps'
|
||||
if (!appFolder) return base
|
||||
return `${base}/${encodeURIComponent(appFolder)}${companyFolderPath
|
||||
.split('/')
|
||||
.map(encodeURIComponent)
|
||||
.join('/')}`
|
||||
}
|
||||
|
||||
export const dropboxProvider: CloudStorageProvider = {
|
||||
id: 'dropbox',
|
||||
label: 'Dropbox',
|
||||
keys: {
|
||||
connection: DROPBOX_CONNECTION_KEY,
|
||||
lastSync: DROPBOX_LAST_SYNC_KEY,
|
||||
schedule: DROPBOX_SCHEDULE_KEY,
|
||||
},
|
||||
callbackPath: DROPBOX_CALLBACK_PATH,
|
||||
|
||||
isConfigured: isDropboxOAuthConfigured,
|
||||
|
||||
buildAuthorizationUrl(origin, state) {
|
||||
return buildDropboxAuthorizationUrl(getDropboxOAuthEnv(origin), state)
|
||||
},
|
||||
|
||||
async exchangeCode(origin, code) {
|
||||
const tokens = await exchangeDropboxCodeForTokens(getDropboxOAuthEnv(origin), code)
|
||||
const email = await fetchDropboxAccountEmail(tokens.access_token)
|
||||
return { refreshToken: tokens.refresh_token, accountLabel: email }
|
||||
},
|
||||
|
||||
async revoke(refreshToken, origin) {
|
||||
// Dropbox revokes by access token, so mint a short-lived one first. The
|
||||
// whole thing is best-effort: a dead refresh token is already revoked.
|
||||
try {
|
||||
const env = getDropboxOAuthEnv(origin)
|
||||
const refreshed = await refreshDropboxAccessToken(env, refreshToken)
|
||||
await revokeDropboxToken(refreshed.access_token)
|
||||
} catch {
|
||||
// Swallow: the local disconnect must complete regardless.
|
||||
}
|
||||
},
|
||||
|
||||
async refreshAccessToken(refreshToken, origin) {
|
||||
const refreshed = await refreshDropboxAccessToken(
|
||||
getDropboxOAuthEnv(origin),
|
||||
refreshToken
|
||||
)
|
||||
return refreshed.access_token
|
||||
},
|
||||
|
||||
/**
|
||||
* Pure path derivation: Dropbox creates missing parent folders on upload, so
|
||||
* there is nothing to create up front and nothing that can be trashed
|
||||
* underneath us.
|
||||
*/
|
||||
async prepareTarget({
|
||||
connection,
|
||||
companyLabel,
|
||||
}: PrepareTargetParams): Promise<PreparedTarget> {
|
||||
const companyFolderPath = `/${sanitizeDropboxName(companyLabel)}`
|
||||
const patch: Partial<CloudConnection> | null =
|
||||
connection.company_folder_path === companyFolderPath
|
||||
? null
|
||||
: { company_folder_path: companyFolderPath }
|
||||
|
||||
return {
|
||||
target: {
|
||||
folderId: companyFolderPath,
|
||||
webViewLink: folderLink(companyFolderPath),
|
||||
},
|
||||
connectionPatch: patch,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* `previousId` is ignored: the path is derived from the file name, and an
|
||||
* overwrite write is correct whether or not the file is already there. That
|
||||
* also means a file the user deleted in Dropbox simply reappears on the next
|
||||
* sync, with no recovery path needed.
|
||||
*/
|
||||
async putFile({
|
||||
accessToken,
|
||||
target,
|
||||
name,
|
||||
data,
|
||||
}: PutFileParams): Promise<PutFileResult> {
|
||||
const path = `${target.folderId}/${sanitizeDropboxName(name)}`
|
||||
const uploaded = await uploadDropboxFile(accessToken, path, data)
|
||||
return {
|
||||
id: uploaded.path,
|
||||
name: uploaded.name,
|
||||
size_bytes: uploaded.size_bytes,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Folder naming shared by the sync engine and the storage providers.
|
||||
*
|
||||
* Lives in its own module so a provider can read it without importing
|
||||
* `sync.ts`, which imports the provider registry back (import cycle).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Top-level folder created in the user's Google Drive. Wire-format identifier:
|
||||
* it names a folder that already exists in every connected user's Drive, so
|
||||
* renaming it would strand their existing backups (see the gnubok naming rule
|
||||
* in CLAUDE.md).
|
||||
*
|
||||
* Dropbox has no equivalent: the app is app-folder scoped, so Dropbox's own
|
||||
* `Apps/<app name>/` folder already plays this role and company folders sit
|
||||
* directly inside it.
|
||||
*/
|
||||
export const ROOT_FOLDER_NAME = 'gnubok'
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
OAUTH_TIMEOUT_MS,
|
||||
OAUTH_REVOKE_TIMEOUT_MS,
|
||||
} from '@/lib/http/fetch-with-timeout'
|
||||
import { CloudTokenRefreshError } from './cloud-provider'
|
||||
|
||||
const DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.file'
|
||||
const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
@@ -24,6 +25,15 @@ export interface OAuthEnv {
|
||||
redirectUri: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this deployment can run the Google flow at all. Checked before the
|
||||
* UI offers a connect button, so a missing credential renders as a disabled
|
||||
* row instead of a failed OAuth round-trip.
|
||||
*/
|
||||
export function isGoogleOAuthConfigured(): boolean {
|
||||
return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET)
|
||||
}
|
||||
|
||||
export function getOAuthEnv(origin: string): OAuthEnv {
|
||||
const clientId = process.env.GOOGLE_CLIENT_ID
|
||||
const clientSecret = process.env.GOOGLE_CLIENT_SECRET
|
||||
@@ -103,25 +113,14 @@ export interface AccessTokenResult {
|
||||
* Thrown when Google's token endpoint rejects a refresh attempt. Carries the
|
||||
* HTTP status and raw response body so callers can distinguish a permanently
|
||||
* dead refresh token (400 invalid_grant) from transient failures.
|
||||
*
|
||||
* Extends the provider-agnostic {@link CloudTokenRefreshError} so `performSync`
|
||||
* can handle a dead token identically whatever the destination is.
|
||||
*/
|
||||
export class GoogleTokenRefreshError extends Error {
|
||||
readonly status: number
|
||||
readonly body: string
|
||||
|
||||
export class GoogleTokenRefreshError extends CloudTokenRefreshError {
|
||||
constructor(status: number, body: string) {
|
||||
super(`Google token refresh failed: ${status} ${body}`)
|
||||
super('Google', status, body)
|
||||
this.name = 'GoogleTokenRefreshError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
|
||||
/**
|
||||
* True when Google reports the refresh token itself is dead (revoked,
|
||||
* expired, or the grant was invalidated). Retrying will never succeed;
|
||||
* the user must re-consent.
|
||||
*/
|
||||
get isInvalidGrant(): boolean {
|
||||
return this.status === 400 && this.body.includes('invalid_grant')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Google Drive as a cloud backup target.
|
||||
*
|
||||
* Wraps the existing `google-oauth.ts` + `google-drive.ts` clients in the
|
||||
* provider contract. Behaviour is unchanged from before the contract existed:
|
||||
* the same `drive.file` scope, the same `gnubok/<company>/` folder pair with
|
||||
* trashed-folder revalidation, and the same resumable md5-verified uploads.
|
||||
*
|
||||
* The storage keys keep their original `google_drive_*` names on purpose:
|
||||
* every already-connected company has records under them.
|
||||
*/
|
||||
import { ROOT_FOLDER_NAME } from './folder-names'
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
exchangeCodeForTokens,
|
||||
fetchUserEmail,
|
||||
getOAuthEnv,
|
||||
isGoogleOAuthConfigured,
|
||||
refreshAccessToken,
|
||||
revokeToken,
|
||||
} from './google-oauth'
|
||||
import {
|
||||
DriveFileGoneError,
|
||||
ensureFolder,
|
||||
getFileMeta,
|
||||
updateFile,
|
||||
uploadFile,
|
||||
} from './google-drive'
|
||||
import type {
|
||||
CloudStorageProvider,
|
||||
PreparedTarget,
|
||||
PrepareTargetParams,
|
||||
PutFileParams,
|
||||
PutFileResult,
|
||||
} from './cloud-provider'
|
||||
import type { CloudConnection } from '../types'
|
||||
|
||||
export const GOOGLE_CONNECTION_KEY = 'google_drive_connection'
|
||||
export const GOOGLE_LAST_SYNC_KEY = 'google_drive_last_sync'
|
||||
export const GOOGLE_SCHEDULE_KEY = 'google_drive_schedule'
|
||||
|
||||
function folderLink(folderId: string): string {
|
||||
return `https://drive.google.com/drive/folders/${folderId}`
|
||||
}
|
||||
|
||||
export const googleDriveProvider: CloudStorageProvider = {
|
||||
id: 'google_drive',
|
||||
label: 'Google Drive',
|
||||
keys: {
|
||||
connection: GOOGLE_CONNECTION_KEY,
|
||||
lastSync: GOOGLE_LAST_SYNC_KEY,
|
||||
schedule: GOOGLE_SCHEDULE_KEY,
|
||||
},
|
||||
// Registered with Google as an authorised redirect URI: never change it.
|
||||
callbackPath: '/oauth/callback',
|
||||
|
||||
isConfigured: isGoogleOAuthConfigured,
|
||||
|
||||
buildAuthorizationUrl(origin, state) {
|
||||
return buildAuthorizationUrl(getOAuthEnv(origin), state)
|
||||
},
|
||||
|
||||
async exchangeCode(origin, code) {
|
||||
const tokens = await exchangeCodeForTokens(getOAuthEnv(origin), code)
|
||||
const email = await fetchUserEmail(tokens.access_token)
|
||||
return { refreshToken: tokens.refresh_token, accountLabel: email }
|
||||
},
|
||||
|
||||
async revoke(refreshToken) {
|
||||
// Google revokes a refresh token directly: no client credentials needed.
|
||||
await revokeToken(refreshToken)
|
||||
},
|
||||
|
||||
async refreshAccessToken(refreshToken, origin) {
|
||||
const refreshed = await refreshAccessToken(getOAuthEnv(origin), refreshToken)
|
||||
return refreshed.access_token
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve the `gnubok/<company>/` folder pair, revalidating the cached ids.
|
||||
*
|
||||
* Files created inside a trashed folder are purged with it, so a folder the
|
||||
* user trashed or deleted must never receive uploads. `trashed` is inherited
|
||||
* from parents, so checking the company folder covers a trashed root too;
|
||||
* the root is only re-checked when the company folder needs recreating.
|
||||
*/
|
||||
async prepareTarget({
|
||||
accessToken,
|
||||
connection,
|
||||
companyLabel,
|
||||
}: PrepareTargetParams): Promise<PreparedTarget> {
|
||||
let rootFolderId = connection.root_folder_id
|
||||
let companyFolderId = connection.company_folder_id
|
||||
|
||||
if (companyFolderId) {
|
||||
const meta = await getFileMeta(accessToken, companyFolderId)
|
||||
if (!meta || meta.trashed) companyFolderId = null
|
||||
}
|
||||
if (!companyFolderId && rootFolderId) {
|
||||
const rootMeta = await getFileMeta(accessToken, rootFolderId)
|
||||
if (!rootMeta || rootMeta.trashed) rootFolderId = null
|
||||
}
|
||||
if (!rootFolderId) {
|
||||
const root = await ensureFolder(accessToken, ROOT_FOLDER_NAME, null)
|
||||
rootFolderId = root.id
|
||||
}
|
||||
if (!companyFolderId) {
|
||||
const companyFolder = await ensureFolder(accessToken, companyLabel, rootFolderId)
|
||||
companyFolderId = companyFolder.id
|
||||
}
|
||||
|
||||
const changed =
|
||||
rootFolderId !== connection.root_folder_id ||
|
||||
companyFolderId !== connection.company_folder_id
|
||||
const patch: Partial<CloudConnection> | null = changed
|
||||
? { root_folder_id: rootFolderId, company_folder_id: companyFolderId }
|
||||
: null
|
||||
|
||||
return {
|
||||
target: { folderId: companyFolderId, webViewLink: folderLink(companyFolderId) },
|
||||
connectionPatch: patch,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the file in place when we already know its id (Drive keeps ~30 days
|
||||
* of prior versions, which gives the backup rolling history without one file
|
||||
* per day), falling back to a create when the user deleted it.
|
||||
*/
|
||||
async putFile({
|
||||
accessToken,
|
||||
target,
|
||||
name,
|
||||
previousId,
|
||||
data,
|
||||
contentType,
|
||||
}: PutFileParams): Promise<PutFileResult> {
|
||||
if (previousId) {
|
||||
try {
|
||||
return await updateFile(accessToken, previousId, data, contentType)
|
||||
} catch (err) {
|
||||
if (!(err instanceof DriveFileGoneError)) throw err
|
||||
// The user deleted the file in Drive: recreate it.
|
||||
}
|
||||
}
|
||||
return uploadFile(accessToken, target.folderId, name, data, contentType)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* The cloud backup destinations this build knows about.
|
||||
*
|
||||
* Order matters: it is the order the providers are listed in the UI and the
|
||||
* order the cron walks them. Google Drive stays first because it shipped
|
||||
* first and every existing connection points at it.
|
||||
*/
|
||||
import { googleDriveProvider } from './google-provider'
|
||||
import { dropboxProvider } from './dropbox-provider'
|
||||
import type { CloudStorageProvider } from './cloud-provider'
|
||||
import type { CloudProviderId } from '../types'
|
||||
|
||||
export const CLOUD_PROVIDERS: readonly CloudStorageProvider[] = [
|
||||
googleDriveProvider,
|
||||
dropboxProvider,
|
||||
]
|
||||
|
||||
/** The destination assumed when a request names none: the original target. */
|
||||
export const DEFAULT_PROVIDER_ID: CloudProviderId = 'google_drive'
|
||||
|
||||
export function getProvider(id: string | null | undefined): CloudStorageProvider | null {
|
||||
if (!id) return getProvider(DEFAULT_PROVIDER_ID)
|
||||
return CLOUD_PROVIDERS.find((p) => p.id === id) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the provider named by a request's `?provider=` parameter.
|
||||
* Absent means Google Drive, so links and clients written before Dropbox
|
||||
* existed keep hitting the destination they meant.
|
||||
*/
|
||||
export function providerFromRequest(request: Request): CloudStorageProvider | null {
|
||||
return getProvider(new URL(request.url).searchParams.get('provider'))
|
||||
}
|
||||
@@ -9,30 +9,25 @@ import { buildDriveFolderReadme } from '@/lib/reports/archive-readme'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
getOAuthEnv,
|
||||
refreshAccessToken,
|
||||
GoogleTokenRefreshError,
|
||||
} from './google-oauth'
|
||||
import {
|
||||
DriveFileGoneError,
|
||||
ensureFolder,
|
||||
getFileMeta,
|
||||
updateFile,
|
||||
uploadFile,
|
||||
type UploadResult,
|
||||
} from './google-drive'
|
||||
import { CloudTokenRefreshError, type CloudStorageProvider } from './cloud-provider'
|
||||
import { googleDriveProvider } from './google-provider'
|
||||
import { decryptToken } from './crypto'
|
||||
import type {
|
||||
DriveFileState,
|
||||
GoogleDriveConnection,
|
||||
GoogleDriveLastSync,
|
||||
CloudConnection,
|
||||
CloudFileState,
|
||||
CloudLastSync,
|
||||
} from '../types'
|
||||
|
||||
export { ROOT_FOLDER_NAME } from './folder-names'
|
||||
|
||||
/**
|
||||
* Google Drive's storage keys, re-exported for the call sites that predate
|
||||
* multi-provider support. Provider-aware code reads `provider.keys` instead:
|
||||
* Dropbox owns `dropbox_*` records alongside these.
|
||||
*/
|
||||
export const CONNECTION_KEY = 'google_drive_connection'
|
||||
export const LAST_SYNC_KEY = 'google_drive_last_sync'
|
||||
export const SCHEDULE_KEY = 'google_drive_schedule'
|
||||
export const ROOT_FOLDER_NAME = 'gnubok'
|
||||
/**
|
||||
* Per-FILE ceiling, not per-backup: the backup splits into one archive per
|
||||
* räkenskapsår plus Grunddata.zip, and uploads are resumable/chunked. The
|
||||
@@ -62,8 +57,8 @@ export type SyncFailureReason =
|
||||
export type PerformSyncResult =
|
||||
| {
|
||||
ok: true
|
||||
lastSync: GoogleDriveLastSync
|
||||
/** Link to the company's backup folder in Drive. */
|
||||
lastSync: CloudLastSync
|
||||
/** Link to the company's backup folder at the provider. */
|
||||
webViewLink: string
|
||||
uploadedCount: number
|
||||
skippedCount: number
|
||||
@@ -89,11 +84,17 @@ interface PerformSyncParams {
|
||||
* off and lets the user choose via a dialog.
|
||||
*/
|
||||
allowDocumentFallback?: boolean
|
||||
/**
|
||||
* Destination. Defaults to Google Drive, which is what every caller meant
|
||||
* before Dropbox existed. Each provider keeps its own connection, last-sync
|
||||
* and schedule records, so syncing one never disturbs the other.
|
||||
*/
|
||||
provider?: CloudStorageProvider
|
||||
}
|
||||
|
||||
interface PlannedFile {
|
||||
key: string
|
||||
kind: DriveFileState['kind']
|
||||
kind: CloudFileState['kind']
|
||||
periodId?: string
|
||||
name: string
|
||||
contentType: string
|
||||
@@ -111,14 +112,17 @@ interface PeriodRow {
|
||||
/**
|
||||
* Run the end-to-end cloud backup sync against the per-fiscal-year layout:
|
||||
*
|
||||
* gnubok/<company>/Arkiv <år>.zip one per räkenskapsår
|
||||
* gnubok/<company>/Grunddata.zip registers, SIE originals, audit trail
|
||||
* gnubok/<company>/LÄSMIG.txt folder map
|
||||
* <company>/Arkiv <år>.zip one per räkenskapsår
|
||||
* <company>/Grunddata.zip registers, SIE originals, audit trail
|
||||
* <company>/LÄSMIG.txt folder map
|
||||
*
|
||||
* (Google Drive nests that company folder under a `gnubok/` root; Dropbox's
|
||||
* app folder already provides one.)
|
||||
*
|
||||
* Every file carries a fingerprint (entry/document counts + latest
|
||||
* timestamps); only files whose fingerprint changed are regenerated and
|
||||
* uploaded, in place (Drive keeps ~30 days of prior versions). State is
|
||||
* persisted after every upload, so an interrupted run resumes where it
|
||||
* uploaded, in place (both providers keep prior versions for ~30 days). State
|
||||
* is persisted after every upload, so an interrupted run resumes where it
|
||||
* stopped instead of re-uploading finished years.
|
||||
*
|
||||
* Settings ops use raw `extension_data` queries (not the extension context
|
||||
@@ -126,6 +130,8 @@ interface PeriodRow {
|
||||
*/
|
||||
export async function performSync(params: PerformSyncParams): Promise<PerformSyncResult> {
|
||||
const { supabase, companyId, userId, origin } = params
|
||||
const provider = params.provider ?? googleDriveProvider
|
||||
const keys = provider.keys
|
||||
|
||||
// Archive generation reads the private `documents` bucket, whose SELECT
|
||||
// policy only covers the uploader's own folder. User-triggered syncs pass
|
||||
@@ -136,37 +142,39 @@ export async function performSync(params: PerformSyncParams): Promise<PerformSyn
|
||||
// by the explicit companyId.
|
||||
const archiveClient = createServiceClient()
|
||||
|
||||
const connection = await loadExtensionData<GoogleDriveConnection>(
|
||||
const connection = await loadExtensionData<CloudConnection>(
|
||||
supabase,
|
||||
companyId,
|
||||
CONNECTION_KEY
|
||||
keys.connection
|
||||
)
|
||||
if (!connection) {
|
||||
return { ok: false, reason: 'not_connected', message: 'Google Drive not connected' }
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'not_connected',
|
||||
message: `${provider.label} not connected`,
|
||||
}
|
||||
}
|
||||
|
||||
const env = getOAuthEnv(origin)
|
||||
const refreshToken = decryptToken(connection.refresh_token_encrypted)
|
||||
let accessToken: string
|
||||
try {
|
||||
const refreshed = await refreshAccessToken(env, refreshToken)
|
||||
accessToken = refreshed.access_token
|
||||
accessToken = await provider.refreshAccessToken(refreshToken, origin)
|
||||
} catch (err) {
|
||||
if (err instanceof GoogleTokenRefreshError && err.isInvalidGrant) {
|
||||
if (err instanceof CloudTokenRefreshError && err.isInvalidGrant) {
|
||||
// The refresh token is permanently dead (revoked or expired). Flag the
|
||||
// connection so the cron stops retrying it and the UI can ask the user
|
||||
// to reconnect. Other failures (network, 5xx) stay throwing: they are
|
||||
// transient and worth retrying.
|
||||
const flagged: GoogleDriveConnection = {
|
||||
const flagged: CloudConnection = {
|
||||
...connection,
|
||||
status: 'needs_reauth',
|
||||
needs_reauth_at: new Date().toISOString(),
|
||||
}
|
||||
await saveExtensionData(supabase, companyId, userId, CONNECTION_KEY, flagged)
|
||||
await saveExtensionData(supabase, companyId, userId, keys.connection, flagged)
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'needs_reauth',
|
||||
message: 'Google Drive authorization expired; reconnect required',
|
||||
message: `${provider.label} authorization expired; reconnect required`,
|
||||
}
|
||||
}
|
||||
throw err
|
||||
@@ -174,39 +182,16 @@ export async function performSync(params: PerformSyncParams): Promise<PerformSyn
|
||||
|
||||
const company = await fetchCompanyInfo(supabase, companyId)
|
||||
|
||||
let rootFolderId = connection.root_folder_id
|
||||
let companyFolderId = connection.company_folder_id
|
||||
// Revalidate cached folder ids: files created inside a trashed folder are
|
||||
// purged with it, so a folder the user trashed or deleted must never
|
||||
// receive uploads. `trashed` is inherited from parents, so checking the
|
||||
// company folder covers a trashed root too; the root is only re-checked
|
||||
// when the company folder needs recreating.
|
||||
if (companyFolderId) {
|
||||
const meta = await getFileMeta(accessToken, companyFolderId)
|
||||
if (!meta || meta.trashed) companyFolderId = null
|
||||
}
|
||||
if (!companyFolderId && rootFolderId) {
|
||||
const rootMeta = await getFileMeta(accessToken, rootFolderId)
|
||||
if (!rootMeta || rootMeta.trashed) rootFolderId = null
|
||||
}
|
||||
if (!rootFolderId) {
|
||||
const root = await ensureFolder(accessToken, ROOT_FOLDER_NAME, null)
|
||||
rootFolderId = root.id
|
||||
}
|
||||
if (!companyFolderId) {
|
||||
const companyFolder = await ensureFolder(accessToken, company.label, rootFolderId)
|
||||
companyFolderId = companyFolder.id
|
||||
}
|
||||
if (
|
||||
rootFolderId !== connection.root_folder_id ||
|
||||
companyFolderId !== connection.company_folder_id ||
|
||||
connection.status === 'needs_reauth'
|
||||
) {
|
||||
const { target, connectionPatch } = await provider.prepareTarget({
|
||||
accessToken,
|
||||
connection,
|
||||
companyLabel: company.label,
|
||||
})
|
||||
if (connectionPatch || connection.status === 'needs_reauth') {
|
||||
// A successful refresh also clears a stale needs_reauth flag.
|
||||
await saveExtensionData(supabase, companyId, userId, CONNECTION_KEY, {
|
||||
await saveExtensionData(supabase, companyId, userId, keys.connection, {
|
||||
...connection,
|
||||
root_folder_id: rootFolderId,
|
||||
company_folder_id: companyFolderId,
|
||||
...connectionPatch,
|
||||
status: 'active',
|
||||
needs_reauth_at: undefined,
|
||||
})
|
||||
@@ -389,25 +374,26 @@ export async function performSync(params: PerformSyncParams): Promise<PerformSyn
|
||||
})
|
||||
|
||||
// ---- Execute: regenerate + upload only what changed. ----
|
||||
const previous = await loadExtensionData<GoogleDriveLastSync>(
|
||||
const previous = await loadExtensionData<CloudLastSync>(
|
||||
supabase,
|
||||
companyId,
|
||||
LAST_SYNC_KEY
|
||||
keys.lastSync
|
||||
)
|
||||
const stateByKey = new Map<string, DriveFileState>()
|
||||
const stateByKey = new Map<string, CloudFileState>()
|
||||
for (const file of previous?.files ?? []) {
|
||||
stateByKey.set(fileKey(file), file)
|
||||
}
|
||||
|
||||
const persistSnapshot = async (): Promise<GoogleDriveLastSync> => {
|
||||
const persistSnapshot = async (): Promise<CloudLastSync> => {
|
||||
const files = [...stateByKey.values()]
|
||||
const lastSync: GoogleDriveLastSync = {
|
||||
const lastSync: CloudLastSync = {
|
||||
at: new Date().toISOString(),
|
||||
folder_id: companyFolderId!,
|
||||
folder_id: target.folderId,
|
||||
web_view_link: target.webViewLink,
|
||||
files,
|
||||
total_size_bytes: files.reduce((sum, f) => sum + f.size_bytes, 0),
|
||||
}
|
||||
await saveExtensionData(supabase, companyId, userId, LAST_SYNC_KEY, lastSync)
|
||||
await saveExtensionData(supabase, companyId, userId, keys.lastSync, lastSync)
|
||||
return lastSync
|
||||
}
|
||||
|
||||
@@ -421,27 +407,18 @@ export async function performSync(params: PerformSyncParams): Promise<PerformSyn
|
||||
}
|
||||
|
||||
const bytes = await plan.generate()
|
||||
let uploaded: UploadResult
|
||||
if (prev?.file_id && prev.file_name === plan.name) {
|
||||
try {
|
||||
uploaded = await updateFile(accessToken, prev.file_id, bytes, plan.contentType)
|
||||
} catch (err) {
|
||||
if (err instanceof DriveFileGoneError) {
|
||||
// The user deleted the file in Drive: recreate it.
|
||||
uploaded = await uploadFile(
|
||||
accessToken,
|
||||
companyFolderId,
|
||||
plan.name,
|
||||
bytes,
|
||||
plan.contentType
|
||||
)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uploaded = await uploadFile(accessToken, companyFolderId, plan.name, bytes, plan.contentType)
|
||||
}
|
||||
const uploaded = await provider.putFile({
|
||||
accessToken,
|
||||
target,
|
||||
name: plan.name,
|
||||
// Only reuse the remote handle when it still belongs to this file name:
|
||||
// a renamed archive (a fiscal year that changed shape) must not
|
||||
// overwrite the file the old name points at.
|
||||
previousId:
|
||||
prev?.file_id && prev.file_name === plan.name ? prev.file_id : undefined,
|
||||
data: bytes,
|
||||
contentType: plan.contentType,
|
||||
})
|
||||
|
||||
stateByKey.set(plan.key, {
|
||||
kind: plan.kind,
|
||||
@@ -471,13 +448,13 @@ export async function performSync(params: PerformSyncParams): Promise<PerformSyn
|
||||
return {
|
||||
ok: true,
|
||||
lastSync,
|
||||
webViewLink: `https://drive.google.com/drive/folders/${companyFolderId}`,
|
||||
webViewLink: target.webViewLink,
|
||||
uploadedCount,
|
||||
skippedCount,
|
||||
}
|
||||
}
|
||||
|
||||
function fileKey(file: DriveFileState): string {
|
||||
function fileKey(file: CloudFileState): string {
|
||||
return file.kind === 'period' ? `period:${file.period_id}` : file.kind
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
"entryPoint": "@/extensions/general/cloud-backup",
|
||||
"workspace": "@/components/extensions/general/CloudBackupWorkspace",
|
||||
"requiredEnvVars": ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"],
|
||||
"optionalEnvVars": [
|
||||
"DROPBOX_APP_KEY",
|
||||
"DROPBOX_APP_SECRET",
|
||||
"DROPBOX_APP_FOLDER_NAME"
|
||||
],
|
||||
"npmDependencies": [],
|
||||
"definition": {
|
||||
"name": "Molnsynkronisering",
|
||||
@@ -13,7 +18,7 @@
|
||||
"dataPattern": "manual",
|
||||
"hasOwnData": true,
|
||||
"description": "Synka säkerhetsbackup till din egen molnlagring",
|
||||
"longDescription": "Koppla ditt Google Drive-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Accounted skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din Drive. Perfekt för att uppfylla egna krav på redundans.",
|
||||
"subscriptionNotice": "Kräver ett Google-konto. Uppladdningar sker direkt till din Drive: ingen data lagras hos tredje part utöver Google."
|
||||
"longDescription": "Koppla ditt Google Drive- eller Dropbox-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Accounted skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din molnlagring. Du kan koppla båda samtidigt, med separata scheman. Perfekt för att uppfylla egna krav på redundans.",
|
||||
"subscriptionNotice": "Kräver ett Google- eller Dropbox-konto. Uppladdningar sker direkt till din egen molnlagring: ingen data lagras hos tredje part utöver den tjänst du väljer."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
/**
|
||||
* Connection stored per company in extension_data under key
|
||||
* `google_drive_connection`. The refresh token is AES-256-GCM encrypted
|
||||
* (see lib/crypto.ts): never store it in plaintext.
|
||||
* Cloud backup storage targets.
|
||||
*
|
||||
* The extension syncs the same archive set to one or more providers. Each
|
||||
* provider owns its own three `extension_data` keys (connection, last sync,
|
||||
* schedule), so connecting Dropbox never touches the Google Drive records and
|
||||
* the two schedules run independently.
|
||||
*/
|
||||
export interface GoogleDriveConnection {
|
||||
export type CloudProviderId = 'google_drive' | 'dropbox'
|
||||
|
||||
/**
|
||||
* Connection stored per company + provider in extension_data (key
|
||||
* `google_drive_connection` / `dropbox_connection`). The refresh token is
|
||||
* AES-256-GCM encrypted (see lib/crypto.ts): never store it in plaintext.
|
||||
*/
|
||||
export interface CloudConnection {
|
||||
refresh_token_encrypted: string
|
||||
/** Account identifier shown in the UI: the account email on both providers. */
|
||||
account_email: string
|
||||
connected_at: string
|
||||
/** ID of the top-level "gnubok" folder in the user's Drive. */
|
||||
/**
|
||||
* Google Drive: ID of the top-level "gnubok" folder in the user's Drive.
|
||||
* Unused on Dropbox (app-folder scoped, so our root IS the app folder).
|
||||
*/
|
||||
root_folder_id: string | null
|
||||
/** ID of the per-company subfolder. */
|
||||
/** Google Drive: ID of the per-company subfolder. Unused on Dropbox. */
|
||||
company_folder_id: string | null
|
||||
/**
|
||||
* Connection health. `needs_reauth` means Google rejected the refresh
|
||||
* Dropbox: app-folder-relative path of the company folder
|
||||
* (`/Testbolag AB (556000-0000)`). Unused on Google Drive.
|
||||
*/
|
||||
company_folder_path?: string | null
|
||||
/**
|
||||
* Connection health. `needs_reauth` means the provider rejected the refresh
|
||||
* token permanently (400 invalid_grant): the cron skips the connection
|
||||
* and the UI asks the user to reconnect. Absent/undefined means active
|
||||
* (records created before this field existed).
|
||||
@@ -22,24 +41,33 @@ export interface GoogleDriveConnection {
|
||||
needs_reauth_at?: string
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link CloudConnection}. Kept for existing call sites. */
|
||||
export type GoogleDriveConnection = CloudConnection
|
||||
|
||||
/**
|
||||
* State of one file in the company's Drive backup folder: an `Arkiv <år>.zip`
|
||||
* State of one file in the company's backup folder: an `Arkiv <år>.zip`
|
||||
* per räkenskapsår, `Grunddata.zip`, and the folder LÄSMIG.txt. Files are
|
||||
* updated in place; `fingerprint` decides whether a sync re-uploads them.
|
||||
*/
|
||||
export interface DriveFileState {
|
||||
export interface CloudFileState {
|
||||
kind: 'period' | 'base' | 'readme'
|
||||
/** Set when kind = 'period'. */
|
||||
period_id?: string
|
||||
/**
|
||||
* Provider-native handle for the stored file: a Drive file id, or the
|
||||
* app-folder-relative path on Dropbox. Opaque to everything but the
|
||||
* provider that wrote it.
|
||||
*/
|
||||
file_id: string
|
||||
file_name: string
|
||||
size_bytes: number
|
||||
/** Change-detection key: the file re-uploads only when this differs. */
|
||||
fingerprint: string
|
||||
/**
|
||||
* SHA-256 of the uploaded bytes. The upload itself is verified against
|
||||
* Drive's md5Checksum; this hash is recorded for evidentiary value (the
|
||||
* user can prove the file in their Drive is the one Accounted produced).
|
||||
* SHA-256 of the uploaded bytes. The upload itself is verified against the
|
||||
* provider's own checksum (Drive md5Checksum, Dropbox content_hash); this
|
||||
* hash is recorded for evidentiary value (the user can prove the file in
|
||||
* their cloud storage is the one Accounted produced).
|
||||
*/
|
||||
sha256: string
|
||||
/** False when the file was built without document blobs (size fallback). */
|
||||
@@ -47,17 +75,31 @@ export interface DriveFileState {
|
||||
uploaded_at: string
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link CloudFileState}. Kept for existing call sites. */
|
||||
export type DriveFileState = CloudFileState
|
||||
|
||||
/**
|
||||
* Last-sync snapshot stored under key `google_drive_last_sync`.
|
||||
* Last-sync snapshot stored under key `google_drive_last_sync` /
|
||||
* `dropbox_last_sync`.
|
||||
*
|
||||
* Current records carry `files` (per-fiscal-year layout). The flat
|
||||
* `file_id`/`file_name`/`file_size_bytes` fields are the legacy single-ZIP
|
||||
* layout, kept optional so old records still render.
|
||||
*/
|
||||
export interface GoogleDriveLastSync {
|
||||
export interface CloudLastSync {
|
||||
at: string
|
||||
/**
|
||||
* Provider-native handle for the company backup folder: a Drive folder id,
|
||||
* or the app-folder-relative path on Dropbox.
|
||||
*/
|
||||
folder_id: string
|
||||
files?: DriveFileState[]
|
||||
/**
|
||||
* Link a human can open to reach the backup folder. Written since the
|
||||
* Dropbox target landed; absent on older Drive records, which the UI
|
||||
* reconstructs from `folder_id`.
|
||||
*/
|
||||
web_view_link?: string
|
||||
files?: CloudFileState[]
|
||||
total_size_bytes?: number
|
||||
// Legacy single-file layout fields.
|
||||
file_id?: string
|
||||
@@ -67,12 +109,17 @@ export interface GoogleDriveLastSync {
|
||||
sha256?: string
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link CloudLastSync}. Kept for existing call sites. */
|
||||
export type GoogleDriveLastSync = CloudLastSync
|
||||
|
||||
/**
|
||||
* Schedule stored under key `google_drive_schedule`. `hour_utc` is 0-23 in UTC;
|
||||
* the UI converts to/from the user's local timezone. Runs once per day at that
|
||||
* hour via a cron route (`app/api/extensions/cloud-backup/auto-sync/cron`).
|
||||
* Schedule stored under key `google_drive_schedule` / `dropbox_schedule`.
|
||||
* Each provider carries its own schedule: a company can back up to Drive
|
||||
* nightly and to Dropbox weekly, or leave one of them off entirely. Runs once
|
||||
* per day at the configured hour via a cron route
|
||||
* (`app/api/extensions/cloud-backup/auto-sync/cron`).
|
||||
*/
|
||||
export interface GoogleDriveSchedule {
|
||||
export interface CloudSchedule {
|
||||
enabled: boolean
|
||||
/**
|
||||
* 0-23, UTC hour when the daily auto-sync should run. Legacy field: kept
|
||||
@@ -100,16 +147,41 @@ export interface GoogleDriveSchedule {
|
||||
last_alert_at?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Status returned to the UI. Mirrors the storage shapes above in a
|
||||
* shape safe to expose to the client (no encrypted token).
|
||||
*/
|
||||
export interface CloudBackupStatus {
|
||||
/** @deprecated Use {@link CloudSchedule}. Kept for existing call sites. */
|
||||
export type GoogleDriveSchedule = CloudSchedule
|
||||
|
||||
/** Per-provider status block returned to the UI. */
|
||||
export interface CloudProviderStatus {
|
||||
provider: CloudProviderId
|
||||
/**
|
||||
* False when the deployment has no OAuth credentials for this provider.
|
||||
* The UI renders the row disabled rather than letting the user start a
|
||||
* flow that can only fail.
|
||||
*/
|
||||
configured: boolean
|
||||
connected: boolean
|
||||
/** True when the stored Google refresh token is dead and the user must reconnect. */
|
||||
/** True when the stored refresh token is dead and the user must reconnect. */
|
||||
needs_reauth: boolean
|
||||
account_email: string | null
|
||||
connected_at: string | null
|
||||
last_sync: GoogleDriveLastSync | null
|
||||
schedule: GoogleDriveSchedule | null
|
||||
last_sync: CloudLastSync | null
|
||||
schedule: CloudSchedule | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Status returned to the UI. Mirrors the storage shapes above in a
|
||||
* shape safe to expose to the client (no encrypted token).
|
||||
*
|
||||
* The top-level fields describe Google Drive and predate multi-provider
|
||||
* support. They are kept so already-deployed clients (and the dashboard
|
||||
* health banner) keep working; new code reads `providers`.
|
||||
*/
|
||||
export interface CloudBackupStatus {
|
||||
providers: CloudProviderStatus[]
|
||||
connected: boolean
|
||||
needs_reauth: boolean
|
||||
account_email: string | null
|
||||
connected_at: string | null
|
||||
last_sync: CloudLastSync | null
|
||||
schedule: CloudSchedule | null
|
||||
}
|
||||
|
||||
+2
-2
@@ -76,9 +76,9 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
"icon": "Cloud",
|
||||
"dataPattern": "manual",
|
||||
"description": "Synka säkerhetsbackup till din egen molnlagring",
|
||||
"longDescription": "Koppla ditt Google Drive-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Accounted skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din Drive. Perfekt för att uppfylla egna krav på redundans.",
|
||||
"longDescription": "Koppla ditt Google Drive- eller Dropbox-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Accounted skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din molnlagring. Du kan koppla båda samtidigt, med separata scheman. Perfekt för att uppfylla egna krav på redundans.",
|
||||
"hasOwnData": true,
|
||||
"subscriptionNotice": "Kräver ett Google-konto. Uppladdningar sker direkt till din Drive: ingen data lagras hos tredje part utöver Google."
|
||||
"subscriptionNotice": "Kräver ett Google- eller Dropbox-konto. Uppladdningar sker direkt till din egen molnlagring: ingen data lagras hos tredje part utöver den tjänst du väljer."
|
||||
},
|
||||
{
|
||||
"slug": "skatteverket",
|
||||
|
||||
+18
-18
@@ -387,9 +387,7 @@
|
||||
"transaction_sync_enabled_toast": "Transaction sync enabled. History is fetched on the next sync.",
|
||||
"transaction_sync_disabled_toast": "Transaction sync disabled.",
|
||||
"transaction_sync_toggle_failed": "Could not save the setting. Please try again.",
|
||||
"sync_done_transactions": "{imported} transaction(s) imported, {linked} linked to vouchers.",
|
||||
"sync_done_feed": "{fetched} transaction(s) fetched: {imported} new in the inbox, {linked} linked to vouchers.",
|
||||
"sync_done_empty": "Stripe returned no transactions for the period. If you expected transactions, check that the right account is connected."
|
||||
"sync_done_transactions": "{imported} transaction(s) imported, {linked} linked to vouchers."
|
||||
},
|
||||
"settings_modal": {
|
||||
"title": "Settings",
|
||||
@@ -4509,19 +4507,20 @@
|
||||
"ext_mcp_server_long_description": "Exposes accounted's bookkeeping engine as MCP tools (Model Context Protocol). Connect your MCP client with an API key and do bookkeeping through conversation: list uncategorized transactions, book them, create invoices.",
|
||||
"ext_cloud_backup_name": "Cloud sync",
|
||||
"ext_cloud_backup_description": "Sync backups to your own cloud storage",
|
||||
"ext_cloud_backup_long_description": "Connect your Google Drive account and upload a full backup with one click. Accounted creates a ZIP with SIE files, receipts and processing history and uploads it to a dedicated folder in your Drive.",
|
||||
"ext_cloud_backup_reauth_title": "Google Drive needs to be reconnected",
|
||||
"ext_cloud_backup_reauth_description": "Access to your Google account has expired or been revoked. Automatic sync is paused until you reconnect the account.",
|
||||
"ext_cloud_backup_reauth_action": "Reconnect Google Drive",
|
||||
"ext_cloud_backup_long_description": "Connect your Google Drive or Dropbox account and upload a full backup with one click. Accounted creates a ZIP with SIE files, receipts and processing history and uploads it to a dedicated folder in your cloud storage.",
|
||||
"ext_cloud_backup_reauth_title": "{provider} needs to be reconnected",
|
||||
"ext_cloud_backup_reauth_description": "Access to your {provider} account has expired or been revoked. Automatic sync is paused until you reconnect the account.",
|
||||
"ext_cloud_backup_reauth_action": "Reconnect {provider}",
|
||||
"ext_cloud_backup_reauth_needed_short": "reconnect required",
|
||||
"ext_cloud_backup_too_large_title": "Backup too large",
|
||||
"ext_cloud_backup_too_large_description": "The archive including documents is {size} MB, above the {limit} MB limit. You can back up the bookkeeping without document files (receipts and attachments).",
|
||||
"ext_cloud_backup_too_large_confirm": "Sync without documents",
|
||||
"ext_cloud_backup_no_documents_note": "without documents",
|
||||
"ext_cloud_backup_last_sync_no_documents": "Latest copy was made without document files",
|
||||
"ext_cloud_backup_card_tagline": "Backup to your own Drive.",
|
||||
"ext_cloud_backup_legal_note": "The Drive copy is an extra backup. The legal archive (7 years under the Swedish Bookkeeping Act) remains in Accounted.",
|
||||
"ext_cloud_backup_card_tagline": "Backup to your own {provider}.",
|
||||
"ext_cloud_backup_legal_note": "The {provider} copy is an extra backup. The legal archive (7 years under the Swedish Bookkeeping Act) remains in Accounted.",
|
||||
"ext_cloud_backup_loading": "Loading…",
|
||||
"ext_cloud_backup_not_configured": "{provider} is not configured in this environment.",
|
||||
"ext_cloud_backup_account_label": "Account",
|
||||
"ext_cloud_backup_last_sync_label": "Last sync",
|
||||
"ext_cloud_backup_never": "Never",
|
||||
@@ -4530,29 +4529,30 @@
|
||||
"ext_cloud_backup_disconnect": "Disconnect",
|
||||
"ext_cloud_backup_disconnecting": "Disconnecting…",
|
||||
"ext_cloud_backup_redirecting": "Redirecting…",
|
||||
"ext_cloud_backup_connect": "Connect Google Drive",
|
||||
"ext_cloud_backup_connect_description": "Connect your Google account to upload the backup to your own Drive. Accounted only gets access to files the app itself creates (scope drive.file).",
|
||||
"ext_cloud_backup_connected_title": "Google Drive connected",
|
||||
"ext_cloud_backup_connect": "Connect {provider}",
|
||||
"ext_cloud_backup_connect_description_google": "Connect your Google account to upload the backup to your own Drive. Accounted only gets access to files the app itself creates (scope drive.file).",
|
||||
"ext_cloud_backup_connect_description_dropbox": "Connect your Dropbox account to upload the backup to your own Dropbox. Accounted gets its own app folder and cannot reach anything else in your Dropbox.",
|
||||
"ext_cloud_backup_connected_title": "{provider} connected",
|
||||
"ext_cloud_backup_connected_description": "A backup is being created in the background.",
|
||||
"ext_cloud_backup_connected_first_description": "Daily backup is enabled. The first copy is being created in the background.",
|
||||
"ext_cloud_backup_connect_failed": "Could not connect Google Drive",
|
||||
"ext_cloud_backup_connect_failed": "Could not connect {provider}",
|
||||
"ext_cloud_backup_unknown_error": "Unknown error",
|
||||
"ext_cloud_backup_disconnected": "Google Drive disconnected",
|
||||
"ext_cloud_backup_disconnected": "{provider} disconnected",
|
||||
"ext_cloud_backup_disconnect_failed": "Could not disconnect",
|
||||
"ext_cloud_backup_uploaded": "Uploaded to Google Drive",
|
||||
"ext_cloud_backup_uploaded": "Uploaded to {provider}",
|
||||
"ext_cloud_backup_sync_failed": "Sync failed",
|
||||
"ext_cloud_backup_try_again": "Try again.",
|
||||
"ext_cloud_backup_status_failed": "Could not fetch status",
|
||||
"ext_cloud_backup_connect_start_failed": "Could not start the connection",
|
||||
"ext_cloud_backup_auto_sync_title": "Automatic sync",
|
||||
"ext_cloud_backup_auto_sync_description": "Daily backup to your Drive.",
|
||||
"ext_cloud_backup_auto_sync_description": "Daily backup to {provider}.",
|
||||
"ext_cloud_backup_time_label": "Time (Swedish time)",
|
||||
"ext_cloud_backup_last_auto_sync": "Last automatic sync:",
|
||||
"ext_cloud_backup_auto_sync_success": "succeeded",
|
||||
"ext_cloud_backup_auto_sync_error": "failed",
|
||||
"ext_cloud_backup_schedule_save_failed": "Could not save schedule",
|
||||
"ext_cloud_backup_banner_reauth": "The Google Drive backup is paused: the account needs to be reconnected.",
|
||||
"ext_cloud_backup_banner_failing": "The automatic Google Drive backup is failing.",
|
||||
"ext_cloud_backup_banner_reauth": "The {provider} backup is paused: the account needs to be reconnected.",
|
||||
"ext_cloud_backup_banner_failing": "The automatic {provider} backup is failing.",
|
||||
"ext_cloud_backup_banner_action": "Open backup settings",
|
||||
"ext_cloud_backup_verified": "Verified",
|
||||
"ext_cloud_backup_up_to_date": "Already up to date",
|
||||
|
||||
+18
-18
@@ -387,9 +387,7 @@
|
||||
"transaction_sync_enabled_toast": "Transaktionssynk aktiverad. Historiken hämtas vid nästa synkning.",
|
||||
"transaction_sync_disabled_toast": "Transaktionssynk avaktiverad.",
|
||||
"transaction_sync_toggle_failed": "Kunde inte spara inställningen. Försök igen.",
|
||||
"sync_done_transactions": "{imported} transaktion(er) importerade, {linked} länkade till verifikat.",
|
||||
"sync_done_feed": "{fetched} transaktion(er) hämtade: {imported} nya i inkorgen, {linked} länkade till verifikat.",
|
||||
"sync_done_empty": "Stripe returnerade inga transaktioner för perioden. Kontrollera att rätt konto är anslutet om du väntade dig transaktioner."
|
||||
"sync_done_transactions": "{imported} transaktion(er) importerade, {linked} länkade till verifikat."
|
||||
},
|
||||
"settings_modal": {
|
||||
"title": "Inställningar",
|
||||
@@ -4509,19 +4507,20 @@
|
||||
"ext_mcp_server_long_description": "Exponerar accounteds bokföringsmotor som MCP-verktyg (Model Context Protocol). Koppla din MCP-klient med en API-nyckel och gör bokföring genom konversation: visa okategoriserade transaktioner, bokför dem, skapa fakturor.",
|
||||
"ext_cloud_backup_name": "Molnsynkronisering",
|
||||
"ext_cloud_backup_description": "Synka säkerhetsbackup till din egen molnlagring",
|
||||
"ext_cloud_backup_long_description": "Koppla ditt Google Drive-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Accounted skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din Drive.",
|
||||
"ext_cloud_backup_reauth_title": "Google Drive behöver kopplas om",
|
||||
"ext_cloud_backup_reauth_description": "Åtkomsten till ditt Google-konto har gått ut eller återkallats. Automatisk synkronisering är pausad tills du kopplar om kontot.",
|
||||
"ext_cloud_backup_reauth_action": "Koppla om Google Drive",
|
||||
"ext_cloud_backup_long_description": "Koppla ditt Google Drive- eller Dropbox-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Accounted skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din molnlagring.",
|
||||
"ext_cloud_backup_reauth_title": "{provider} behöver kopplas om",
|
||||
"ext_cloud_backup_reauth_description": "Åtkomsten till ditt {provider}-konto har gått ut eller återkallats. Automatisk synkronisering är pausad tills du kopplar om kontot.",
|
||||
"ext_cloud_backup_reauth_action": "Koppla om {provider}",
|
||||
"ext_cloud_backup_reauth_needed_short": "kräver omkoppling",
|
||||
"ext_cloud_backup_too_large_title": "Säkerhetskopian är för stor",
|
||||
"ext_cloud_backup_too_large_description": "Arkivet med underlag är {size} MB, över gränsen på {limit} MB. Du kan säkerhetskopiera bokföringen utan underlagsfiler (kvitton och dokument).",
|
||||
"ext_cloud_backup_too_large_confirm": "Synka utan underlag",
|
||||
"ext_cloud_backup_no_documents_note": "utan underlag",
|
||||
"ext_cloud_backup_last_sync_no_documents": "Senaste kopian togs utan underlagsfiler",
|
||||
"ext_cloud_backup_card_tagline": "Säkerhetskopia till din egen Drive.",
|
||||
"ext_cloud_backup_legal_note": "Drive-kopian är en extra säkerhetskopia. Det lagliga arkivet (7 år enligt bokföringslagen) finns kvar i Accounted.",
|
||||
"ext_cloud_backup_card_tagline": "Säkerhetskopia till din egen {provider}.",
|
||||
"ext_cloud_backup_legal_note": "Kopian i {provider} är en extra säkerhetskopia. Det lagliga arkivet (7 år enligt bokföringslagen) finns kvar i Accounted.",
|
||||
"ext_cloud_backup_loading": "Laddar…",
|
||||
"ext_cloud_backup_not_configured": "{provider} är inte konfigurerat i den här miljön.",
|
||||
"ext_cloud_backup_account_label": "Konto",
|
||||
"ext_cloud_backup_last_sync_label": "Senaste synk",
|
||||
"ext_cloud_backup_never": "Aldrig",
|
||||
@@ -4530,29 +4529,30 @@
|
||||
"ext_cloud_backup_disconnect": "Koppla bort",
|
||||
"ext_cloud_backup_disconnecting": "Kopplar bort…",
|
||||
"ext_cloud_backup_redirecting": "Omdirigerar…",
|
||||
"ext_cloud_backup_connect": "Koppla Google Drive",
|
||||
"ext_cloud_backup_connect_description": "Koppla ditt Google-konto för att ladda upp säkerhetsbackupen till din egen Drive. Accounted får bara tillgång till filer som appen själv skapar (scope drive.file).",
|
||||
"ext_cloud_backup_connected_title": "Google Drive kopplat",
|
||||
"ext_cloud_backup_connect": "Koppla {provider}",
|
||||
"ext_cloud_backup_connect_description_google": "Koppla ditt Google-konto för att ladda upp säkerhetsbackupen till din egen Drive. Accounted får bara tillgång till filer som appen själv skapar (scope drive.file).",
|
||||
"ext_cloud_backup_connect_description_dropbox": "Koppla ditt Dropbox-konto för att ladda upp säkerhetsbackupen till din egen Dropbox. Accounted får en egen appmapp och kommer inte åt något annat i din Dropbox.",
|
||||
"ext_cloud_backup_connected_title": "{provider} kopplat",
|
||||
"ext_cloud_backup_connected_description": "En säkerhetskopia skapas nu i bakgrunden.",
|
||||
"ext_cloud_backup_connected_first_description": "Daglig säkerhetskopiering är aktiverad. Den första kopian skapas nu i bakgrunden.",
|
||||
"ext_cloud_backup_connect_failed": "Kunde inte koppla Google Drive",
|
||||
"ext_cloud_backup_connect_failed": "Kunde inte koppla {provider}",
|
||||
"ext_cloud_backup_unknown_error": "Okänt fel",
|
||||
"ext_cloud_backup_disconnected": "Google Drive bortkopplat",
|
||||
"ext_cloud_backup_disconnected": "{provider} bortkopplat",
|
||||
"ext_cloud_backup_disconnect_failed": "Kunde inte koppla bort",
|
||||
"ext_cloud_backup_uploaded": "Uppladdad till Google Drive",
|
||||
"ext_cloud_backup_uploaded": "Uppladdad till {provider}",
|
||||
"ext_cloud_backup_sync_failed": "Synkningen misslyckades",
|
||||
"ext_cloud_backup_try_again": "Försök igen.",
|
||||
"ext_cloud_backup_status_failed": "Kunde inte hämta status",
|
||||
"ext_cloud_backup_connect_start_failed": "Kunde inte starta anslutning",
|
||||
"ext_cloud_backup_auto_sync_title": "Automatisk synkronisering",
|
||||
"ext_cloud_backup_auto_sync_description": "Daglig säkerhetsbackup till din Drive.",
|
||||
"ext_cloud_backup_auto_sync_description": "Daglig säkerhetsbackup till {provider}.",
|
||||
"ext_cloud_backup_time_label": "Tid (svensk tid)",
|
||||
"ext_cloud_backup_last_auto_sync": "Senaste automatiska synk:",
|
||||
"ext_cloud_backup_auto_sync_success": "lyckades",
|
||||
"ext_cloud_backup_auto_sync_error": "misslyckades",
|
||||
"ext_cloud_backup_schedule_save_failed": "Kunde inte spara schema",
|
||||
"ext_cloud_backup_banner_reauth": "Säkerhetskopieringen till Google Drive är pausad: kontot behöver kopplas om.",
|
||||
"ext_cloud_backup_banner_failing": "Den automatiska säkerhetskopieringen till Google Drive misslyckas.",
|
||||
"ext_cloud_backup_banner_reauth": "Säkerhetskopieringen till {provider} är pausad: kontot behöver kopplas om.",
|
||||
"ext_cloud_backup_banner_failing": "Den automatiska säkerhetskopieringen till {provider} misslyckas.",
|
||||
"ext_cloud_backup_banner_action": "Öppna säkerhetskopiering",
|
||||
"ext_cloud_backup_verified": "Verifierad",
|
||||
"ext_cloud_backup_up_to_date": "Redan uppdaterad",
|
||||
|
||||
Reference in New Issue
Block a user