mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-07-16 04:23:43 +00:00
feat(#89): Add backup functionality for google drive and dropbox
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
|
||||
import { requireAuth } from '$lib/utils/auth';
|
||||
|
||||
export const GET = async (event: RequestEvent) => {
|
||||
try {
|
||||
const userId = await requireAuth(event);
|
||||
|
||||
const { session } = await event.locals.safeGetSession();
|
||||
if (session) {
|
||||
await event.locals.supabase.auth.setSession(session);
|
||||
}
|
||||
|
||||
const repo = new PokedexExportIntegrationRepository(event.locals.supabase, userId, null);
|
||||
const integrations = await repo.listAll();
|
||||
|
||||
const safe = integrations.map((integration) => ({
|
||||
id: integration._id,
|
||||
provider: integration.provider,
|
||||
enabled: integration.enabled,
|
||||
fileName: integration.fileName,
|
||||
folderId: integration.folderId,
|
||||
path: integration.path,
|
||||
metadata: integration.metadata,
|
||||
lastExportedAt: integration.lastExportedAt,
|
||||
lastError: integration.lastError
|
||||
}));
|
||||
|
||||
return json(safe);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { requireAuth } from '$lib/utils/auth';
|
||||
|
||||
const ALLOWED_PROVIDERS = new Set(['google_drive', 'dropbox']);
|
||||
|
||||
export const PUT = async (event: RequestEvent) => {
|
||||
try {
|
||||
const userId = await requireAuth(event);
|
||||
const { provider } = event.params;
|
||||
|
||||
if (!provider || !ALLOWED_PROVIDERS.has(provider)) {
|
||||
return json({ error: 'Invalid provider' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { session } = await event.locals.safeGetSession();
|
||||
if (session) {
|
||||
await event.locals.supabase.auth.setSession(session);
|
||||
}
|
||||
|
||||
const body = (await event.request.json()) as {
|
||||
folderId?: string | null;
|
||||
path?: string | null;
|
||||
};
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (body.folderId !== undefined) patch.folderId = body.folderId;
|
||||
if (body.path !== undefined) patch.path = body.path;
|
||||
|
||||
const { data, error } = await event.locals.supabase
|
||||
.from('pokedex_export_integrations')
|
||||
.update(patch)
|
||||
.eq('userId', userId)
|
||||
.is('pokedexId', null)
|
||||
.eq('provider', provider)
|
||||
.select('id, provider, enabled, fileName, folderId, path, metadata, lastExportedAt, lastError')
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
return json({ error: 'Integration not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return json(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { json, redirect } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
|
||||
import { requireAuth } from '$lib/utils/auth';
|
||||
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
|
||||
import { getEnv } from '$lib/utils/env';
|
||||
|
||||
export const GET = async (event: RequestEvent) => {
|
||||
try {
|
||||
const userId = await requireAuth(event);
|
||||
const url = new URL(event.request.url);
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const errorParam = url.searchParams.get('error');
|
||||
|
||||
const oauthState = readOAuthStateCookie(event, 'dropbox');
|
||||
if (!oauthState || !state || oauthState.state !== state || oauthState.userId !== userId) {
|
||||
clearOAuthStateCookie(event, 'dropbox');
|
||||
return json({ error: 'Invalid OAuth state' }, { status: 400 });
|
||||
}
|
||||
|
||||
clearOAuthStateCookie(event, 'dropbox');
|
||||
|
||||
const fallbackReturnTo = oauthState.pokedexId
|
||||
? `/pokedex/${oauthState.pokedexId}`
|
||||
: '/backup-settings';
|
||||
const baseReturnTo =
|
||||
oauthState.returnTo && oauthState.returnTo.startsWith('/')
|
||||
? oauthState.returnTo
|
||||
: fallbackReturnTo;
|
||||
const returnTo = baseReturnTo.includes('?')
|
||||
? `${baseReturnTo}&export=dropbox`
|
||||
: `${baseReturnTo}?export=dropbox`;
|
||||
|
||||
if (errorParam) {
|
||||
throw redirect(302, `${returnTo}-denied`);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return json({ error: 'Missing OAuth code' }, { status: 400 });
|
||||
}
|
||||
|
||||
const env = getEnv();
|
||||
const clientId = env.DROPBOX_OAUTH_CLIENT_ID;
|
||||
const clientSecret = env.DROPBOX_OAUTH_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
return json({ error: 'Missing Dropbox OAuth credentials' }, { status: 500 });
|
||||
}
|
||||
|
||||
const redirectUri = `${event.url.origin}/api/integrations/dropbox/callback`;
|
||||
const tokenParams = new URLSearchParams({
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code'
|
||||
});
|
||||
|
||||
const tokenResponse = await fetch('https://api.dropbox.com/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: tokenParams.toString()
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const text = await tokenResponse.text();
|
||||
return json({ error: `Dropbox token exchange failed: ${text}` }, { status: 500 });
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as {
|
||||
access_token: string;
|
||||
expires_in?: number;
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
const expiresAt = tokenData.expires_in
|
||||
? new Date(Date.now() + tokenData.expires_in * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
const { session } = await event.locals.safeGetSession();
|
||||
if (session) {
|
||||
await event.locals.supabase.auth.setSession(session);
|
||||
}
|
||||
|
||||
const repo = new PokedexExportIntegrationRepository(event.locals.supabase, userId, null);
|
||||
|
||||
const fileName = oauthState.fileName?.trim() ? oauthState.fileName : null;
|
||||
const path = oauthState.path?.trim() ? oauthState.path : null;
|
||||
|
||||
try {
|
||||
await repo.upsert({
|
||||
provider: 'dropbox',
|
||||
enabled: true,
|
||||
fileName,
|
||||
path,
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token ?? null,
|
||||
accessTokenExpiresAt: expiresAt,
|
||||
metadata: tokenData.scope ? { scope: tokenData.scope } : null
|
||||
});
|
||||
} catch (saveError) {
|
||||
console.error('Dropbox integration save failed:', saveError);
|
||||
throw redirect(302, `${returnTo}-error`);
|
||||
}
|
||||
|
||||
throw redirect(302, `${returnTo}-connected`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||
import { requireAuth } from '$lib/utils/auth';
|
||||
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
|
||||
import { getEnv } from '$lib/utils/env';
|
||||
|
||||
export const GET = async (event: RequestEvent) => {
|
||||
const userId = await requireAuth(event);
|
||||
const url = new URL(event.request.url);
|
||||
const pokedexId = url.searchParams.get('pokedexId');
|
||||
const fileName = url.searchParams.get('fileName');
|
||||
const path = url.searchParams.get('path');
|
||||
const returnToRaw = url.searchParams.get('returnTo');
|
||||
const returnTo =
|
||||
returnToRaw && returnToRaw.startsWith('/') ? returnToRaw : '/backup-settings';
|
||||
|
||||
if (pokedexId) {
|
||||
const { session } = await event.locals.safeGetSession();
|
||||
if (session) {
|
||||
await event.locals.supabase.auth.setSession(session);
|
||||
}
|
||||
|
||||
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
|
||||
const pokedex = await pokedexRepo.findById(pokedexId);
|
||||
if (!pokedex) {
|
||||
throw redirect(302, '/my-pokedexes');
|
||||
}
|
||||
}
|
||||
|
||||
const env = getEnv();
|
||||
const clientId = env.DROPBOX_OAUTH_CLIENT_ID;
|
||||
if (!clientId) {
|
||||
throw redirect(302, `/pokedex/${pokedexId}?export=dropbox-missing-client`);
|
||||
}
|
||||
|
||||
const state = createOAuthState();
|
||||
setOAuthStateCookie(event, 'dropbox', {
|
||||
state,
|
||||
userId,
|
||||
pokedexId,
|
||||
provider: 'dropbox',
|
||||
fileName,
|
||||
path,
|
||||
returnTo
|
||||
});
|
||||
|
||||
const redirectUri = `${event.url.origin}/api/integrations/dropbox/callback`;
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
state,
|
||||
token_access_type: 'offline',
|
||||
scope: 'files.content.write'
|
||||
});
|
||||
|
||||
throw redirect(302, `https://www.dropbox.com/oauth2/authorize?${params.toString()}`);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { json, redirect } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
|
||||
import { requireAuth } from '$lib/utils/auth';
|
||||
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
|
||||
import { getEnv } from '$lib/utils/env';
|
||||
|
||||
export const GET = async (event: RequestEvent) => {
|
||||
try {
|
||||
const userId = await requireAuth(event);
|
||||
const url = new URL(event.request.url);
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const errorParam = url.searchParams.get('error');
|
||||
|
||||
const oauthState = readOAuthStateCookie(event, 'google_drive');
|
||||
if (!oauthState || !state || oauthState.state !== state || oauthState.userId !== userId) {
|
||||
clearOAuthStateCookie(event, 'google_drive');
|
||||
return json({ error: 'Invalid OAuth state' }, { status: 400 });
|
||||
}
|
||||
|
||||
clearOAuthStateCookie(event, 'google_drive');
|
||||
|
||||
const fallbackReturnTo = oauthState.pokedexId
|
||||
? `/pokedex/${oauthState.pokedexId}`
|
||||
: '/backup-settings';
|
||||
const baseReturnTo =
|
||||
oauthState.returnTo && oauthState.returnTo.startsWith('/')
|
||||
? oauthState.returnTo
|
||||
: fallbackReturnTo;
|
||||
const returnTo = baseReturnTo.includes('?')
|
||||
? `${baseReturnTo}&export=google`
|
||||
: `${baseReturnTo}?export=google`;
|
||||
|
||||
if (errorParam) {
|
||||
throw redirect(302, `${returnTo}-denied`);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return json({ error: 'Missing OAuth code' }, { status: 400 });
|
||||
}
|
||||
|
||||
const env = getEnv();
|
||||
const clientId = env.GOOGLE_OAUTH_CLIENT_ID;
|
||||
const clientSecret = env.GOOGLE_OAUTH_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
return json({ error: 'Missing Google OAuth credentials' }, { status: 500 });
|
||||
}
|
||||
|
||||
const redirectUri = `${event.url.origin}/api/integrations/google-drive/callback`;
|
||||
|
||||
const tokenParams = new URLSearchParams({
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code'
|
||||
});
|
||||
|
||||
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: tokenParams.toString()
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const text = await tokenResponse.text();
|
||||
return json({ error: `Google token exchange failed: ${text}` }, { status: 500 });
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as {
|
||||
access_token: string;
|
||||
expires_in?: number;
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
const expiresAt = tokenData.expires_in
|
||||
? new Date(Date.now() + tokenData.expires_in * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
const { session } = await event.locals.safeGetSession();
|
||||
if (session) {
|
||||
await event.locals.supabase.auth.setSession(session);
|
||||
}
|
||||
|
||||
const repo = new PokedexExportIntegrationRepository(event.locals.supabase, userId, null);
|
||||
|
||||
const fileName = oauthState.fileName?.trim() ? oauthState.fileName : null;
|
||||
const folderId = oauthState.folderId?.trim() ? oauthState.folderId : null;
|
||||
|
||||
await repo.upsert({
|
||||
provider: 'google_drive',
|
||||
enabled: true,
|
||||
fileName,
|
||||
folderId,
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token ?? null,
|
||||
accessTokenExpiresAt: expiresAt,
|
||||
metadata: tokenData.scope ? { scope: tokenData.scope } : null
|
||||
});
|
||||
|
||||
throw redirect(302, `${returnTo}-connected`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||
import { requireAuth } from '$lib/utils/auth';
|
||||
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
|
||||
import { getEnv } from '$lib/utils/env';
|
||||
|
||||
export const GET = async (event: RequestEvent) => {
|
||||
const userId = await requireAuth(event);
|
||||
const url = new URL(event.request.url);
|
||||
const pokedexId = url.searchParams.get('pokedexId');
|
||||
const fileName = url.searchParams.get('fileName');
|
||||
const folderId = url.searchParams.get('folderId');
|
||||
const returnToRaw = url.searchParams.get('returnTo');
|
||||
const returnTo =
|
||||
returnToRaw && returnToRaw.startsWith('/') ? returnToRaw : '/backup-settings';
|
||||
|
||||
if (pokedexId) {
|
||||
const { session } = await event.locals.safeGetSession();
|
||||
if (session) {
|
||||
await event.locals.supabase.auth.setSession(session);
|
||||
}
|
||||
|
||||
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
|
||||
const pokedex = await pokedexRepo.findById(pokedexId);
|
||||
if (!pokedex) {
|
||||
throw redirect(302, '/my-pokedexes');
|
||||
}
|
||||
}
|
||||
|
||||
const env = getEnv();
|
||||
const clientId = env.GOOGLE_OAUTH_CLIENT_ID;
|
||||
if (!clientId) {
|
||||
throw redirect(302, `/pokedex/${pokedexId}?export=google-missing-client`);
|
||||
}
|
||||
|
||||
const state = createOAuthState();
|
||||
setOAuthStateCookie(event, 'google_drive', {
|
||||
state,
|
||||
userId,
|
||||
pokedexId,
|
||||
provider: 'google_drive',
|
||||
fileName,
|
||||
folderId,
|
||||
returnTo
|
||||
});
|
||||
|
||||
const redirectUri = `${event.url.origin}/api/integrations/google-drive/callback`;
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/drive.metadata.readonly'
|
||||
].join(' '),
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
include_granted_scopes: 'true',
|
||||
state
|
||||
});
|
||||
|
||||
throw redirect(302, `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import { type CatchRecord } from '$lib/models/CatchRecord';
|
||||
import CatchRecordRepository from '$lib/repositories/CatchRecordRepository';
|
||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||
import { exportPokedexIfConfigured } from '$lib/services/PokedexExportService';
|
||||
import { requireAuth } from '$lib/utils/auth';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
@@ -73,6 +74,11 @@ export const PUT = async (event: RequestEvent) => {
|
||||
|
||||
const repo = new CatchRecordRepository(event.locals.supabase, userId, pokedexId);
|
||||
const upsertedRecord = await repo.upsert(data);
|
||||
try {
|
||||
await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
|
||||
} catch (exportError) {
|
||||
console.error('Failed to export pokedex after catch record update:', exportError);
|
||||
}
|
||||
return json(upsertedRecord);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -137,6 +143,11 @@ export const POST = async (event: RequestEvent) => {
|
||||
// Prefer a single bulk upsert when the DB supports it; fall back to per-record upserts.
|
||||
try {
|
||||
const upserted = await repo.bulkUpsert(scoped);
|
||||
try {
|
||||
await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
|
||||
} catch (exportError) {
|
||||
console.error('Failed to export pokedex after bulk catch record update:', exportError);
|
||||
}
|
||||
return json(upserted);
|
||||
} catch (bulkErr) {
|
||||
console.warn('Bulk upsert failed; falling back to per-record upserts:', bulkErr);
|
||||
@@ -145,6 +156,14 @@ export const POST = async (event: RequestEvent) => {
|
||||
const upsertedRecord = await repo.upsert(record);
|
||||
insertedRecords.push(upsertedRecord);
|
||||
}
|
||||
try {
|
||||
await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
|
||||
} catch (exportError) {
|
||||
console.error(
|
||||
'Failed to export pokedex after per-record catch updates:',
|
||||
exportError
|
||||
);
|
||||
}
|
||||
return json(insertedRecords);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||
import { exportPokedexIfConfigured } from '$lib/services/PokedexExportService';
|
||||
import { requireAuth } from '$lib/utils/auth';
|
||||
|
||||
export const POST = async (event: RequestEvent) => {
|
||||
try {
|
||||
const userId = await requireAuth(event);
|
||||
const { id: pokedexId } = event.params;
|
||||
|
||||
if (!pokedexId) {
|
||||
return json({ error: 'Pokedex ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { session } = await event.locals.safeGetSession();
|
||||
if (session) {
|
||||
await event.locals.supabase.auth.setSession(session);
|
||||
}
|
||||
|
||||
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
|
||||
const pokedex = await pokedexRepo.findById(pokedexId);
|
||||
|
||||
if (!pokedex) {
|
||||
return json({ error: 'Pokedex not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const result = await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user