Feature: add Google Drive backup and restore integration

This commit is contained in:
Ponte
2026-02-11 20:19:16 +01:00
parent 621beee036
commit 81d966b64a
9 changed files with 484 additions and 0 deletions

View File

@@ -12,3 +12,10 @@ VG_DB_PASSWORD=change_me
# Auth de l'interface web # Auth de l'interface web
APP_BASIC_AUTH_USER=beuz APP_BASIC_AUTH_USER=beuz
APP_BASIC_AUTH_PASSWORD=change_me_now 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

View File

@@ -71,6 +71,13 @@ API_PORT=7002
VG_DB_NAME=video_games VG_DB_NAME=video_games
VG_DB_USER=video_games_user VG_DB_USER=video_games_user
VG_DB_PASSWORD=change_me 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. 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`) - POST `/api/backup/restore` (modes `merge` ou `replace`)
- snapshot auto en base avant restore `replace` (`backup_snapshots`) - snapshot auto en base avant restore `replace` (`backup_snapshots`)
- actions accessibles dans le panneau lateral `Outils` - 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) ## Import Excel (COLLECTIONS.xlsx)

View File

@@ -8,6 +8,7 @@
"start": "node server.js" "start": "node server.js"
}, },
"dependencies": { "dependencies": {
"googleapis": "^150.0.1",
"pg": "^8.16.3" "pg": "^8.16.3"
} }
} }

View File

@@ -1,15 +1,23 @@
const http = require("http"); const http = require("http");
const crypto = require("crypto");
const { Pool } = require("pg"); const { Pool } = require("pg");
const { google } = require("googleapis");
const port = Number(process.env.API_INTERNAL_PORT || 3001); const port = Number(process.env.API_INTERNAL_PORT || 3001);
const serviceName = process.env.SERVICE_NAME || "video-games-api"; const serviceName = process.env.SERVICE_NAME || "video-games-api";
const databaseUrl = const databaseUrl =
process.env.DATABASE_URL || process.env.DATABASE_URL ||
"postgres://video_games_user:change_me@video-games-db:5432/video_games"; "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({ const pool = new Pool({
connectionString: databaseUrl, connectionString: databaseUrl,
}); });
const oauthStateCache = new Map();
function sendJson(response, statusCode, payload) { function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" }); response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
@@ -129,6 +137,18 @@ async function runMigrations() {
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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(` await pool.query(`
CREATE OR REPLACE FUNCTION set_updated_at() 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) { async function handleRequest(request, response) {
const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`); 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") { if (request.method === "GET" && url.pathname === "/health") {
try { try {
await pool.query("SELECT 1;"); await pool.query("SELECT 1;");
@@ -900,6 +1165,57 @@ async function handleRequest(request, response) {
return; 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") { if (request.method === "POST" && url.pathname === "/api/backup/restore") {
try { try {
const body = await readJsonBody(request); const body = await readJsonBody(request);

105
app.js
View File

@@ -46,6 +46,10 @@ const backupBtn = document.getElementById("backupBtn");
const restoreMergeBtn = document.getElementById("restoreMergeBtn"); const restoreMergeBtn = document.getElementById("restoreMergeBtn");
const restoreReplaceBtn = document.getElementById("restoreReplaceBtn"); const restoreReplaceBtn = document.getElementById("restoreReplaceBtn");
const restoreFileInput = document.getElementById("restoreFileInput"); 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 quickSearchInput = document.getElementById("quickSearchInput");
const quickSearchResults = document.getElementById("quickSearchResults"); const quickSearchResults = document.getElementById("quickSearchResults");
const gamesList = document.getElementById("gamesList"); const gamesList = document.getElementById("gamesList");
@@ -53,6 +57,7 @@ const gameCardTemplate = document.getElementById("gameCardTemplate");
let editingGameId = null; let editingGameId = null;
let pendingRestoreMode = "merge"; let pendingRestoreMode = "merge";
let quickSearchTerm = ""; let quickSearchTerm = "";
let googleStatus = { configured: false, connected: false, email: "" };
toolsToggleBtn.addEventListener("click", () => { toolsToggleBtn.addEventListener("click", () => {
toolsDrawer.classList.toggle("open"); 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) => { quickSearchInput.addEventListener("input", (event) => {
quickSearchTerm = event.target.value.trim(); quickSearchTerm = event.target.value.trim();
renderSearchResults(); renderSearchResults();
@@ -435,6 +482,7 @@ restoreFileInput.addEventListener("change", async (event) => {
function render() { function render() {
renderDataMode(); renderDataMode();
renderGoogleStatus();
renderBrandTabs(); renderBrandTabs();
renderConsoleTabs(); renderConsoleTabs();
renderGames(); 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) { function findBrandByConsole(consoleName) {
for (const [brand, consoles] of Object.entries(state.brands)) { for (const [brand, consoles] of Object.entries(state.brands)) {
if (Array.isArray(consoles) && consoles.includes(consoleName)) { if (Array.isArray(consoles) && consoles.includes(consoleName)) {
@@ -829,6 +905,15 @@ async function refreshFromApi(preferredBrand, preferredConsole) {
render(); render();
} }
async function refreshGoogleStatus() {
try {
googleStatus = await apiRequest("/api/google/status");
} catch {
googleStatus = { configured: false, connected: false, email: "" };
}
renderGoogleStatus();
}
async function hydrateFromApi() { async function hydrateFromApi() {
try { try {
await refreshFromApi(state.selectedBrand, state.selectedConsole); await refreshFromApi(state.selectedBrand, state.selectedConsole);
@@ -839,9 +924,29 @@ async function hydrateFromApi() {
} }
async function bootstrap() { async function bootstrap() {
await refreshGoogleStatus();
await hydrateFromApi(); await hydrateFromApi();
normalizeState(); normalizeState();
render(); render();
handleGoogleCallbackResult();
} }
bootstrap(); 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());
}

View File

@@ -28,6 +28,11 @@ services:
- SERVICE_NAME=video-games-api - SERVICE_NAME=video-games-api
- API_INTERNAL_PORT=3001 - 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} - 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: ports:
- "${API_PORT:-7002}:3001" - "${API_PORT:-7002}:3001"

View File

@@ -30,6 +30,16 @@
<button id="restoreReplaceBtn" type="button" class="btn-secondary">Restaurer (remplacement)</button> <button id="restoreReplaceBtn" type="button" class="btn-secondary">Restaurer (remplacement)</button>
<input id="restoreFileInput" type="file" accept="application/json" class="hidden" /> <input id="restoreFileInput" type="file" accept="application/json" class="hidden" />
</div> </div>
<div class="google-section">
<h3 class="google-title">☁ Google Drive Backup</h3>
<p id="googleDriveState" class="google-state">Etat Google Drive: detection...</p>
<div class="backup-controls">
<button id="googleConnectBtn" type="button" class="btn-secondary">Connecter Google</button>
<button id="googleBackupBtn" type="button" class="btn-secondary">Sauvegarder sur Drive</button>
<button id="googleRestoreBtn" type="button" class="btn-secondary">Restaurer depuis Drive</button>
</div>
</div>
</aside> </aside>
<main class="app-shell"> <main class="app-shell">

View File

@@ -16,6 +16,16 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; 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 { location = /health {
auth_basic off; auth_basic off;
proxy_pass http://video-games-api:3001/health; proxy_pass http://video-games-api:3001/health;

View File

@@ -86,6 +86,22 @@ body {
padding: 0.45rem 0.6rem; 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 { .app-shell {
width: min(1100px, 94vw); width: min(1100px, 94vw);
margin: 2rem auto; margin: 2rem auto;