Feat/cloud backup (#277)
* feat: cloud backup to Google Drive + full-archive all-scope Adds a cloud-backup extension that uploads a full-company backup ZIP to the user's own Google Drive via OAuth (drive.file scope only). Refresh tokens are AES-256-GCM encrypted before being stored in extension_data. The full-archive export gains a scope=all mode for whole-company backups (per-period SIE under sie/, per-period rapporter/ subfolders, flat dokument/ manifest tagged with fiscal_period_id). An 80 MB size guard short-circuits generation before the platform response limit. Also fixes a latent bug in lib/core/audit/audit-service.ts where the parameter was named userId while the query filtered by company_id; the audit-trail API route was passing user.id so audit queries returned empty unless user and company shared a UUID. Drive-by: scope the dashboard "fresh start" localStorage key per companyId so dismissing the setup checklist in one company no longer carries over to others. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address review comments on cloud backup + archive export - Extend audit trail to_date to end-of-day so last-day entries aren't silently excluded from period-scoped archives. - Apply 413 size-limit guard regardless of include_documents, using the overhead-only figure when documents are excluded. - Use crypto.randomUUID() for Drive multipart boundary to eliminate any collision risk with ZIP payload bytes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: migrate legacy setup-gate localStorage keys on dashboard Users who previously dismissed the setup checklist via the old global erp_setup_fresh_start or erp_checklist_dismissed keys were re-gated after the switch to a company-scoped key. Fall back to the legacy keys on read and migrate them to the scoped key on first hit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: update customer email handling and anonymization rules in supportmail-to-ticket skill * test: update audit trail to_date expectation for end-of-day timestamp Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
2ea5a72b3d
commit
d708a85d4c
@@ -0,0 +1,263 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Cloud, ExternalLink, Loader2, RefreshCw, Unplug } from 'lucide-react'
|
||||
import type { CloudBackupStatus } from '../types'
|
||||
|
||||
const API_BASE = '/api/extensions/ext/cloud-backup'
|
||||
|
||||
export default function CloudBackupCard() {
|
||||
const { toast } = useToast()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
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 {
|
||||
const res = await fetch(`${API_BASE}/status`)
|
||||
if (!res.ok) throw new Error('Kunde inte hämta status')
|
||||
const { data } = (await res.json()) as { data: CloudBackupStatus }
|
||||
setStatus(data)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus()
|
||||
}, [loadStatus])
|
||||
|
||||
// Handle OAuth callback redirect params.
|
||||
useEffect(() => {
|
||||
const result = searchParams.get('cloud_backup')
|
||||
if (!result) return
|
||||
if (result === 'connected') {
|
||||
toast({ title: 'Google Drive kopplat', description: 'Du kan nu synka till din Drive.' })
|
||||
} else if (result === 'error') {
|
||||
const reason = searchParams.get('reason') || 'Okänt fel'
|
||||
toast({
|
||||
title: 'Kunde inte koppla Google Drive',
|
||||
description: reason,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
// 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('reason')
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}, [searchParams, toast])
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
setIsConnecting(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/connect`, { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || 'Kunde inte starta anslutning')
|
||||
}
|
||||
const { url } = (await res.json()) as { url: string }
|
||||
window.location.href = url
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kunde inte koppla Google Drive',
|
||||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
setIsDisconnecting(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/disconnect`, { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || 'Kunde inte koppla bort')
|
||||
}
|
||||
toast({ title: 'Google Drive bortkopplat' })
|
||||
await loadStatus()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kunde inte koppla bort',
|
||||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsDisconnecting(false)
|
||||
}
|
||||
}, [loadStatus, toast])
|
||||
|
||||
const handleSync = useCallback(async () => {
|
||||
setIsSyncing(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/sync`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ include_documents: true }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (res.status === 413) {
|
||||
const mb = body.size_bytes
|
||||
? Math.round(body.size_bytes / (1024 * 1024))
|
||||
: null
|
||||
throw new Error(
|
||||
mb
|
||||
? `Arkivet är ${mb} MB — större än nuvarande gräns. Minska omfattning eller avvakta bakgrundssynk.`
|
||||
: 'Arkivet är för stort för direktsynk.'
|
||||
)
|
||||
}
|
||||
throw new Error(body.error || 'Synkningen misslyckades')
|
||||
}
|
||||
const { data } = (await res.json()) as {
|
||||
data: { file_name: string; file_size_bytes: number; web_view_link: string }
|
||||
}
|
||||
toast({
|
||||
title: 'Uppladdad till Google Drive',
|
||||
description: `${data.file_name} (${formatMb(data.file_size_bytes)})`,
|
||||
})
|
||||
await loadStatus()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Synkningen misslyckades',
|
||||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSyncing(false)
|
||||
}
|
||||
}, [loadStatus, toast])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Cloud className="h-4 w-4 text-muted-foreground" />
|
||||
Google Drive
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Laddar…</p>
|
||||
) : status?.connected ? (
|
||||
<>
|
||||
<div className="text-sm">
|
||||
<p>
|
||||
Ansluten som <span className="font-medium">{status.account_email}</span>
|
||||
</p>
|
||||
{status.connected_at && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kopplat {formatDate(status.connected_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status.last_sync ? (
|
||||
<div className="rounded-md border border-border/60 bg-muted/30 p-3 text-sm">
|
||||
<p>
|
||||
Senaste synk: <span className="font-medium">{status.last_sync.file_name}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(status.last_sync.at)} · {formatMb(status.last_sync.file_size_bytes)}
|
||||
</p>
|
||||
<a
|
||||
href={`https://drive.google.com/file/d/${status.last_sync.file_id}/view`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
Öppna i Drive
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ingen synk än — kör “Synka nu” för att ladda upp första arkivet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button onClick={handleSync} disabled={isSyncing}>
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Synkar…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Synka nu
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDisconnect}
|
||||
disabled={isDisconnecting}
|
||||
>
|
||||
{isDisconnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Kopplar bort…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Unplug className="mr-2 h-4 w-4" />
|
||||
Koppla bort
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Koppla ditt Google-konto för att ladda upp säkerhetsbackupen till din egen
|
||||
Drive. gnubok får bara tillgång till filer som appen själv skapar (scope
|
||||
<span className="font-mono text-xs"> drive.file</span>).
|
||||
</p>
|
||||
<Button onClick={handleConnect} disabled={isConnecting}>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Omdirigerar…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
Koppla Google Drive
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function formatMb(bytes: number): string {
|
||||
const mb = bytes / (1024 * 1024)
|
||||
return `${mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('sv-SE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
generateFullArchive,
|
||||
estimateArchiveSize,
|
||||
type ArchiveScope,
|
||||
} from '@/lib/reports/full-archive-export'
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
exchangeCodeForTokens,
|
||||
fetchUserEmail,
|
||||
getOAuthEnv,
|
||||
refreshAccessToken,
|
||||
revokeToken,
|
||||
} from './lib/google-oauth'
|
||||
import { ensureFolder, uploadFile } from './lib/google-drive'
|
||||
import {
|
||||
createOAuthState,
|
||||
decryptToken,
|
||||
encryptToken,
|
||||
verifyOAuthState,
|
||||
} from './lib/crypto'
|
||||
import type {
|
||||
CloudBackupStatus,
|
||||
GoogleDriveConnection,
|
||||
GoogleDriveLastSync,
|
||||
} from './types'
|
||||
|
||||
const CONNECTION_KEY = 'google_drive_connection'
|
||||
const LAST_SYNC_KEY = 'google_drive_last_sync'
|
||||
const ROOT_FOLDER_NAME = 'gnubok'
|
||||
const SIZE_LIMIT_BYTES = 80 * 1024 * 1024
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
async function getFreshAccessToken(
|
||||
ctx: ExtensionContext,
|
||||
connection: GoogleDriveConnection
|
||||
): Promise<string> {
|
||||
const origin = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
const env = getOAuthEnv(origin)
|
||||
const refreshToken = decryptToken(connection.refresh_token_encrypted)
|
||||
const { access_token } = await refreshAccessToken(env, refreshToken)
|
||||
return access_token
|
||||
}
|
||||
|
||||
async function fetchCompanyName(ctx: ExtensionContext): Promise<string> {
|
||||
const { data } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, org_number')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.single()
|
||||
const name = (data?.company_name as string) || 'företag'
|
||||
const org = (data?.org_number as string) || ctx.companyId.slice(0, 8)
|
||||
return `${name} (${org})`.replace(/[\\/]/g, '-')
|
||||
}
|
||||
|
||||
export const cloudBackupExtension: Extension = {
|
||||
id: 'cloud-backup',
|
||||
name: 'Molnsynkronisering',
|
||||
version: '1.0.0',
|
||||
sector: 'general',
|
||||
|
||||
settingsPanel: {
|
||||
label: 'Molnsynkronisering',
|
||||
path: '/settings/backup',
|
||||
},
|
||||
|
||||
apiRoutes: [
|
||||
// Kick off OAuth: return the Google consent URL.
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/connect',
|
||||
handler: async (request, ctx) => {
|
||||
if (!ctx) return jsonError('Missing context', 500)
|
||||
try {
|
||||
const origin = new URL(request.url).origin
|
||||
const env = getOAuthEnv(origin)
|
||||
const state = createOAuthState(ctx.userId, ctx.companyId)
|
||||
const url = buildAuthorizationUrl(env, state)
|
||||
return NextResponse.json({ url })
|
||||
} catch (err) {
|
||||
ctx.log.error('connect failed', err)
|
||||
return jsonError(
|
||||
err instanceof Error ? err.message : 'Could not start OAuth',
|
||||
500
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Google redirects here after the user consents.
|
||||
{
|
||||
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)
|
||||
return redirect('connected')
|
||||
} catch (err) {
|
||||
ctx.log.error('oauth callback failed', err)
|
||||
return redirect(
|
||||
'error',
|
||||
err instanceof Error ? err.message.slice(0, 80) : 'exchange_failed'
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Revoke the refresh token and clear the stored connection.
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/disconnect',
|
||||
handler: async (_request, ctx) => {
|
||||
if (!ctx) return jsonError('Missing context', 500)
|
||||
try {
|
||||
const connection = await loadConnection(ctx)
|
||||
if (connection) {
|
||||
try {
|
||||
const refreshToken = decryptToken(connection.refresh_token_encrypted)
|
||||
await revokeToken(refreshToken)
|
||||
} catch (err) {
|
||||
ctx.log.warn('token revoke failed (continuing)', err)
|
||||
}
|
||||
}
|
||||
await ctx.settings.set(CONNECTION_KEY, null)
|
||||
await ctx.settings.set(LAST_SYNC_KEY, null)
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (err) {
|
||||
ctx.log.error('disconnect failed', err)
|
||||
return jsonError(
|
||||
err instanceof Error ? err.message : 'Disconnect failed',
|
||||
500
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Read-only status used by the UI to show connected/last-sync info.
|
||||
{
|
||||
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 status: CloudBackupStatus = {
|
||||
connected: !!connection,
|
||||
account_email: connection?.account_email ?? null,
|
||||
connected_at: connection?.connected_at ?? null,
|
||||
last_sync: lastSync ?? null,
|
||||
}
|
||||
return NextResponse.json({ data: status })
|
||||
},
|
||||
},
|
||||
|
||||
// Generate an archive and upload it to Drive. Returns the Drive file info.
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/sync',
|
||||
handler: async (request, ctx) => {
|
||||
if (!ctx) return jsonError('Missing context', 500)
|
||||
try {
|
||||
const connection = await loadConnection(ctx)
|
||||
if (!connection) {
|
||||
return jsonError('not_connected', 400)
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
include_documents?: boolean
|
||||
}
|
||||
const scope: ArchiveScope = 'all'
|
||||
const includeDocuments = body.include_documents !== false
|
||||
|
||||
const estimate = await estimateArchiveSize(ctx.supabase, ctx.companyId, scope)
|
||||
const effectiveBytes = includeDocuments
|
||||
? estimate.total_bytes
|
||||
: estimate.total_bytes - estimate.document_bytes
|
||||
if (effectiveBytes > SIZE_LIMIT_BYTES) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'archive_too_large',
|
||||
size_bytes: effectiveBytes,
|
||||
size_limit_bytes: SIZE_LIMIT_BYTES,
|
||||
},
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
|
||||
const accessToken = await getFreshAccessToken(ctx, connection)
|
||||
|
||||
// Ensure folder structure. Persist ids on first sync so later runs skip the lookup.
|
||||
let rootFolderId = connection.root_folder_id
|
||||
let companyFolderId = connection.company_folder_id
|
||||
if (!rootFolderId) {
|
||||
const root = await ensureFolder(accessToken, ROOT_FOLDER_NAME, null)
|
||||
rootFolderId = root.id
|
||||
}
|
||||
if (!companyFolderId) {
|
||||
const companyName = await fetchCompanyName(ctx)
|
||||
const companyFolder = await ensureFolder(
|
||||
accessToken,
|
||||
companyName,
|
||||
rootFolderId
|
||||
)
|
||||
companyFolderId = companyFolder.id
|
||||
}
|
||||
if (
|
||||
rootFolderId !== connection.root_folder_id ||
|
||||
companyFolderId !== connection.company_folder_id
|
||||
) {
|
||||
await ctx.settings.set(CONNECTION_KEY, {
|
||||
...connection,
|
||||
root_folder_id: rootFolderId,
|
||||
company_folder_id: companyFolderId,
|
||||
})
|
||||
}
|
||||
|
||||
const archive = await generateFullArchive(ctx.supabase, ctx.companyId, {
|
||||
scope: 'all',
|
||||
include_documents: includeDocuments,
|
||||
})
|
||||
|
||||
const stamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\..+/, '')
|
||||
const fileName = `arkiv_full_${stamp}.zip`
|
||||
|
||||
const uploaded = await uploadFile(
|
||||
accessToken,
|
||||
companyFolderId,
|
||||
fileName,
|
||||
archive
|
||||
)
|
||||
|
||||
const lastSync: GoogleDriveLastSync = {
|
||||
at: new Date().toISOString(),
|
||||
file_id: uploaded.id,
|
||||
file_name: uploaded.name,
|
||||
file_size_bytes: uploaded.size_bytes,
|
||||
folder_id: companyFolderId,
|
||||
}
|
||||
await ctx.settings.set(LAST_SYNC_KEY, lastSync)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...lastSync,
|
||||
web_view_link: uploaded.web_view_link,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
ctx.log.error('sync failed', err)
|
||||
return jsonError(
|
||||
err instanceof Error ? err.message : 'Sync failed',
|
||||
500
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
encryptToken,
|
||||
decryptToken,
|
||||
createOAuthState,
|
||||
verifyOAuthState,
|
||||
} from '../crypto'
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-role-key-that-is-long-enough'
|
||||
})
|
||||
|
||||
describe('token encryption', () => {
|
||||
it('round-trips a refresh token', () => {
|
||||
const token = '1//0abcdef_refresh_token_value'
|
||||
const encrypted = encryptToken(token)
|
||||
expect(encrypted).not.toContain(token)
|
||||
expect(decryptToken(encrypted)).toBe(token)
|
||||
})
|
||||
|
||||
it('produces different ciphertext for the same plaintext (IV is random)', () => {
|
||||
const token = 'same-token'
|
||||
const a = encryptToken(token)
|
||||
const b = encryptToken(token)
|
||||
expect(a).not.toBe(b)
|
||||
expect(decryptToken(a)).toBe(token)
|
||||
expect(decryptToken(b)).toBe(token)
|
||||
})
|
||||
|
||||
it('fails to decrypt tampered ciphertext', () => {
|
||||
const encrypted = encryptToken('secret')
|
||||
// Flip a byte in the middle of the ciphertext.
|
||||
const buf = Buffer.from(encrypted, 'base64url')
|
||||
buf[30] ^= 0xff
|
||||
const tampered = buf.toString('base64url')
|
||||
expect(() => decryptToken(tampered)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuth state', () => {
|
||||
it('round-trips userId and companyId', () => {
|
||||
const state = createOAuthState('user-1', 'company-1')
|
||||
expect(verifyOAuthState(state)).toEqual({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for garbage state', () => {
|
||||
expect(verifyOAuthState('not-a-valid-state')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for expired state', () => {
|
||||
const state = createOAuthState('user-1', 'company-1')
|
||||
// Fast-forward past the 10-minute TTL.
|
||||
const realNow = Date.now
|
||||
Date.now = () => realNow() + 11 * 60 * 1000
|
||||
try {
|
||||
expect(verifyOAuthState(state)).toBeNull()
|
||||
} finally {
|
||||
Date.now = realNow
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { ensureFolder, uploadFile } from '../google-drive'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('ensureFolder', () => {
|
||||
it('returns existing folder when found', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ files: [{ id: 'folder-1', name: 'gnubok' }] }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
const folder = await ensureFolder('at', 'gnubok', null)
|
||||
expect(folder.id).toBe('folder-1')
|
||||
// Only the search call; no create needed.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const url = fetchMock.mock.calls[0][0] as string
|
||||
expect(url).toContain('/files?q=')
|
||||
expect(decodeURIComponent(url)).toContain(`name = 'gnubok'`)
|
||||
expect(decodeURIComponent(url)).toContain(`'root' in parents`)
|
||||
})
|
||||
|
||||
it('creates a new folder when none exists', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ files: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ id: 'new-id', name: 'gnubok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
const folder = await ensureFolder('at', 'gnubok', null)
|
||||
expect(folder.id).toBe('new-id')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
const createCall = fetchMock.mock.calls[1]
|
||||
expect((createCall[1] as RequestInit).method).toBe('POST')
|
||||
const body = JSON.parse(String((createCall[1] as RequestInit).body))
|
||||
expect(body.mimeType).toBe('application/vnd.google-apps.folder')
|
||||
expect(body.name).toBe('gnubok')
|
||||
})
|
||||
|
||||
it('escapes single quotes in folder name and scopes to parent', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
// findFolderByName → match so we never hit create.
|
||||
new Response(JSON.stringify({ files: [{ id: 'x', name: "Kalle's" }] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
await ensureFolder('at', "Kalle's", 'parent-id')
|
||||
const searchUrl = decodeURIComponent(fetchMock.mock.calls[0][0] as string)
|
||||
expect(searchUrl).toContain(`name = 'Kalle\\'s'`)
|
||||
expect(searchUrl).toContain(`'parent-id' in parents`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadFile', () => {
|
||||
it('posts multipart body with metadata + binary and returns parsed result', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: 'file-123',
|
||||
name: 'arkiv.zip',
|
||||
size: '2048',
|
||||
webViewLink: 'https://drive.google.com/file/d/file-123/view',
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]).buffer
|
||||
const result = await uploadFile('access-tok', 'folder-1', 'arkiv.zip', data)
|
||||
|
||||
expect(result.id).toBe('file-123')
|
||||
expect(result.size_bytes).toBe(2048)
|
||||
expect(result.web_view_link).toContain('file-123')
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toContain('uploadType=multipart')
|
||||
const contentType = (init as RequestInit).headers as Record<string, string>
|
||||
expect(contentType.Authorization).toBe('Bearer access-tok')
|
||||
expect(contentType['Content-Type']).toContain('multipart/related')
|
||||
// Body must be a Buffer that contains the metadata JSON.
|
||||
const body = (init as RequestInit).body as Buffer
|
||||
expect(Buffer.isBuffer(body)).toBe(true)
|
||||
expect(body.toString('utf8')).toContain('"name":"arkiv.zip"')
|
||||
expect(body.toString('utf8')).toContain('"parents":["folder-1"]')
|
||||
})
|
||||
|
||||
it('throws with Drive error body when upload fails', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('quota exceeded', { status: 403 })
|
||||
)
|
||||
await expect(
|
||||
uploadFile('at', 'folder', 'a.zip', new Uint8Array(1).buffer)
|
||||
).rejects.toThrow(/403/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
exchangeCodeForTokens,
|
||||
refreshAccessToken,
|
||||
getOAuthEnv,
|
||||
} from '../google-oauth'
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.GOOGLE_CLIENT_ID = 'test-client-id'
|
||||
process.env.GOOGLE_CLIENT_SECRET = 'test-client-secret'
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('getOAuthEnv', () => {
|
||||
it('builds redirect URI from origin', () => {
|
||||
const env = getOAuthEnv('https://app.example.com')
|
||||
expect(env.redirectUri).toBe(
|
||||
'https://app.example.com/api/extensions/ext/cloud-backup/oauth/callback'
|
||||
)
|
||||
expect(env.clientId).toBe('test-client-id')
|
||||
})
|
||||
|
||||
it('throws when env vars missing', () => {
|
||||
delete process.env.GOOGLE_CLIENT_ID
|
||||
expect(() => getOAuthEnv('http://localhost:3000')).toThrow(/GOOGLE_CLIENT_ID/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAuthorizationUrl', () => {
|
||||
it('includes scope, offline access, consent prompt, and state', () => {
|
||||
const env = getOAuthEnv('http://localhost:3000')
|
||||
const url = buildAuthorizationUrl(env, 'abc123state')
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.origin + parsed.pathname).toBe(
|
||||
'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
)
|
||||
expect(parsed.searchParams.get('access_type')).toBe('offline')
|
||||
expect(parsed.searchParams.get('prompt')).toBe('consent')
|
||||
expect(parsed.searchParams.get('state')).toBe('abc123state')
|
||||
expect(parsed.searchParams.get('scope')).toContain(
|
||||
'https://www.googleapis.com/auth/drive.file'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exchangeCodeForTokens', () => {
|
||||
it('posts form-encoded body and parses token response', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
const env = getOAuthEnv('http://localhost:3000')
|
||||
const result = await exchangeCodeForTokens(env, 'auth-code')
|
||||
|
||||
expect(result.refresh_token).toBe('rt')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('https://oauth2.googleapis.com/token')
|
||||
expect((init as RequestInit).method).toBe('POST')
|
||||
expect(String((init as RequestInit).body)).toContain('grant_type=authorization_code')
|
||||
expect(String((init as RequestInit).body)).toContain('code=auth-code')
|
||||
})
|
||||
|
||||
it('throws a clear error when no refresh_token is returned', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ access_token: 'at', expires_in: 3600 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
const env = getOAuthEnv('http://localhost:3000')
|
||||
await expect(exchangeCodeForTokens(env, 'code')).rejects.toThrow(/refresh token/i)
|
||||
})
|
||||
|
||||
it('throws on non-OK response', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('Bad Request', { status: 400 })
|
||||
)
|
||||
const env = getOAuthEnv('http://localhost:3000')
|
||||
await expect(exchangeCodeForTokens(env, 'code')).rejects.toThrow(/400/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshAccessToken', () => {
|
||||
it('returns a fresh access token', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ access_token: 'new-at', expires_in: 3600 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
const env = getOAuthEnv('http://localhost:3000')
|
||||
const result = await refreshAccessToken(env, 'old-refresh')
|
||||
expect(result.access_token).toBe('new-at')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
/**
|
||||
* AES-256-GCM encryption for long-lived refresh tokens stored in
|
||||
* extension_data. Key is derived from SUPABASE_SERVICE_ROLE_KEY (same
|
||||
* trust boundary as the database itself — anyone who can exfiltrate the
|
||||
* key can already read the data).
|
||||
*/
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm'
|
||||
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
if (!secret) throw new Error('SUPABASE_SERVICE_ROLE_KEY is required')
|
||||
// Scope the key with a purpose string so this can't be confused with
|
||||
// oauth-codes.ts's derivation if both are ever compromised together.
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update('cloud-backup:v1:' + secret)
|
||||
.digest()
|
||||
}
|
||||
|
||||
export function encryptToken(plaintext: string): string {
|
||||
const key = getKey()
|
||||
const iv = crypto.randomBytes(12)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
|
||||
}
|
||||
|
||||
export function decryptToken(ciphertext: string): string {
|
||||
const key = getKey()
|
||||
const combined = Buffer.from(ciphertext, 'base64url')
|
||||
const iv = combined.subarray(0, 12)
|
||||
const tag = combined.subarray(12, 28)
|
||||
const encrypted = combined.subarray(28)
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
|
||||
return decrypted.toString('utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived signed state parameter for OAuth CSRF protection.
|
||||
*
|
||||
* The state encodes `{userId, companyId, exp}` and is verified on the
|
||||
* callback. Stateless (no DB round-trip) and self-expiring.
|
||||
*/
|
||||
const STATE_TTL_MS = 10 * 60 * 1000
|
||||
|
||||
interface StatePayload {
|
||||
u: string
|
||||
c: string
|
||||
e: number
|
||||
}
|
||||
|
||||
export function createOAuthState(userId: string, companyId: string): string {
|
||||
const payload: StatePayload = {
|
||||
u: userId,
|
||||
c: companyId,
|
||||
e: Date.now() + STATE_TTL_MS,
|
||||
}
|
||||
return encryptToken(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
export function verifyOAuthState(
|
||||
state: string
|
||||
): { userId: string; companyId: string } | null {
|
||||
try {
|
||||
const payload = JSON.parse(decryptToken(state)) as StatePayload
|
||||
if (Date.now() > payload.e) return null
|
||||
return { userId: payload.u, companyId: payload.c }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Minimal Google Drive v3 client — just enough to:
|
||||
* - find or create a named folder,
|
||||
* - upload a file via multipart.
|
||||
*
|
||||
* We operate on `drive.file` scope, so we can only see files we created.
|
||||
* Queries by name return only app-created folders with that name.
|
||||
*/
|
||||
|
||||
const DRIVE_API = 'https://www.googleapis.com/drive/v3'
|
||||
const DRIVE_UPLOAD_API = 'https://www.googleapis.com/upload/drive/v3/files'
|
||||
const FOLDER_MIME = 'application/vnd.google-apps.folder'
|
||||
|
||||
interface DriveFile {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
async function driveFetch(
|
||||
accessToken: string,
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
): Promise<Response> {
|
||||
const res = await fetch(`${DRIVE_API}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers || {}),
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`Drive API ${res.status}: ${body.slice(0, 200)}`)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a folder by name under a parent (or root). Returns null if none exists.
|
||||
* Uses q= filter; drive.file scope only sees app-created folders.
|
||||
*/
|
||||
async function findFolderByName(
|
||||
accessToken: string,
|
||||
name: string,
|
||||
parentId: string | null
|
||||
): Promise<DriveFile | null> {
|
||||
const parentClause = parentId ? `'${parentId}' in parents` : `'root' in parents`
|
||||
const q = [
|
||||
`mimeType = '${FOLDER_MIME}'`,
|
||||
`name = '${escapeName(name)}'`,
|
||||
parentClause,
|
||||
'trashed = false',
|
||||
].join(' and ')
|
||||
const url = `/files?q=${encodeURIComponent(q)}&fields=files(id,name)&pageSize=1`
|
||||
const res = await driveFetch(accessToken, url)
|
||||
const json = (await res.json()) as { files: DriveFile[] }
|
||||
return json.files[0] || null
|
||||
}
|
||||
|
||||
async function createFolder(
|
||||
accessToken: string,
|
||||
name: string,
|
||||
parentId: string | null
|
||||
): Promise<DriveFile> {
|
||||
const body = {
|
||||
name,
|
||||
mimeType: FOLDER_MIME,
|
||||
parents: parentId ? [parentId] : undefined,
|
||||
}
|
||||
const res = await driveFetch(accessToken, '/files?fields=id,name', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return (await res.json()) as DriveFile
|
||||
}
|
||||
|
||||
export async function ensureFolder(
|
||||
accessToken: string,
|
||||
name: string,
|
||||
parentId: string | null
|
||||
): Promise<DriveFile> {
|
||||
const existing = await findFolderByName(accessToken, name, parentId)
|
||||
if (existing) return existing
|
||||
return createFolder(accessToken, name, parentId)
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
id: string
|
||||
name: string
|
||||
size_bytes: number
|
||||
web_view_link: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Multipart upload: metadata + bytes in one request. Suitable for files
|
||||
* up to ~100 MB; beyond that Drive recommends resumable uploads.
|
||||
*/
|
||||
export async function uploadFile(
|
||||
accessToken: string,
|
||||
folderId: string,
|
||||
fileName: string,
|
||||
data: ArrayBuffer,
|
||||
contentType = 'application/zip'
|
||||
): Promise<UploadResult> {
|
||||
const boundary = `gnubok-${crypto.randomUUID().replace(/-/g, '')}`
|
||||
const metadata = JSON.stringify({
|
||||
name: fileName,
|
||||
parents: [folderId],
|
||||
})
|
||||
|
||||
const head =
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Type: application/json; charset=UTF-8\r\n\r\n` +
|
||||
`${metadata}\r\n` +
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Type: ${contentType}\r\n\r\n`
|
||||
const tail = `\r\n--${boundary}--`
|
||||
|
||||
const body = Buffer.concat([
|
||||
Buffer.from(head, 'utf8'),
|
||||
Buffer.from(data),
|
||||
Buffer.from(tail, 'utf8'),
|
||||
])
|
||||
|
||||
const res = await fetch(
|
||||
`${DRIVE_UPLOAD_API}?uploadType=multipart&fields=id,name,size,webViewLink`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': `multipart/related; boundary=${boundary}`,
|
||||
'Content-Length': String(body.length),
|
||||
},
|
||||
body,
|
||||
}
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text()
|
||||
throw new Error(`Drive upload failed: ${res.status} ${errText.slice(0, 200)}`)
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
id: string
|
||||
name: string
|
||||
size?: string
|
||||
webViewLink?: string
|
||||
}
|
||||
|
||||
return {
|
||||
id: json.id,
|
||||
name: json.name,
|
||||
size_bytes: json.size ? Number(json.size) : data.byteLength,
|
||||
web_view_link: json.webViewLink || `https://drive.google.com/file/d/${json.id}/view`,
|
||||
}
|
||||
}
|
||||
|
||||
function escapeName(name: string): string {
|
||||
// Drive query string: escape single quotes and backslashes.
|
||||
return name.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Minimal Google OAuth 2.0 client for the cloud-backup extension.
|
||||
*
|
||||
* Scope: `drive.file` — app-created files only, not the user's full Drive.
|
||||
* Access type: `offline` — returns a refresh token on first consent.
|
||||
* Prompt: `consent` — forces the consent screen so the refresh token is
|
||||
* re-issued even if the user has previously authorised the app.
|
||||
*/
|
||||
|
||||
const DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.file'
|
||||
const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
|
||||
const USERINFO_ENDPOINT = 'https://openidconnect.googleapis.com/v1/userinfo'
|
||||
|
||||
export interface OAuthEnv {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
redirectUri: string
|
||||
}
|
||||
|
||||
export function getOAuthEnv(origin: string): OAuthEnv {
|
||||
const clientId = process.env.GOOGLE_CLIENT_ID
|
||||
const clientSecret = process.env.GOOGLE_CLIENT_SECRET
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error(
|
||||
'Google OAuth is not configured: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET'
|
||||
)
|
||||
}
|
||||
return {
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri: `${origin}/api/extensions/ext/cloud-backup/oauth/callback`,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAuthorizationUrl(env: OAuthEnv, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: env.clientId,
|
||||
redirect_uri: env.redirectUri,
|
||||
response_type: 'code',
|
||||
scope: `openid email ${DRIVE_SCOPE}`,
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
include_granted_scopes: 'true',
|
||||
state,
|
||||
})
|
||||
return `${AUTH_ENDPOINT}?${params.toString()}`
|
||||
}
|
||||
|
||||
export interface TokenExchangeResult {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
id_token?: string
|
||||
}
|
||||
|
||||
export async function exchangeCodeForTokens(
|
||||
env: OAuthEnv,
|
||||
code: string
|
||||
): Promise<TokenExchangeResult> {
|
||||
const body = new URLSearchParams({
|
||||
code,
|
||||
client_id: env.clientId,
|
||||
client_secret: env.clientSecret,
|
||||
redirect_uri: env.redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
})
|
||||
const res = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const errText = await res.text()
|
||||
throw new Error(`Google token exchange failed: ${res.status} ${errText}`)
|
||||
}
|
||||
const json = (await res.json()) as TokenExchangeResult
|
||||
if (!json.refresh_token) {
|
||||
throw new Error(
|
||||
'No refresh token returned — Google only issues one on first consent. ' +
|
||||
'Revoke the app at myaccount.google.com/permissions and try again.'
|
||||
)
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
export interface AccessTokenResult {
|
||||
access_token: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(
|
||||
env: OAuthEnv,
|
||||
refreshToken: string
|
||||
): Promise<AccessTokenResult> {
|
||||
const body = new URLSearchParams({
|
||||
client_id: env.clientId,
|
||||
client_secret: env.clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
})
|
||||
const res = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const errText = await res.text()
|
||||
throw new Error(`Google token refresh failed: ${res.status} ${errText}`)
|
||||
}
|
||||
return (await res.json()) as AccessTokenResult
|
||||
}
|
||||
|
||||
export async function revokeToken(token: string): Promise<void> {
|
||||
await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(token)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchUserEmail(accessToken: string): Promise<string> {
|
||||
const res = await fetch(USERINFO_ENDPOINT, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch Google user info: ${res.status}`)
|
||||
}
|
||||
const json = (await res.json()) as { email?: string }
|
||||
return json.email || 'unknown@google'
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "cloud-backup",
|
||||
"sector": "general",
|
||||
"exportName": "cloudBackupExtension",
|
||||
"entryPoint": "@/extensions/general/cloud-backup",
|
||||
"workspace": "@/components/extensions/general/CloudBackupWorkspace",
|
||||
"requiredEnvVars": ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"],
|
||||
"npmDependencies": [],
|
||||
"definition": {
|
||||
"name": "Molnsynkronisering",
|
||||
"category": "operations",
|
||||
"icon": "Cloud",
|
||||
"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. Gnubok 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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export interface GoogleDriveConnection {
|
||||
refresh_token_encrypted: string
|
||||
account_email: string
|
||||
connected_at: string
|
||||
/** ID of the top-level "gnubok" folder in the user's Drive. */
|
||||
root_folder_id: string | null
|
||||
/** ID of the per-company subfolder. */
|
||||
company_folder_id: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-sync snapshot stored under key `google_drive_last_sync`.
|
||||
*/
|
||||
export interface GoogleDriveLastSync {
|
||||
at: string
|
||||
file_id: string
|
||||
file_name: string
|
||||
file_size_bytes: number
|
||||
folder_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Status returned to the UI. Mirrors the two storage shapes above in a
|
||||
* shape safe to expose to the client (no encrypted token).
|
||||
*/
|
||||
export interface CloudBackupStatus {
|
||||
connected: boolean
|
||||
account_email: string | null
|
||||
connected_at: string | null
|
||||
last_sync: GoogleDriveLastSync | null
|
||||
}
|
||||
Reference in New Issue
Block a user