diff --git a/.env.example b/.env.example index 31d6d28..1bba6e8 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,10 @@ VG_DB_PASSWORD=change_me # Auth de l'interface web APP_BASIC_AUTH_USER=beuz APP_BASIC_AUTH_PASSWORD=change_me_now + +# Google Drive OAuth +PUBLIC_BASE_URL=http://localhost:7001 +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI= +GOOGLE_DRIVE_FOLDER_NAME=BeuzGamesBackups diff --git a/README.md b/README.md index 7c84667..4e5dddb 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,13 @@ API_PORT=7002 VG_DB_NAME=video_games VG_DB_USER=video_games_user VG_DB_PASSWORD=change_me +APP_BASIC_AUTH_USER=beuz +APP_BASIC_AUTH_PASSWORD=change_me_now +PUBLIC_BASE_URL=http://localhost:7001 +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI= +GOOGLE_DRIVE_FOLDER_NAME=BeuzGamesBackups ``` Tu peux mettre `7000` si ce port est libre sur ta machine. @@ -149,6 +156,13 @@ git pull - POST `/api/backup/restore` (modes `merge` ou `replace`) - snapshot auto en base avant restore `replace` (`backup_snapshots`) - actions accessibles dans le panneau lateral `Outils` +- Etape 7: sauvegarde/restauration Google Drive (OAuth) + - GET `/api/google/status` + - GET `/api/google/connect-url` + - GET `/auth/google/callback` + - POST `/api/google/backup/upload` + - GET `/api/google/backups` + - POST `/api/google/backup/restore` ## Import Excel (COLLECTIONS.xlsx) diff --git a/api/package.json b/api/package.json index 006a4b4..53411ef 100644 --- a/api/package.json +++ b/api/package.json @@ -8,6 +8,7 @@ "start": "node server.js" }, "dependencies": { + "googleapis": "^150.0.1", "pg": "^8.16.3" } } diff --git a/api/server.js b/api/server.js index 00973e0..3bca89d 100644 --- a/api/server.js +++ b/api/server.js @@ -1,15 +1,23 @@ const http = require("http"); +const crypto = require("crypto"); const { Pool } = require("pg"); +const { google } = require("googleapis"); const port = Number(process.env.API_INTERNAL_PORT || 3001); const serviceName = process.env.SERVICE_NAME || "video-games-api"; const databaseUrl = process.env.DATABASE_URL || "postgres://video_games_user:change_me@video-games-db:5432/video_games"; +const publicBaseUrl = process.env.PUBLIC_BASE_URL || "http://localhost:7001"; +const googleClientId = process.env.GOOGLE_CLIENT_ID || ""; +const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET || ""; +const googleRedirectUri = process.env.GOOGLE_REDIRECT_URI || ""; +const googleDriveFolderName = process.env.GOOGLE_DRIVE_FOLDER_NAME || "BeuzGamesBackups"; const pool = new Pool({ connectionString: databaseUrl, }); +const oauthStateCache = new Map(); function sendJson(response, statusCode, payload) { response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" }); @@ -129,6 +137,18 @@ async function runMigrations() { created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); `); + await pool.query(` + CREATE TABLE IF NOT EXISTS google_drive_auth ( + id BIGSERIAL PRIMARY KEY, + provider TEXT NOT NULL UNIQUE, + refresh_token TEXT, + access_token TEXT, + expiry_date BIGINT, + scope TEXT, + email TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); await pool.query(` CREATE OR REPLACE FUNCTION set_updated_at() @@ -804,9 +824,254 @@ async function importCatalog(payload) { } } +function isGoogleConfigured() { + return Boolean(googleClientId && googleClientSecret && googleRedirectUri); +} + +function buildGoogleOAuthClient() { + if (!isGoogleConfigured()) { + throw new Error("Google OAuth is not configured"); + } + return new google.auth.OAuth2(googleClientId, googleClientSecret, googleRedirectUri); +} + +async function getGoogleAuthRow() { + const result = await pool.query( + "SELECT * FROM google_drive_auth WHERE provider = 'google' ORDER BY id ASC LIMIT 1;", + ); + return result.rows[0] || null; +} + +async function upsertGoogleAuthRow(data) { + await pool.query( + ` + INSERT INTO google_drive_auth(provider, refresh_token, access_token, expiry_date, scope, email, updated_at) + VALUES ('google', $1, $2, $3, $4, $5, NOW()) + ON CONFLICT (provider) + DO UPDATE SET + refresh_token = COALESCE(EXCLUDED.refresh_token, google_drive_auth.refresh_token), + access_token = EXCLUDED.access_token, + expiry_date = EXCLUDED.expiry_date, + scope = EXCLUDED.scope, + email = EXCLUDED.email, + updated_at = NOW(); + `, + [ + data.refreshToken || null, + data.accessToken || null, + data.expiryDate || null, + data.scope || null, + data.email || null, + ], + ); +} + +function getScopes() { + return [ + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/userinfo.email", + "openid", + ]; +} + +async function buildGoogleAuthUrl() { + const oauth2Client = buildGoogleOAuthClient(); + const state = crypto.randomBytes(24).toString("hex"); + oauthStateCache.set(state, Date.now() + 10 * 60 * 1000); + + const url = oauth2Client.generateAuthUrl({ + access_type: "offline", + prompt: "consent", + scope: getScopes(), + state, + }); + + return url; +} + +function cleanupExpiredStates() { + const now = Date.now(); + for (const [state, expiresAt] of oauthStateCache.entries()) { + if (expiresAt < now) { + oauthStateCache.delete(state); + } + } +} + +async function handleGoogleCallback(urlObj) { + cleanupExpiredStates(); + + const code = urlObj.searchParams.get("code"); + const state = urlObj.searchParams.get("state"); + if (!code || !state || !oauthStateCache.has(state)) { + throw new Error("Invalid OAuth callback state"); + } + oauthStateCache.delete(state); + + const oauth2Client = buildGoogleOAuthClient(); + const { tokens } = await oauth2Client.getToken(code); + oauth2Client.setCredentials(tokens); + + const oauth2 = google.oauth2({ auth: oauth2Client, version: "v2" }); + const me = await oauth2.userinfo.get(); + const email = (me.data && me.data.email) || ""; + + await upsertGoogleAuthRow({ + refreshToken: tokens.refresh_token || null, + accessToken: tokens.access_token || null, + expiryDate: tokens.expiry_date || null, + scope: tokens.scope || "", + email, + }); +} + +async function getGoogleStatus() { + if (!isGoogleConfigured()) { + return { configured: false, connected: false }; + } + + const row = await getGoogleAuthRow(); + const connected = Boolean(row && row.refresh_token); + return { + configured: true, + connected, + email: connected ? row.email || "" : "", + }; +} + +async function getGoogleDriveClient() { + const status = await getGoogleStatus(); + if (!status.configured) { + throw new Error("Google OAuth is not configured"); + } + if (!status.connected) { + throw new Error("Google account is not connected"); + } + + const row = await getGoogleAuthRow(); + const oauth2Client = buildGoogleOAuthClient(); + oauth2Client.setCredentials({ + refresh_token: row.refresh_token, + access_token: row.access_token || undefined, + expiry_date: row.expiry_date || undefined, + }); + + return google.drive({ version: "v3", auth: oauth2Client }); +} + +async function ensureDriveFolder(drive) { + const query = [ + "mimeType='application/vnd.google-apps.folder'", + `name='${googleDriveFolderName.replace(/'/g, "\\'")}'`, + "trashed=false", + ].join(" and "); + + const found = await drive.files.list({ + q: query, + fields: "files(id,name)", + pageSize: 1, + }); + + if (found.data.files && found.data.files.length) { + return found.data.files[0].id; + } + + const created = await drive.files.create({ + requestBody: { + name: googleDriveFolderName, + mimeType: "application/vnd.google-apps.folder", + }, + fields: "id,name", + }); + + return created.data.id; +} + +async function uploadBackupToGoogleDrive() { + const drive = await getGoogleDriveClient(); + const dump = await exportCatalogDump(); + const folderId = await ensureDriveFolder(drive); + const fileName = `video-games-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.json`; + + const created = await drive.files.create({ + requestBody: { + name: fileName, + parents: [folderId], + mimeType: "application/json", + }, + media: { + mimeType: "application/json", + body: JSON.stringify(dump, null, 2), + }, + fields: "id,name,createdTime", + }); + + return { + fileId: created.data.id, + fileName: created.data.name, + createdTime: created.data.createdTime, + }; +} + +async function listGoogleBackups() { + const drive = await getGoogleDriveClient(); + const folderId = await ensureDriveFolder(drive); + const query = [ + `'${folderId}' in parents`, + "mimeType='application/json'", + "trashed=false", + ].join(" and "); + + const found = await drive.files.list({ + q: query, + pageSize: 50, + orderBy: "createdTime desc", + fields: "files(id,name,createdTime,size)", + }); + + return found.data.files || []; +} + +async function restoreFromGoogleBackup(fileId, mode) { + const drive = await getGoogleDriveClient(); + + let targetId = fileId || ""; + if (!targetId) { + const files = await listGoogleBackups(); + if (!files.length) { + throw new Error("No Google Drive backup found"); + } + targetId = files[0].id; + } + + const file = await drive.files.get( + { + fileId: targetId, + alt: "media", + }, + { responseType: "json" }, + ); + + const dump = file.data; + const result = await restoreCatalogDump(mode || "merge", dump); + return { fileId: targetId, ...result }; +} + async function handleRequest(request, response) { const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`); + if (request.method === "GET" && url.pathname === "/auth/google/callback") { + try { + await handleGoogleCallback(url); + response.writeHead(302, { Location: `${publicBaseUrl}/?google=connected` }); + response.end(); + } catch (error) { + response.writeHead(302, { Location: `${publicBaseUrl}/?google=error` }); + response.end(); + } + return; + } + if (request.method === "GET" && url.pathname === "/health") { try { await pool.query("SELECT 1;"); @@ -900,6 +1165,57 @@ async function handleRequest(request, response) { return; } + if (request.method === "GET" && url.pathname === "/api/google/status") { + try { + const status = await getGoogleStatus(); + sendJson(response, 200, status); + } catch (error) { + sendJson(response, 500, { status: "error", message: error.message }); + } + return; + } + + if (request.method === "GET" && url.pathname === "/api/google/connect-url") { + try { + const connectUrl = await buildGoogleAuthUrl(); + sendJson(response, 200, { url: connectUrl }); + } catch (error) { + sendJson(response, 400, { status: "error", message: error.message }); + } + return; + } + + if (request.method === "POST" && url.pathname === "/api/google/backup/upload") { + try { + const result = await uploadBackupToGoogleDrive(); + sendJson(response, 200, { status: "ok", ...result }); + } catch (error) { + sendJson(response, 400, { status: "error", message: error.message }); + } + return; + } + + if (request.method === "GET" && url.pathname === "/api/google/backups") { + try { + const files = await listGoogleBackups(); + sendJson(response, 200, { files }); + } catch (error) { + sendJson(response, 400, { status: "error", message: error.message }); + } + return; + } + + if (request.method === "POST" && url.pathname === "/api/google/backup/restore") { + try { + const body = await readJsonBody(request); + const result = await restoreFromGoogleBackup(body.fileId || "", body.mode || "merge"); + sendJson(response, 200, { status: "ok", ...result }); + } catch (error) { + sendJson(response, 400, { status: "error", message: error.message }); + } + return; + } + if (request.method === "POST" && url.pathname === "/api/backup/restore") { try { const body = await readJsonBody(request); diff --git a/app.js b/app.js index e9cab21..8273a9f 100644 --- a/app.js +++ b/app.js @@ -46,6 +46,10 @@ const backupBtn = document.getElementById("backupBtn"); const restoreMergeBtn = document.getElementById("restoreMergeBtn"); const restoreReplaceBtn = document.getElementById("restoreReplaceBtn"); const restoreFileInput = document.getElementById("restoreFileInput"); +const googleDriveState = document.getElementById("googleDriveState"); +const googleConnectBtn = document.getElementById("googleConnectBtn"); +const googleBackupBtn = document.getElementById("googleBackupBtn"); +const googleRestoreBtn = document.getElementById("googleRestoreBtn"); const quickSearchInput = document.getElementById("quickSearchInput"); const quickSearchResults = document.getElementById("quickSearchResults"); const gamesList = document.getElementById("gamesList"); @@ -53,6 +57,7 @@ const gameCardTemplate = document.getElementById("gameCardTemplate"); let editingGameId = null; let pendingRestoreMode = "merge"; let quickSearchTerm = ""; +let googleStatus = { configured: false, connected: false, email: "" }; toolsToggleBtn.addEventListener("click", () => { toolsDrawer.classList.toggle("open"); @@ -81,6 +86,48 @@ document.addEventListener("keydown", (event) => { } }); +googleConnectBtn.addEventListener("click", async () => { + try { + const payload = await apiRequest("/api/google/connect-url"); + if (!payload.url) { + throw new Error("Missing Google connect URL"); + } + window.location.href = payload.url; + } catch (error) { + console.error(error); + alert("Connexion Google indisponible. Verifie la configuration OAuth."); + } +}); + +googleBackupBtn.addEventListener("click", async () => { + try { + const payload = await apiRequest("/api/google/backup/upload", { method: "POST" }); + await refreshGoogleStatus(); + alert(`Sauvegarde Drive OK: ${payload.fileName || "fichier cree"}.`); + } catch (error) { + console.error(error); + alert("Echec sauvegarde Google Drive."); + } +}); + +googleRestoreBtn.addEventListener("click", async () => { + const confirmed = window.confirm("Restaurer depuis le dernier backup Google Drive ?"); + if (!confirmed) { + return; + } + try { + const payload = await apiRequest("/api/google/backup/restore", { + method: "POST", + body: { mode: "merge" }, + }); + await refreshFromApi(state.selectedBrand, state.selectedConsole); + alert(`Restauration Drive OK (${payload.mode})`); + } catch (error) { + console.error(error); + alert("Echec restauration Google Drive."); + } +}); + quickSearchInput.addEventListener("input", (event) => { quickSearchTerm = event.target.value.trim(); renderSearchResults(); @@ -435,6 +482,7 @@ restoreFileInput.addEventListener("change", async (event) => { function render() { renderDataMode(); + renderGoogleStatus(); renderBrandTabs(); renderConsoleTabs(); renderGames(); @@ -491,6 +539,34 @@ function renderDataMode() { } } +function renderGoogleStatus() { + if (!googleDriveState) { + return; + } + + if (!googleStatus.configured) { + googleDriveState.textContent = "Etat Google Drive: non configure (OAuth manquant)."; + googleConnectBtn.disabled = true; + googleBackupBtn.disabled = true; + googleRestoreBtn.disabled = true; + return; + } + + if (!googleStatus.connected) { + googleDriveState.textContent = "Etat Google Drive: non connecte."; + googleConnectBtn.disabled = false; + googleBackupBtn.disabled = true; + googleRestoreBtn.disabled = true; + return; + } + + const email = googleStatus.email ? ` (${googleStatus.email})` : ""; + googleDriveState.textContent = `Etat Google Drive: connecte${email}.`; + googleConnectBtn.disabled = false; + googleBackupBtn.disabled = false; + googleRestoreBtn.disabled = false; +} + function findBrandByConsole(consoleName) { for (const [brand, consoles] of Object.entries(state.brands)) { if (Array.isArray(consoles) && consoles.includes(consoleName)) { @@ -829,6 +905,15 @@ async function refreshFromApi(preferredBrand, preferredConsole) { render(); } +async function refreshGoogleStatus() { + try { + googleStatus = await apiRequest("/api/google/status"); + } catch { + googleStatus = { configured: false, connected: false, email: "" }; + } + renderGoogleStatus(); +} + async function hydrateFromApi() { try { await refreshFromApi(state.selectedBrand, state.selectedConsole); @@ -839,9 +924,29 @@ async function hydrateFromApi() { } async function bootstrap() { + await refreshGoogleStatus(); await hydrateFromApi(); normalizeState(); render(); + handleGoogleCallbackResult(); } bootstrap(); + +function handleGoogleCallbackResult() { + const url = new URL(window.location.href); + const googleParam = url.searchParams.get("google"); + if (!googleParam) { + return; + } + + if (googleParam === "connected") { + alert("Google Drive connecte avec succes."); + refreshGoogleStatus(); + } else if (googleParam === "error") { + alert("Connexion Google Drive echouee."); + } + + url.searchParams.delete("google"); + window.history.replaceState({}, "", url.toString()); +} diff --git a/docker-compose.yml b/docker-compose.yml index f72bf07..1efa8c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,11 @@ services: - SERVICE_NAME=video-games-api - API_INTERNAL_PORT=3001 - DATABASE_URL=postgres://${VG_DB_USER:-video_games_user}:${VG_DB_PASSWORD:-change_me}@video-games-db:5432/${VG_DB_NAME:-video_games} + - PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:7001} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} + - GOOGLE_REDIRECT_URI=${GOOGLE_REDIRECT_URI:-} + - GOOGLE_DRIVE_FOLDER_NAME=${GOOGLE_DRIVE_FOLDER_NAME:-BeuzGamesBackups} ports: - "${API_PORT:-7002}:3001" diff --git a/index.html b/index.html index 1fd2f50..9bab262 100644 --- a/index.html +++ b/index.html @@ -30,6 +30,16 @@ + +
+

☁ Google Drive Backup

+

Etat Google Drive: detection...

+
+ + + +
+
diff --git a/nginx/default.conf b/nginx/default.conf index ba22ce0..8b6c43d 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -16,6 +16,16 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location = /auth/google/callback { + auth_basic off; + proxy_pass http://video-games-api:3001/auth/google/callback; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location = /health { auth_basic off; proxy_pass http://video-games-api:3001/health; diff --git a/styles.css b/styles.css index d3abc44..2b64e8d 100644 --- a/styles.css +++ b/styles.css @@ -86,6 +86,22 @@ body { padding: 0.45rem 0.6rem; } +.google-section { + border-top: 1px solid var(--border); + padding-top: 0.8rem; +} + +.google-title { + margin: 0 0 0.5rem; + font-size: 0.98rem; +} + +.google-state { + margin: 0 0 0.7rem; + font-size: 0.86rem; + color: var(--muted); +} + .app-shell { width: min(1100px, 94vw); margin: 2rem auto;