GET /api/google-drive/backup-files: kullanıcının senkronize edilmiş dosyalarının listesi (PRD §28 "N dosya bulundu"). GET /download: bir dosyayı Drive'dan proxy'leyip ham baytları döner — istenen drive_file_id'nin gerçekten bu kullanıcıya ait olduğu önce doğrulanıyor (PRD §39, başka kullanıcının backup'ı sızmasın). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
234 lines
10 KiB
TypeScript
234 lines
10 KiB
TypeScript
// 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).
|
||
export 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 };
|
||
}
|
||
|
||
// PRD §9: local dosya yapısı (dava klasörü → belgeler) birebir Drive'a
|
||
// yansıtılıyor — dava başlığı = klasör adı. drive.file scope zaten sadece
|
||
// uygulamanın oluşturduğu klasörleri görebildiği için burada da arama/oluşturma
|
||
// findOrCreateFolder ile aynı mantıkla çalışıyor.
|
||
export async function findOrCreateCaseFolder(accessToken: string, casesFolderId: string, caseTitle: string): Promise<string> {
|
||
return findOrCreateFolder(accessToken, caseTitle, casesFolderId);
|
||
}
|
||
|
||
interface UploadParams {
|
||
name: string;
|
||
parentId: string;
|
||
mimeType: string;
|
||
buffer: Buffer;
|
||
// Verilirse içerik bu mevcut Drive dosyasının YENİ SÜRÜMÜ olarak yüklenir
|
||
// (Google Drive eski sürümü otomatik revizyon geçmişinde tutar) — yeni bir
|
||
// kopya dosya OLUŞTURULMAZ. PRD Test 4 "Modified File" tam olarak bu.
|
||
existingFileId?: string;
|
||
}
|
||
|
||
// PRD §16: Google Drive'ın resumable upload protokolü kullanılıyor (electron-updater
|
||
// gibi burada da sıfırdan bir upload mekanizması yazmak yerine Google'ın kendi
|
||
// resumable session'ı kullanılıyor). Dosya baytları backend'e tek seferde (multer,
|
||
// bellekte buffer olarak) geldiği için PUT tek parça gönderiliyor — asıl fayda
|
||
// backend↔Google bacağının bu sayede Google'ın kendi bütünlük/aktarım garantisini
|
||
// kullanması. Not: Electron↔backend bacağı için bayt-bazlı devam etme (resume)
|
||
// henüz yok, bkz. PR açıklaması — kuyruk seviyesinde tüm dosya yeniden denenir.
|
||
export async function uploadFileResumable(accessToken: string, params: UploadParams): Promise<{ id: string; modifiedTime?: string }> {
|
||
const isUpdate = !!params.existingFileId;
|
||
const sessionUrl = isUpdate
|
||
? `https://www.googleapis.com/upload/drive/v3/files/${params.existingFileId}?uploadType=resumable`
|
||
: `https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable`;
|
||
const metadata = isUpdate ? {} : { name: params.name, parents: [params.parentId] };
|
||
|
||
const sessionRes = await fetch(sessionUrl, {
|
||
method: isUpdate ? 'PATCH' : 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
'Content-Type': 'application/json; charset=UTF-8',
|
||
'X-Upload-Content-Type': params.mimeType,
|
||
'X-Upload-Content-Length': String(params.buffer.length),
|
||
},
|
||
body: JSON.stringify(metadata),
|
||
});
|
||
if (!sessionRes.ok) {
|
||
const err = await sessionRes.json().catch(() => ({}));
|
||
throw new Error(err.error?.message || 'Google Drive upload oturumu başlatılamadı.');
|
||
}
|
||
const sessionUri = sessionRes.headers.get('location');
|
||
if (!sessionUri) {
|
||
throw new Error('Google Drive upload oturum adresi alınamadı.');
|
||
}
|
||
|
||
const uploadRes = await fetch(sessionUri, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': params.mimeType,
|
||
'Content-Length': String(params.buffer.length),
|
||
},
|
||
// Node'un fetch implementasyonu (undici) Buffer'ı runtime'da sorunsuz kabul
|
||
// ediyor — DOM tip tanımları BodyInit'e Buffer'ı dahil etmediği için sadece
|
||
// tip seviyesinde cast gerekiyor.
|
||
body: params.buffer as unknown as BodyInit,
|
||
});
|
||
const data = await uploadRes.json().catch(() => ({}));
|
||
if (!uploadRes.ok) {
|
||
throw new Error(data.error?.message || `Google Drive upload başarısız (HTTP ${uploadRes.status}).`);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
// Faz 3 — Restore: bir dosyanın ham baytlarını Drive'dan indirir. Electron'un
|
||
// Google credential'ı hiç olmadığı için (upload'ta olduğu gibi) bu da backend
|
||
// üzerinden proxy'leniyor — çağıran taraf (googleDrive.controller.ts) fileId'nin
|
||
// gerçekten bu kullanıcıya ait olduğunu ÖNCE doğrulamalı.
|
||
export async function downloadFile(accessToken: string, fileId: string): Promise<Buffer> {
|
||
const res = await fetch(`${DRIVE_API_URL}/files/${fileId}?alt=media`, {
|
||
headers: { Authorization: `Bearer ${accessToken}` },
|
||
});
|
||
if (!res.ok) {
|
||
throw new Error(`Google Drive dosyası indirilemedi (HTTP ${res.status}).`);
|
||
}
|
||
const arrayBuffer = await res.arrayBuffer();
|
||
return Buffer.from(arrayBuffer);
|
||
}
|