Files
accounted/extensions/general/cloud-backup/lib/dropbox-provider.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

149 lines
5.0 KiB
TypeScript

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