Feature: add Google Drive backup and restore integration
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"googleapis": "^150.0.1",
|
||||
"pg": "^8.16.3"
|
||||
}
|
||||
}
|
||||
|
||||
316
api/server.js
316
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);
|
||||
|
||||
Reference in New Issue
Block a user