Files
oikos/server/services/google-calendar.js
T
Ulas 3d2604bab9 fix(security): address critical and high findings from security audit
Fix stored XSS in tasks (titles/subtasks) and settings (member list)
by applying escHtml(). Harden trust proxy to loopback default, add
OAuth state parameter for Google Calendar CSRF protection, sanitize
CSV export against formula injection, invalidate sessions on user
deletion, restrict usernames to alphanumeric chars, and require admin
role for calendar sync triggers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 17:28:36 +02:00

335 lines
12 KiB
JavaScript

/**
* Modul: Google Calendar Sync
* Zweck: OAuth 2.0 + bidirektionaler Sync mit Google Calendar API v3
* Abhängigkeiten: googleapis, server/db.js
*
* sync_config-Schlüssel:
* google_access_token - OAuth Access Token
* google_refresh_token - OAuth Refresh Token (langlebig)
* google_token_expiry - ISO-8601-Timestamp bis wann Access Token gültig ist
* google_sync_token - Inkrementeller Sync-Token von Google (events.list)
* google_last_sync - ISO-8601-Timestamp des letzten erfolgreichen Syncs
*/
'use strict';
const { google } = require('googleapis');
const crypto = require('crypto');
const db = require('../db');
const GOOGLE_COLOR = '#4285F4';
// --------------------------------------------------------
// OAuth2-Client (lazy initialisiert)
// --------------------------------------------------------
function createClient() {
const clientId = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
const redirectUri = process.env.GOOGLE_REDIRECT_URI;
if (!clientId || !clientSecret || !redirectUri) {
throw new Error('[Google] GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET und GOOGLE_REDIRECT_URI müssen gesetzt sein.');
}
return new google.auth.OAuth2(clientId, clientSecret, redirectUri);
}
// --------------------------------------------------------
// sync_config Helfer
// --------------------------------------------------------
function cfgGet(key) {
const row = db.get().prepare('SELECT value FROM sync_config WHERE key = ?').get(key);
return row ? row.value : null;
}
function cfgSet(key, value) {
db.get().prepare(`
INSERT INTO sync_config (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
`).run(key, value);
}
function cfgDel(key) {
db.get().prepare('DELETE FROM sync_config WHERE key = ?').run(key);
}
// --------------------------------------------------------
// Client mit gespeicherten Tokens laden
// --------------------------------------------------------
function loadAuthorizedClient() {
const accessToken = cfgGet('google_access_token');
const refreshToken = cfgGet('google_refresh_token');
if (!accessToken || !refreshToken) {
throw new Error('[Google] Nicht konfiguriert - zuerst OAuth durchführen.');
}
const client = createClient();
client.setCredentials({
access_token: accessToken,
refresh_token: refreshToken,
expiry_date: cfgGet('google_token_expiry') ? parseInt(cfgGet('google_token_expiry'), 10) : undefined,
});
// Token-Refresh automatisch speichern
client.on('tokens', (tokens) => {
if (tokens.access_token) cfgSet('google_access_token', tokens.access_token);
if (tokens.expiry_date) cfgSet('google_token_expiry', String(tokens.expiry_date));
});
return client;
}
// --------------------------------------------------------
// Öffentliche API
// --------------------------------------------------------
/**
* Generiert die Google OAuth2-URL zum Weiterleiten des Admins.
* @returns {string} Auth-URL
*/
/**
* Generiert die Google OAuth2-URL zum Weiterleiten des Admins.
* Enthalt einen CSRF-sicheren state-Parameter.
* @param {object} session - Express-Session-Objekt (state wird dort gespeichert)
* @returns {string} Auth-URL
*/
function getAuthUrl(session) {
const client = createClient();
const state = crypto.randomBytes(32).toString('hex');
if (session) session.googleOAuthState = state;
return client.generateAuthUrl({
access_type: 'offline',
prompt: 'consent',
scope: ['https://www.googleapis.com/auth/calendar'],
state,
});
}
/**
* OAuth-Callback: tauscht Code gegen Tokens, speichert in sync_config.
* @param {string} code - Code aus dem OAuth-Callback-Query-Parameter
*/
async function handleCallback(code) {
const client = createClient();
const { tokens } = await client.getToken(code);
if (!tokens.refresh_token) {
throw new Error('[Google] Kein Refresh Token erhalten. Bitte Zugriff in Google-Konto widerrufen und erneut verbinden.');
}
cfgSet('google_access_token', tokens.access_token);
cfgSet('google_refresh_token', tokens.refresh_token);
if (tokens.expiry_date) cfgSet('google_token_expiry', String(tokens.expiry_date));
console.log('[Google] OAuth erfolgreich - Tokens gespeichert.');
}
/**
* Verbindungsstatus zurückgeben.
* @returns {{ configured: boolean, connected: boolean, lastSync: string|null }}
*/
function getStatus() {
const configured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET && process.env.GOOGLE_REDIRECT_URI);
const connected = !!(cfgGet('google_access_token') && cfgGet('google_refresh_token'));
const lastSync = cfgGet('google_last_sync');
return { configured, connected, lastSync };
}
/**
* Tokens und Sync-State löschen (Verbindung trennen).
*/
function disconnect() {
['google_access_token', 'google_refresh_token', 'google_token_expiry',
'google_sync_token', 'google_last_sync'].forEach(cfgDel);
console.log('[Google] Verbindung getrennt.');
}
/**
* Bidirektionaler Sync.
* Inbound: Google → lokale DB (Upsert via external_calendar_id)
* Outbound: lokale Termine (external_source='local', external_calendar_id IS NULL) → Google
*/
async function sync() {
const client = loadAuthorizedClient();
const calendar = google.calendar({ version: 'v3', auth: client });
// --------------------------------------------------------
// Inbound: Google → lokal
// --------------------------------------------------------
let syncToken = cfgGet('google_sync_token');
let pageToken = undefined;
let newSyncToken = null;
do {
let listParams = {
calendarId: 'primary',
singleEvents: true,
pageToken,
};
if (syncToken) {
listParams.syncToken = syncToken;
} else {
// Erstsync: letzte 3 Monate + nächste 12 Monate
const timeMin = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString();
const timeMax = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
listParams.timeMin = timeMin;
listParams.timeMax = timeMax;
}
let response;
try {
response = await calendar.events.list(listParams);
} catch (err) {
if (err.code === 410) {
// syncToken abgelaufen → vollständiger Resync
console.warn('[Google] syncToken ungültig - vollständiger Resync.');
cfgDel('google_sync_token');
syncToken = null;
continue;
}
throw err;
}
const items = response.data.items || [];
upsertGoogleEvents(items);
pageToken = response.data.nextPageToken;
newSyncToken = response.data.nextSyncToken || newSyncToken;
} while (pageToken);
if (newSyncToken) cfgSet('google_sync_token', newSyncToken);
// --------------------------------------------------------
// Outbound: lokal → Google
// --------------------------------------------------------
const localEvents = db.get().prepare(`
SELECT * FROM calendar_events
WHERE external_source = 'local' AND external_calendar_id IS NULL
`).all();
for (const event of localEvents) {
try {
const gEvent = localEventToGoogle(event);
const created = await calendar.events.insert({
calendarId: 'primary',
requestBody: gEvent,
});
db.get().prepare(`
UPDATE calendar_events SET external_calendar_id = ?, external_source = 'google' WHERE id = ?
`).run(created.data.id, event.id);
} catch (err) {
console.error(`[Google] Outbound-Fehler für Event ${event.id}:`, err.message);
}
}
cfgSet('google_last_sync', new Date().toISOString());
console.log(`[Google] Sync abgeschlossen - ${localEvents.length} lokal → Google, Inbound via syncToken.`);
}
// --------------------------------------------------------
// Helfer: Google-Event in lokale DB upserten
// --------------------------------------------------------
function upsertGoogleEvents(items) {
const upsert = db.get().prepare(`
INSERT INTO calendar_events
(title, description, start_datetime, end_datetime, all_day,
location, color, external_calendar_id, external_source, recurrence_rule, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'google', ?, 1)
ON CONFLICT(external_calendar_id) DO UPDATE SET
title = excluded.title,
description = excluded.description,
start_datetime = excluded.start_datetime,
end_datetime = excluded.end_datetime,
all_day = excluded.all_day,
location = excluded.location,
recurrence_rule = excluded.recurrence_rule
`);
const del = db.get().prepare(`
DELETE FROM calendar_events WHERE external_calendar_id = ? AND external_source = 'google'
`);
// Erst external_calendar_id UNIQUE index anlegen falls noch nicht vorhanden
// (Migration 2 legt idx_calendar_external_id an, aber kein UNIQUE constraint)
// Wir nutzen stattdessen manuelles Upsert mit SELECT + INSERT/UPDATE
const insertOrUpdate = db.transaction((item) => {
if (item.status === 'cancelled') {
del.run(item.id);
return;
}
const allDay = !!(item.start?.date && !item.start?.dateTime);
const startDt = allDay ? item.start.date : (item.start?.dateTime || item.start?.date);
const endDt = allDay ? (item.end?.date || null) : (item.end?.dateTime || item.end?.date || null);
const title = item.summary || '(kein Titel)';
const description = item.description || null;
const location = item.location || null;
const rrule = item.recurrence ? item.recurrence[0] : null;
const existing = db.get().prepare(
'SELECT id FROM calendar_events WHERE external_calendar_id = ? AND external_source = ?'
).get(item.id, 'google');
if (existing) {
db.get().prepare(`
UPDATE calendar_events
SET title = ?, description = ?, start_datetime = ?, end_datetime = ?,
all_day = ?, location = ?, recurrence_rule = ?
WHERE id = ?
`).run(title, description, startDt, endDt, allDay ? 1 : 0, location, rrule, existing.id);
} else {
db.get().prepare(`
INSERT INTO calendar_events
(title, description, start_datetime, end_datetime, all_day,
location, color, external_calendar_id, external_source, recurrence_rule, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'google', ?, 1)
`).run(title, description, startDt, endDt, allDay ? 1 : 0, location, GOOGLE_COLOR, item.id, rrule);
}
});
for (const item of items) {
try {
insertOrUpdate(item);
} catch (err) {
console.error(`[Google] Upsert-Fehler für Event ${item.id}:`, err.message);
}
}
}
// --------------------------------------------------------
// Helfer: lokales Event → Google Calendar Event Format
// --------------------------------------------------------
function localEventToGoogle(event) {
const allDay = !!event.all_day;
const gEvent = {
summary: event.title,
description: event.description || undefined,
location: event.location || undefined,
};
if (allDay) {
gEvent.start = { date: event.start_datetime.slice(0, 10) };
gEvent.end = { date: event.end_datetime ? event.end_datetime.slice(0, 10) : event.start_datetime.slice(0, 10) };
} else {
gEvent.start = { dateTime: event.start_datetime, timeZone: 'Europe/Berlin' };
gEvent.end = { dateTime: event.end_datetime || event.start_datetime, timeZone: 'Europe/Berlin' };
}
if (event.recurrence_rule) {
gEvent.recurrence = [event.recurrence_rule];
}
return gEvent;
}
module.exports = { getAuthUrl, handleCallback, getStatus, disconnect, sync };