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

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

View File

@@ -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);