Feature: add Google Drive backup and restore integration
This commit is contained in:
@@ -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
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
316
api/server.js
316
api/server.js
@@ -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
105
app.js
@@ -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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
10
index.html
10
index.html
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
16
styles.css
16
styles.css
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user