Files
accounted/extensions/general/cloud-backup/lib/google-provider.ts
T
Mattsson fbd4b992f5 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>
2026-07-27 16:49:24 +02:00

149 lines
4.7 KiB
TypeScript

/**
* 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)
},
}