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.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
// Google OAuth "state" parametresi: CSRF'e karşı kriptografik olarak rastgele,
|
||||||
|
// kısa ömürlü, tek kullanımlık (bkz. PRD §20). Bellek-içi Map yeterli — OAuth
|
||||||
|
// akışı saniyeler/dakikalar içinde tamamlanıyor, kalıcı depolamaya gerek yok.
|
||||||
|
// NOT: backend birden fazla instance'la (load balancer arkasında) çalışırsa bu
|
||||||
|
// state farklı bir instance'a düşebilir — o zaman paylaşımlı bir store (Redis
|
||||||
|
// vb.) gerekir. Şu an tek instance varsayımıyla yazıldı.
|
||||||
|
interface StateEntry { userId: string; expiresAt: number; }
|
||||||
|
|
||||||
|
const STATE_TTL_MS = 10 * 60 * 1000; // 10 dakika
|
||||||
|
const stateStore = new Map<string, StateEntry>();
|
||||||
|
|
||||||
|
function cleanupExpired() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of stateStore.entries()) {
|
||||||
|
if (entry.expiresAt < now) stateStore.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createState(userId: string): string {
|
||||||
|
cleanupExpired();
|
||||||
|
const state = crypto.randomBytes(32).toString('hex');
|
||||||
|
stateStore.set(state, { userId, expiresAt: Date.now() + STATE_TTL_MS });
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tek kullanımlık — çağrıldığı anda store'dan siliniyor, ikinci kullanım (replay)
|
||||||
|
// her zaman null döner.
|
||||||
|
export function consumeState(state: string): string | null {
|
||||||
|
const entry = stateStore.get(state);
|
||||||
|
if (!entry) return null;
|
||||||
|
stateStore.delete(state);
|
||||||
|
if (entry.expiresAt < Date.now()) return null;
|
||||||
|
return entry.userId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
// Google OAuth tokenları asla plaintext saklanmaz (bkz. PRD §6) — AES-256-GCM
|
||||||
|
// ile şifrelenip tek bir string olarak (iv:authTag:ciphertext, hepsi base64)
|
||||||
|
// veritabanına yazılır. Node'un yerleşik crypto modülü kullanılıyor, ek
|
||||||
|
// bağımlılık gerekmiyor.
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
|
||||||
|
function getKey(): Buffer {
|
||||||
|
const raw = process.env.GOOGLE_TOKEN_ENCRYPTION_KEY;
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error('GOOGLE_TOKEN_ENCRYPTION_KEY ortam değişkeni tanımlı değil.');
|
||||||
|
}
|
||||||
|
const buf = raw.length === 64 ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64');
|
||||||
|
if (buf.length !== 32) {
|
||||||
|
throw new Error('GOOGLE_TOKEN_ENCRYPTION_KEY 32 byte olmalı (64 karakterlik hex ya da base64 olarak sağlayın).');
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 authTag = cipher.getAuthTag();
|
||||||
|
return [iv, authTag, encrypted].map((b) => b.toString('base64')).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptToken(stored: string): string {
|
||||||
|
const key = getKey();
|
||||||
|
const [ivB64, tagB64, dataB64] = stored.split(':');
|
||||||
|
if (!ivB64 || !tagB64 || !dataB64) {
|
||||||
|
throw new Error('Şifrelenmiş token formatı geçersiz.');
|
||||||
|
}
|
||||||
|
const iv = Buffer.from(ivB64, 'base64');
|
||||||
|
const authTag = Buffer.from(tagB64, 'base64');
|
||||||
|
const data = Buffer.from(dataB64, 'base64');
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../middleware/auth';
|
||||||
|
import { connect, oauthCallback, getStatus, disconnect, refresh } from '../controllers/googleDrive.controller';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Google'ın doğrudan (Bearer token olmadan) yönlendirdiği tek public uç nokta.
|
||||||
|
router.get('/oauth/callback', oauthCallback);
|
||||||
|
|
||||||
|
router.get('/connect', requireAuth, connect);
|
||||||
|
router.get('/status', requireAuth, getStatus);
|
||||||
|
router.post('/disconnect', requireAuth, disconnect);
|
||||||
|
router.post('/refresh', requireAuth, refresh);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -5,6 +5,7 @@ import precedentRoutes from './precedent.routes';
|
|||||||
import caseRoutes from './case.routes';
|
import caseRoutes from './case.routes';
|
||||||
import templateRoutes from './template.routes';
|
import templateRoutes from './template.routes';
|
||||||
import draftingRoutes from './drafting.routes';
|
import draftingRoutes from './drafting.routes';
|
||||||
|
import googleDriveRoutes from './googleDrive.routes';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ router.use('/chat', chatRoutes);
|
|||||||
router.use('/cases', caseRoutes);
|
router.use('/cases', caseRoutes);
|
||||||
router.use('/templates', templateRoutes);
|
router.use('/templates', templateRoutes);
|
||||||
router.use('/drafting', draftingRoutes);
|
router.use('/drafting', draftingRoutes);
|
||||||
|
router.use('/google-drive', googleDriveRoutes);
|
||||||
router.use('/', precedentRoutes); // Keeps the same paths like /api/search-precedents
|
router.use('/', precedentRoutes); // Keeps the same paths like /api/search-precedents
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
Reference in New Issue
Block a user