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,148 @@
|
||||
// Google OAuth + Drive API v3 çağrıları — bilerek googleapis SDK'sı yerine
|
||||
// düz fetch kullanılıyor (bu birkaç REST çağrısı için ağır bir bağımlılık
|
||||
// gerekmiyor, projenin geri kalanındaki aiClient.ts ile aynı yaklaşım).
|
||||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
|
||||
const GOOGLE_REVOKE_URL = 'https://oauth2.googleapis.com/revoke';
|
||||
const DRIVE_API_URL = 'https://www.googleapis.com/drive/v3';
|
||||
|
||||
// PRD §7: mümkün olan en düşük scope. drive.file, uygulamanın SADECE kendi
|
||||
// oluşturduğu/açtığı dosyalara erişmesini sağlar — kullanıcının tüm Drive'ına
|
||||
// erişim istemiyoruz.
|
||||
const SCOPE = [
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
].join(' ');
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
function getOAuthConfig() {
|
||||
const clientId = process.env.GOOGLE_CLIENT_ID;
|
||||
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||
const redirectUri = process.env.GOOGLE_REDIRECT_URI;
|
||||
if (!clientId || !clientSecret || !redirectUri) {
|
||||
throw new Error('GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET veya GOOGLE_REDIRECT_URI tanımlı değil.');
|
||||
}
|
||||
return { clientId, clientSecret, redirectUri };
|
||||
}
|
||||
|
||||
export function buildAuthUrl(state: string): string {
|
||||
const { clientId, redirectUri } = getOAuthConfig();
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
access_type: 'offline', // refresh_token almak için gerekli
|
||||
prompt: 'consent', // refresh_token'ın HER bağlantıda dönmesini garantiler
|
||||
scope: SCOPE,
|
||||
state,
|
||||
});
|
||||
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForTokens(code: string): Promise<TokenResponse> {
|
||||
const { clientId, clientSecret, redirectUri } = getOAuthConfig();
|
||||
const res = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error_description || data.error || 'Google token exchange başarısız.');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(refreshToken: string): Promise<{ access_token: string; expires_in: number; scope: string }> {
|
||||
const { clientId, clientSecret } = getOAuthConfig();
|
||||
const res = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const err: any = new Error(data.error_description || data.error || 'Google token yenileme başarısız.');
|
||||
err.code = data.error; // 'invalid_grant' → refresh token artık geçersiz
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// En iyi çaba — Google tarafında revoke başarısız olsa bile disconnect akışı
|
||||
// yerel kaydı yine de siler (bkz. googleDrive.controller.ts).
|
||||
export async function revokeToken(token: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`${GOOGLE_REVOKE_URL}?token=${encodeURIComponent(token)}`, { method: 'POST' });
|
||||
} catch (e: any) {
|
||||
console.error('[DRIVE] Token revoke isteği başarısız:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserEmail(accessToken: string): Promise<string> {
|
||||
const res = await fetch(GOOGLE_USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` } });
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.email) {
|
||||
throw new Error('Google kullanıcı bilgisi alınamadı.');
|
||||
}
|
||||
return data.email;
|
||||
}
|
||||
|
||||
// drive.file scope'u sadece uygulamanın oluşturduğu/açtığı dosyaları görmesine
|
||||
// izin verir — bu yüzden klasörü her zaman biz oluşturuyoruz/arıyoruz, kullanıcının
|
||||
// genel Drive'ında serbest arama yapamayız (zaten istemiyoruz, bkz. PRD §7).
|
||||
async function findOrCreateFolder(accessToken: string, name: string, parentId?: string): Promise<string> {
|
||||
const conditions = [`name='${name.replace(/'/g, "\\'")}'`, "mimeType='application/vnd.google-apps.folder'", 'trashed=false'];
|
||||
if (parentId) conditions.push(`'${parentId}' in parents`);
|
||||
|
||||
const listRes = await fetch(`${DRIVE_API_URL}/files?q=${encodeURIComponent(conditions.join(' and '))}&fields=files(id,name)`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const listData = await listRes.json();
|
||||
if (listRes.ok && Array.isArray(listData.files) && listData.files.length > 0) {
|
||||
return listData.files[0].id;
|
||||
}
|
||||
|
||||
const createRes = await fetch(`${DRIVE_API_URL}/files?fields=id`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
mimeType: 'application/vnd.google-apps.folder',
|
||||
parents: parentId ? [parentId] : undefined,
|
||||
}),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
if (!createRes.ok) {
|
||||
throw new Error(`Google Drive klasörü oluşturulamadı: ${name}`);
|
||||
}
|
||||
return createData.id;
|
||||
}
|
||||
|
||||
// İlk bağlantıda AyrisLegal/Davalar klasör yapısını kurar (PRD §8). Sonraki
|
||||
// bağlantılarda (aynı kullanıcı tekrar bağlanırsa) mevcut klasörler bulunup
|
||||
// tekrar kullanılır, kopya oluşturulmaz.
|
||||
export async function ensureAyrisLegalFolders(accessToken: string): Promise<{ rootFolderId: string; casesFolderId: string }> {
|
||||
const rootFolderId = await findOrCreateFolder(accessToken, 'AyrisLegal');
|
||||
const casesFolderId = await findOrCreateFolder(accessToken, 'Davalar', rootFolderId);
|
||||
return { rootFolderId, casesFolderId };
|
||||
}
|
||||
Reference in New Issue
Block a user