feat: Google Drive backup OAuth (Faz 1 — backend)
PRD "AyrisLegal Google Drive Backup & Recovery" kapsamında sadece backend altyapısı: OAuth connect/callback/status/disconnect/refresh uç noktaları, AES-256-GCM token şifreleme (tokenCrypto.ts), tek kullanımlık CSRF state (oauthState.ts), drive.file scope ile AyrisLegal/Davalar klasör oluşturma (googleDriveClient.ts). Electron tarafı (dosya tarama/kuyruk/UI) ayrı bir aşamada. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
9618ff4513
commit
328aef91bd
@@ -0,0 +1,187 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AuthenticatedRequest } from '../middleware/auth';
|
||||
import { encryptToken, decryptToken } from '../lib/tokenCrypto';
|
||||
import { createState, consumeState } from '../lib/oauthState';
|
||||
import * as googleDrive from '../lib/googleDriveClient';
|
||||
|
||||
function callbackPage(title: string, message: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="tr"><head><meta charset="utf-8"><title>${title}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #060b14; color: #eef1f4;
|
||||
display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
||||
.card { max-width: 420px; text-align: center; padding: 32px; }
|
||||
h1 { font-size: 20px; margin-bottom: 12px; }
|
||||
p { color: #9ca3af; font-size: 14px; line-height: 1.6; }
|
||||
</style></head>
|
||||
<body><div class="card"><h1>${title}</h1><p>${message}</p></div></body></html>`;
|
||||
}
|
||||
|
||||
// PRD §19: Electron sadece bir tarayıcı açıp Google'a yönlendirir, tüm OAuth
|
||||
// kod-değişimi burada (backend'de) yapılır — Electron'a kesinlikle client
|
||||
// secret veya refresh token gönderilmiyor.
|
||||
export const connect = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.user?.id as string;
|
||||
const state = createState(userId);
|
||||
const authUrl = googleDrive.buildAuthUrl(state);
|
||||
res.json({ authUrl });
|
||||
} catch (error: any) {
|
||||
console.error('[DRIVE] Connect Error:', error);
|
||||
res.status(500).json({ error: 'Google Drive bağlantısı başlatılamadı.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Google'ın doğrudan tarayıcı yönlendirmesiyle çağırdığı tek public uç nokta —
|
||||
// Bearer token yok, kimlik doğrulama "state" üzerinden yapılıyor (bkz. oauthState.ts).
|
||||
export const oauthCallback = async (req: Request, res: Response) => {
|
||||
const { code, state, error: oauthError } = req.query as { code?: string; state?: string; error?: string };
|
||||
|
||||
if (oauthError) {
|
||||
console.error('[DRIVE] Google OAuth error:', oauthError);
|
||||
return res.status(400).send(callbackPage('Bağlantı reddedildi', 'Google Drive bağlantısı tamamlanamadı. Bu pencereyi kapatıp AyrisLegal\'e dönebilirsiniz.'));
|
||||
}
|
||||
if (!code || !state) {
|
||||
return res.status(400).send(callbackPage('Geçersiz istek', 'Eksik parametre. Lütfen AyrisLegal\'den tekrar deneyin.'));
|
||||
}
|
||||
|
||||
const userId = consumeState(state);
|
||||
if (!userId) {
|
||||
console.error('[DRIVE] Geçersiz veya süresi dolmuş OAuth state.');
|
||||
return res.status(400).send(callbackPage('Bağlantı süresi doldu', 'Lütfen AyrisLegal\'de "Google Drive\'a Bağlan"ı tekrar deneyin.'));
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[DRIVE] OAuth started, user:', userId);
|
||||
const tokens = await googleDrive.exchangeCodeForTokens(code);
|
||||
if (!tokens.refresh_token) {
|
||||
throw new Error('Google refresh token döndürmedi.');
|
||||
}
|
||||
|
||||
const email = await googleDrive.getUserEmail(tokens.access_token);
|
||||
const { rootFolderId, casesFolderId } = await googleDrive.ensureAyrisLegalFolders(tokens.access_token);
|
||||
|
||||
const { error: dbError } = await supabase.from('google_drive_connections').upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
google_email: email,
|
||||
access_token_encrypted: encryptToken(tokens.access_token),
|
||||
refresh_token_encrypted: encryptToken(tokens.refresh_token),
|
||||
token_expires_at: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
|
||||
scope: tokens.scope,
|
||||
drive_root_folder_id: rootFolderId,
|
||||
drive_cases_folder_id: casesFolderId,
|
||||
status: 'connected',
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: 'user_id' }
|
||||
);
|
||||
if (dbError) throw dbError;
|
||||
|
||||
console.log('[DRIVE] OAuth completed, user:', userId);
|
||||
res.send(callbackPage('Bağlantı başarılı', `Google Drive hesabınız (${email}) AyrisLegal'e bağlandı. Bu pencereyi kapatıp uygulamaya dönebilirsiniz.`));
|
||||
} catch (error: any) {
|
||||
console.error('[DRIVE] OAuth callback error:', error);
|
||||
res.status(500).send(callbackPage('Bağlantı başarısız', 'Google Drive bağlantısı sırasında bir hata oluştu. Lütfen tekrar deneyin.'));
|
||||
}
|
||||
};
|
||||
|
||||
export const getStatus = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const { data, error } = await supabase
|
||||
.from('google_drive_connections')
|
||||
.select('google_email, status, last_sync_at, created_at')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
if (!data) return res.json({ connected: false });
|
||||
res.json({ connected: data.status === 'connected', ...data });
|
||||
} catch (error: any) {
|
||||
console.error('[DRIVE] Get Status Error:', error);
|
||||
res.status(500).json({ error: 'Bağlantı durumu alınamadı.' });
|
||||
}
|
||||
};
|
||||
|
||||
// PRD §22: local dosyalar VE Drive'daki backup dosyaları asla silinmiyor —
|
||||
// burada sadece bağlantı/token kaydı kaldırılıyor.
|
||||
export const disconnect = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const { data: connection } = await supabase
|
||||
.from('google_drive_connections')
|
||||
.select('refresh_token_encrypted')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (connection) {
|
||||
try {
|
||||
await googleDrive.revokeToken(decryptToken(connection.refresh_token_encrypted));
|
||||
} catch (err: any) {
|
||||
console.error('[DRIVE] Revoke sırasında hata (yine de bağlantı kaldırılacak):', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('google_drive_connections').delete().eq('user_id', userId);
|
||||
if (error) throw error;
|
||||
|
||||
console.log('[DRIVE] User disconnected:', userId);
|
||||
res.json({ ok: true });
|
||||
} catch (error: any) {
|
||||
console.error('[DRIVE] Disconnect Error:', error);
|
||||
res.status(500).json({ error: 'Bağlantı kesilemedi.' });
|
||||
}
|
||||
};
|
||||
|
||||
// İleride upload/backup-status endpoint'lerinin (Faz 2) kullanacağı iç
|
||||
// yardımcı — geçerli bir access_token döner, süresi dolmuşsa/dolmak üzereyse
|
||||
// refresh_token ile otomatik yeniler (PRD §21). Kullanıcıya login sordurmaz.
|
||||
export async function getValidAccessToken(userId: string): Promise<string> {
|
||||
const { data: connection, error } = await supabase
|
||||
.from('google_drive_connections')
|
||||
.select('access_token_encrypted, refresh_token_encrypted, token_expires_at, status')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
if (error || !connection) {
|
||||
throw new Error('Google Drive bağlantısı bulunamadı.');
|
||||
}
|
||||
if (connection.status === 'reauthorization_required') {
|
||||
throw new Error('Google Drive bağlantınızın yenilenmesi gerekiyor.');
|
||||
}
|
||||
|
||||
const expiresAt = new Date(connection.token_expires_at).getTime();
|
||||
if (expiresAt - Date.now() > 60_000) {
|
||||
return decryptToken(connection.access_token_encrypted);
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshToken = decryptToken(connection.refresh_token_encrypted);
|
||||
const refreshed = await googleDrive.refreshAccessToken(refreshToken);
|
||||
console.log('[DRIVE] Token refreshed, user:', userId);
|
||||
await supabase
|
||||
.from('google_drive_connections')
|
||||
.update({
|
||||
access_token_encrypted: encryptToken(refreshed.access_token),
|
||||
token_expires_at: new Date(Date.now() + refreshed.expires_in * 1000).toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('user_id', userId);
|
||||
return refreshed.access_token;
|
||||
} catch (err: any) {
|
||||
if (err.code === 'invalid_grant') {
|
||||
await supabase.from('google_drive_connections').update({ status: 'reauthorization_required' }).eq('user_id', userId);
|
||||
}
|
||||
throw new Error('Google Drive bağlantınızın yenilenmesi gerekiyor.');
|
||||
}
|
||||
}
|
||||
|
||||
export const refresh = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
await getValidAccessToken(req.user?.id as string);
|
||||
res.json({ ok: true });
|
||||
} catch (error: any) {
|
||||
console.error('[DRIVE] Refresh Error:', error);
|
||||
res.status(409).json({ error: error.message || 'Google Drive bağlantısı yenilenemedi.' });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user