Merge pull request #16 from Spengreb/google-calendar-sync

Google calendar sync
This commit is contained in:
Spengreb 2026-06-01 00:24:24 +02:00 committed by GitHub
commit 4ec1e83337
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1064 additions and 1 deletions

View file

@ -215,3 +215,14 @@ twitch-client-id: null
poll:
max-options: 50
calendar-sync:
enabled: false
# 32-byte key encoded as base64. Prefer setting via env CALENDAR_SYNC_ENCRYPTION_KEY.
encryption-key: ''
google:
client-id: ''
client-secret: ''
# Must exactly match Google OAuth redirect URI
# Example: https://your-domain/api/v1/integrations/google/callback
redirect-uri: ''

View file

@ -109,6 +109,15 @@ var defaults = {
"twitch-client-id": null,
poll: {
"max-options": 50
},
"calendar-sync": {
enabled: false,
"encryption-key": "",
google: {
"client-id": "",
"client-secret": "",
"redirect-uri": ""
}
}
};

View file

@ -0,0 +1,174 @@
const db = require('../database');
function knex() {
return db.getDB().knex;
}
function parseIntegrationRow(row) {
if (!row) return null;
let config = {};
try {
config = row.config_json ? JSON.parse(row.config_json) : {};
} catch (_err) {
config = {};
}
return {
id: row.id,
channel_id: row.channel_id,
provider: row.provider,
status: row.status,
config,
token_encrypted: row.token_encrypted || null,
refresh_token_encrypted: row.refresh_token_encrypted || null,
token_expires_at: row.token_expires_at || null,
last_sync_at: row.last_sync_at || null,
last_error: row.last_error || null,
connected_by: row.connected_by || null,
updated_by: row.updated_by || null,
created_at: row.created_at,
updated_at: row.updated_at
};
}
async function listByChannel(channelId) {
const rows = await knex()('channel_calendar_integrations')
.where({ channel_id: channelId })
.orderBy('provider', 'asc')
.select();
return rows.map(parseIntegrationRow);
}
async function getByChannelProvider(channelId, provider) {
const rows = await knex()('channel_calendar_integrations')
.where({ channel_id: channelId, provider })
.limit(1)
.select();
return parseIntegrationRow(rows[0]);
}
async function upsertGoogleIntegration(channelId, payload) {
const now = Date.now();
const existing = await getByChannelProvider(channelId, 'google');
const patch = {
status: payload.status || 'connected',
config_json: JSON.stringify(payload.config || {}),
token_encrypted: payload.token_encrypted || null,
refresh_token_encrypted: payload.refresh_token_encrypted || null,
token_expires_at: payload.token_expires_at || null,
last_error: payload.last_error || null,
connected_by: payload.connected_by || null,
updated_by: payload.updated_by || null,
updated_at: now
};
if (!existing) {
const [id] = await knex()('channel_calendar_integrations').insert({
channel_id: channelId,
provider: 'google',
created_at: now,
...patch
});
return id;
}
await knex()('channel_calendar_integrations')
.where({ id: existing.id })
.update(patch);
return existing.id;
}
async function disconnectIntegration(id, channelId, updatedBy) {
await knex()('channel_calendar_integrations')
.where({ id, channel_id: channelId })
.update({
status: 'disconnected',
token_encrypted: null,
refresh_token_encrypted: null,
token_expires_at: null,
last_error: null,
updated_by: updatedBy || null,
updated_at: Date.now()
});
}
async function updateIntegrationSyncResult(id, patch) {
const update = {
status: patch.status || 'connected',
last_sync_at: patch.last_sync_at || Date.now(),
last_error: patch.last_error || null,
updated_at: Date.now()
};
if (Object.prototype.hasOwnProperty.call(patch, 'token_encrypted')) {
update.token_encrypted = patch.token_encrypted;
}
if (Object.prototype.hasOwnProperty.call(patch, 'refresh_token_encrypted')) {
update.refresh_token_encrypted = patch.refresh_token_encrypted;
}
if (Object.prototype.hasOwnProperty.call(patch, 'token_expires_at')) {
update.token_expires_at = patch.token_expires_at;
}
await knex()('channel_calendar_integrations')
.where({ id })
.update(update);
}
function parseExternalRow(row) {
if (!row) return null;
return {
id: row.id,
channel_id: row.channel_id,
show_id: row.show_id,
integration_id: row.integration_id,
provider: row.provider,
external_event_id: row.external_event_id,
external_etag: row.external_etag || null
};
}
async function getExternalEvent(showId, integrationId) {
const rows = await knex()('channel_show_external_events')
.where({ show_id: showId, integration_id: integrationId })
.limit(1)
.select();
return parseExternalRow(rows[0]);
}
async function upsertExternalEvent({ channelId, showId, integrationId, provider, externalEventId, externalEtag }) {
const now = Date.now();
const existing = await getExternalEvent(showId, integrationId);
if (!existing) {
await knex()('channel_show_external_events').insert({
channel_id: channelId,
show_id: showId,
integration_id: integrationId,
provider,
external_event_id: externalEventId,
external_etag: externalEtag || null,
last_pushed_at: now,
created_at: now,
updated_at: now
});
return;
}
await knex()('channel_show_external_events')
.where({ id: existing.id })
.update({
external_event_id: externalEventId,
external_etag: externalEtag || null,
last_pushed_at: now,
updated_at: now
});
}
module.exports = {
listByChannel,
getByChannelProvider,
upsertGoogleIntegration,
disconnectIntegration,
updateIntegrationSyncResult,
getExternalEvent,
upsertExternalEvent
};

View file

@ -217,4 +217,56 @@ export async function initTables() {
t.string('banned_by', 20).notNullable();
t.timestamps(/* useTimestamps */ true, /* defaultToNow */ true);
});
await ensureTable('channel_calendar_integrations', t => {
t.charset('utf8');
t.increments('id').notNullable().primary();
t.integer('channel_id')
.unsigned()
.notNullable()
.references('id').inTable('channels')
.onDelete('cascade');
t.string('provider', 32).notNullable();
t.string('status', 20).notNullable().defaultTo('disconnected');
t.specificType('config_json', 'text character set utf8mb4');
t.specificType('token_encrypted', 'text character set utf8mb4');
t.specificType('refresh_token_encrypted', 'text character set utf8mb4');
t.bigInteger('token_expires_at').nullable();
t.bigInteger('last_sync_at').nullable();
t.specificType('last_error', 'text character set utf8mb4');
t.bigInteger('created_at').notNullable();
t.bigInteger('updated_at').notNullable();
t.string('connected_by', 20).nullable();
t.string('updated_by', 20).nullable();
t.unique(['channel_id', 'provider'], 'channel_calendar_integration_unique');
t.index(['channel_id', 'provider'], 'channel_calendar_integration_lookup');
});
await ensureTable('channel_show_external_events', t => {
t.charset('utf8');
t.increments('id').notNullable().primary();
t.integer('channel_id')
.unsigned()
.notNullable()
.references('id').inTable('channels')
.onDelete('cascade');
t.integer('show_id')
.unsigned()
.notNullable()
.references('id').inTable('channel_shows')
.onDelete('cascade');
t.integer('integration_id')
.unsigned()
.notNullable()
.references('id').inTable('channel_calendar_integrations')
.onDelete('cascade');
t.string('provider', 32).notNullable();
t.string('external_event_id', 255).notNullable();
t.string('external_etag', 255).nullable();
t.bigInteger('last_pushed_at').nullable();
t.bigInteger('created_at').notNullable();
t.bigInteger('updated_at').notNullable();
t.unique(['show_id', 'integration_id'], 'channel_show_external_event_unique');
t.index(['integration_id', 'provider'], 'channel_show_external_event_integration_idx');
});
}

View file

@ -3,7 +3,7 @@ import Promise from 'bluebird';
const LOGGER = require('@calzoneman/jsli')('database/update');
const DB_VERSION = 13;
const DB_VERSION = 15;
var hasUpdates = [];
module.exports.checkVersion = function () {
@ -55,6 +55,10 @@ function update(version, cb) {
addUserInactiveColumn(cb);
} else if (version < 13) {
addShowsNotesAndColorColumns(cb);
} else if (version < 14) {
addCalendarIntegrationTables(cb);
} else if (version < 15) {
addCalendarIntegrationAuditColumns(cb);
}
}
@ -169,3 +173,88 @@ function addShowsNotesAndColorColumns(cb) {
}
);
}
function addCalendarIntegrationTables(cb) {
db.query(
"CREATE TABLE IF NOT EXISTS channel_calendar_integrations (" +
"id INT NOT NULL AUTO_INCREMENT PRIMARY KEY," +
"channel_id INT UNSIGNED NOT NULL," +
"provider VARCHAR(32) NOT NULL," +
"status VARCHAR(20) NOT NULL DEFAULT 'disconnected'," +
"config_json TEXT CHARACTER SET utf8mb4 NULL," +
"token_encrypted TEXT CHARACTER SET utf8mb4 NULL," +
"refresh_token_encrypted TEXT CHARACTER SET utf8mb4 NULL," +
"token_expires_at BIGINT NULL," +
"last_sync_at BIGINT NULL," +
"last_error TEXT CHARACTER SET utf8mb4 NULL," +
"created_at BIGINT NOT NULL," +
"updated_at BIGINT NOT NULL," +
"connected_by VARCHAR(20) NULL," +
"updated_by VARCHAR(20) NULL," +
"UNIQUE KEY channel_calendar_integration_unique (channel_id, provider)," +
"KEY channel_calendar_integration_lookup (channel_id, provider)," +
"CONSTRAINT fk_calendar_integrations_channel FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE" +
") CHARACTER SET utf8",
error => {
if (error) {
LOGGER.error(`Failed to create channel_calendar_integrations table: ${error}`);
cb(error);
return;
}
db.query(
"CREATE TABLE IF NOT EXISTS channel_show_external_events (" +
"id INT NOT NULL AUTO_INCREMENT PRIMARY KEY," +
"channel_id INT UNSIGNED NOT NULL," +
"show_id INT UNSIGNED NOT NULL," +
"integration_id INT UNSIGNED NOT NULL," +
"provider VARCHAR(32) NOT NULL," +
"external_event_id VARCHAR(255) NOT NULL," +
"external_etag VARCHAR(255) NULL," +
"last_pushed_at BIGINT NULL," +
"created_at BIGINT NOT NULL," +
"updated_at BIGINT NOT NULL," +
"UNIQUE KEY channel_show_external_event_unique (show_id, integration_id)," +
"KEY channel_show_external_event_integration_idx (integration_id, provider)," +
"CONSTRAINT fk_external_events_channel FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE," +
"CONSTRAINT fk_external_events_show FOREIGN KEY (show_id) REFERENCES channel_shows(id) ON DELETE CASCADE," +
"CONSTRAINT fk_external_events_integration FOREIGN KEY (integration_id) REFERENCES channel_calendar_integrations(id) ON DELETE CASCADE" +
") CHARACTER SET utf8",
error => {
if (error) {
LOGGER.error(`Failed to create channel_show_external_events table: ${error}`);
cb(error);
return;
}
cb();
}
);
}
);
}
function addCalendarIntegrationAuditColumns(cb) {
db.query(
"ALTER TABLE channel_calendar_integrations ADD COLUMN connected_by VARCHAR(20) NULL",
error => {
if (error) {
LOGGER.error(`Failed to add connected_by column: ${error}`);
cb(error);
return;
}
db.query(
"ALTER TABLE channel_calendar_integrations ADD COLUMN updated_by VARCHAR(20) NULL",
error => {
if (error) {
LOGGER.error(`Failed to add updated_by column: ${error}`);
cb(error);
return;
}
cb();
}
);
}
);
}

View file

@ -0,0 +1,267 @@
const https = require('https');
const querystring = require('querystring');
const Config = require('../config');
const { encryptString, decryptString } = require('../util/secretbox');
function getIntegrationConfig() {
return Config.get('calendar-sync');
}
function isEnabled() {
const cfg = getIntegrationConfig();
return !!cfg.enabled;
}
function getEncryptionKey() {
return process.env.CALENDAR_SYNC_ENCRYPTION_KEY ||
Config.get('calendar-sync.encryption-key') ||
'';
}
function getGoogleConfig() {
return Config.get('calendar-sync.google');
}
function assertConfigured() {
if (!isEnabled()) {
throw new Error('Calendar sync is disabled by config');
}
const google = getGoogleConfig();
if (!google['client-id'] || !google['client-secret'] || !google['redirect-uri']) {
throw new Error('Google calendar sync config is incomplete');
}
if (!getEncryptionKey()) {
throw new Error('Calendar sync encryption key is not configured');
}
}
function encodeState(payload, cookieSecret) {
const crypto = require('crypto');
const raw = Buffer.from(JSON.stringify(payload)).toString('base64');
const sig = crypto.createHmac('sha256', cookieSecret).update(raw).digest('hex');
return `${raw}.${sig}`;
}
function decodeState(state, cookieSecret) {
const crypto = require('crypto');
if (!state || typeof state !== 'string' || state.indexOf('.') === -1) {
throw new Error('Invalid OAuth state');
}
const parts = state.split('.');
const raw = parts[0];
const sig = parts[1];
const expected = crypto.createHmac('sha256', cookieSecret).update(raw).digest('hex');
if (sig !== expected) {
throw new Error('Invalid OAuth state signature');
}
const payload = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
if (!payload || payload.exp < Date.now()) {
throw new Error('OAuth state expired');
}
return payload;
}
function buildAuthUrl(state) {
assertConfigured();
const google = getGoogleConfig();
const qs = querystring.stringify({
client_id: google['client-id'],
redirect_uri: google['redirect-uri'],
response_type: 'code',
scope: 'https://www.googleapis.com/auth/calendar.events',
access_type: 'offline',
prompt: 'consent',
state
});
return `https://accounts.google.com/o/oauth2/v2/auth?${qs}`;
}
function requestJson(method, baseUrl, path, body, headers = {}) {
return new Promise((resolve, reject) => {
const rawBody = body ? JSON.stringify(body) : null;
const req = https.request(baseUrl + path, {
method,
headers: Object.assign(
{
Accept: 'application/json'
},
rawBody ? {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(rawBody)
} : {},
headers
)
}, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
const ok = res.statusCode >= 200 && res.statusCode < 300;
let parsed = {};
try {
parsed = text ? JSON.parse(text) : {};
} catch (_err) {
if (!ok) {
return reject(new Error(`HTTP ${res.statusCode}: ${text}`));
}
parsed = {};
}
if (!ok) {
let message = parsed.error_description || parsed.error || `HTTP ${res.statusCode}`;
if (message && typeof message === 'object') {
message = message.message || message.status || JSON.stringify(message);
}
return reject(new Error(String(message)));
}
resolve(parsed);
});
});
req.on('error', reject);
if (rawBody) req.write(rawBody);
req.end();
});
}
function requestForm(baseUrl, path, formBody) {
return new Promise((resolve, reject) => {
const raw = querystring.stringify(formBody);
const req = https.request(baseUrl + path, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(raw),
Accept: 'application/json'
}
}, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
const ok = res.statusCode >= 200 && res.statusCode < 300;
let parsed = {};
try {
parsed = text ? JSON.parse(text) : {};
} catch (_err) {
return reject(new Error(`Google token parse error: ${text}`));
}
if (!ok) {
let message = parsed.error_description || parsed.error || `HTTP ${res.statusCode}`;
if (message && typeof message === 'object') {
message = message.message || message.status || JSON.stringify(message);
}
return reject(new Error(String(message)));
}
resolve(parsed);
});
});
req.on('error', reject);
req.write(raw);
req.end();
});
}
async function exchangeCodeForToken(code) {
assertConfigured();
const google = getGoogleConfig();
return requestForm('https://oauth2.googleapis.com', '/token', {
code,
client_id: google['client-id'],
client_secret: google['client-secret'],
redirect_uri: google['redirect-uri'],
grant_type: 'authorization_code'
});
}
async function refreshAccessToken(refreshToken) {
assertConfigured();
const google = getGoogleConfig();
return requestForm('https://oauth2.googleapis.com', '/token', {
refresh_token: refreshToken,
client_id: google['client-id'],
client_secret: google['client-secret'],
grant_type: 'refresh_token'
});
}
function packTokens(tokenPayload) {
const key = getEncryptionKey();
const expiresAt = Date.now() + ((tokenPayload.expires_in || 3600) * 1000);
return {
token_encrypted: encryptString(tokenPayload.access_token, key),
refresh_token_encrypted: tokenPayload.refresh_token ?
encryptString(tokenPayload.refresh_token, key) : null,
token_expires_at: expiresAt
};
}
function unpackTokens(integrationRow) {
const key = getEncryptionKey();
return {
accessToken: integrationRow.token_encrypted ? decryptString(integrationRow.token_encrypted, key) : null,
refreshToken: integrationRow.refresh_token_encrypted ? decryptString(integrationRow.refresh_token_encrypted, key) : null,
tokenExpiresAt: integrationRow.token_expires_at || 0
};
}
async function upsertGoogleCalendarEvent(accessToken, calendarId, show) {
const start = new Date(show.scheduled_for);
const end = new Date(start.getTime() + 60 * 60 * 1000);
const body = {
summary: show.name,
description: show.notes || '',
start: {
dateTime: start.toISOString(),
timeZone: show.timezone || 'UTC'
},
end: {
dateTime: end.toISOString(),
timeZone: show.timezone || 'UTC'
},
colorId: null
};
return requestJson(
'POST',
'https://www.googleapis.com',
`/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`,
body,
{ Authorization: `Bearer ${accessToken}` }
);
}
async function updateGoogleCalendarEvent(accessToken, calendarId, eventId, show) {
const start = new Date(show.scheduled_for);
const end = new Date(start.getTime() + 60 * 60 * 1000);
const body = {
summary: show.name,
description: show.notes || '',
start: {
dateTime: start.toISOString(),
timeZone: show.timezone || 'UTC'
},
end: {
dateTime: end.toISOString(),
timeZone: show.timezone || 'UTC'
}
};
return requestJson(
'PUT',
'https://www.googleapis.com',
`/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`,
body,
{ Authorization: `Bearer ${accessToken}` }
);
}
module.exports = {
isEnabled,
assertConfigured,
encodeState,
decodeState,
buildAuthUrl,
exchangeCodeForToken,
refreshAccessToken,
packTokens,
unpackTokens,
upsertGoogleCalendarEvent,
updateGoogleCalendarEvent
};

48
src/util/secretbox.js Normal file
View file

@ -0,0 +1,48 @@
const crypto = require('crypto');
function decodeKey(base64Key) {
if (!base64Key || typeof base64Key !== 'string') {
throw new Error('Missing encryption key');
}
const key = Buffer.from(base64Key, 'base64');
if (key.length !== 32) {
throw new Error('Encryption key must decode to exactly 32 bytes');
}
return key;
}
function encryptString(plaintext, base64Key) {
const key = decodeKey(base64Key);
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([
cipher.update(String(plaintext), 'utf8'),
cipher.final()
]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString('base64');
}
function decryptString(ciphertextB64, base64Key) {
const key = decodeKey(base64Key);
const raw = Buffer.from(String(ciphertextB64), 'base64');
if (raw.length < 29) {
throw new Error('Ciphertext payload is invalid');
}
const iv = raw.slice(0, 12);
const tag = raw.slice(12, 28);
const ciphertext = raw.slice(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([
decipher.update(ciphertext),
decipher.final()
]).toString('utf8');
}
module.exports = {
encryptString,
decryptString
};

View file

@ -77,6 +77,8 @@ router.use('/channels/:channel/emotes', require('./emotes'));
router.use('/channels/:channel/playlist', require('./playlist'));
router.use('/channels/:channel/settings', require('./settings'));
router.use('/channels/:channel/shows', require('./shows'));
router.use('/channels/:channel/integrations', require('./integrations'));
router.use('/integrations', require('./integrations-oauth'));
router.use('/channels/:channel', require('./moderation'));
module.exports = router;

View file

@ -0,0 +1,59 @@
const express = require('express');
const webserver = require('../../webserver');
const Config = require('../../../config');
const calendarDB = require('../../../database/calendar-integrations');
const googleCalendar = require('../../../integrations/google-calendar');
const { getChannelRow, getUserEffectiveRank } = require('./middleware');
const router = express.Router();
router.get('/google/callback', async (req, res) => {
const user = await webserver.authorize(req);
if (!user) return res.status(401).send('Unauthorized');
const code = String(req.query.code || '').trim();
const state = String(req.query.state || '').trim();
if (!code || !state) return res.status(400).send('Missing code/state');
let decoded;
try {
decoded = googleCalendar.decodeState(state, Config.get('http.cookie-secret'));
} catch (err) {
return res.status(400).send(err.message || 'Invalid state');
}
if (!decoded.channel || !decoded.calendarId) {
return res.status(400).send('State is missing channel/calendar info');
}
if (String(decoded.actor || '').toLowerCase() !== String(user.name || '').toLowerCase()) {
return res.status(403).send('OAuth callback user mismatch');
}
const channelRow = await getChannelRow(decoded.channel).catch(() => null);
if (!channelRow) return res.status(404).send('Channel not found');
const rank = await getUserEffectiveRank(user, channelRow);
if (rank < 3) return res.status(403).send('Insufficient rank');
try {
const tokenPayload = await googleCalendar.exchangeCodeForToken(code);
const packed = googleCalendar.packTokens(tokenPayload);
await calendarDB.upsertGoogleIntegration(channelRow.id, {
status: 'connected',
config: {
calendar_id: decoded.calendarId,
sync_statuses: ['scheduled', 'running', 'paused', 'completed']
},
token_encrypted: packed.token_encrypted,
refresh_token_encrypted: packed.refresh_token_encrypted,
token_expires_at: packed.token_expires_at,
connected_by: user.name,
updated_by: user.name
});
res.send(`Google Calendar connected for channel ${channelRow.name}. You can close this tab.`);
} catch (err) {
res.status(400).send(err.message || 'OAuth exchange failed');
}
});
module.exports = router;

View file

@ -0,0 +1,208 @@
const express = require('express');
const webserver = require('../../webserver');
const Config = require('../../../config');
const showsDB = require('../../../database/shows');
const calendarDB = require('../../../database/calendar-integrations');
const googleCalendar = require('../../../integrations/google-calendar');
const { getChannelRow, getUserEffectiveRank } = require('./middleware');
const router = express.Router({ mergeParams: true });
const PROVIDERS = new Set(['google']);
const SYNCABLE_STATUSES = new Set(['scheduled', 'running', 'paused', 'completed']);
function sanitizeIntegration(integration) {
return {
id: integration.id,
provider: integration.provider,
status: integration.status,
config: integration.config || {},
token_expires_at: integration.token_expires_at,
last_sync_at: integration.last_sync_at,
last_error: integration.last_error,
connected_by: integration.connected_by,
updated_by: integration.updated_by,
created_at: integration.created_at,
updated_at: integration.updated_at
};
}
async function authorizeChannelAdmin(req, res, minRank = 3) {
const user = await webserver.authorize(req);
if (!user) {
res.status(401).json({ error: 'Unauthorized' });
return null;
}
let channelRow;
try {
channelRow = await getChannelRow(req.params.channel);
} catch (_err) {
res.status(404).json({ error: 'Channel not found' });
return null;
}
const rank = await getUserEffectiveRank(user, channelRow);
if (rank < minRank) {
res.status(403).json({ error: 'Insufficient rank' });
return null;
}
return { user, rank, channelRow };
}
async function ensureAccessToken(integration) {
const unpacked = googleCalendar.unpackTokens(integration);
if (unpacked.accessToken && unpacked.tokenExpiresAt > Date.now() + 60 * 1000) {
return { accessToken: unpacked.accessToken, updatedPatch: null };
}
if (!unpacked.refreshToken) {
throw new Error('Integration token expired and no refresh token is available');
}
const refreshed = await googleCalendar.refreshAccessToken(unpacked.refreshToken);
const packed = googleCalendar.packTokens({
access_token: refreshed.access_token,
refresh_token: unpacked.refreshToken,
expires_in: refreshed.expires_in
});
return {
accessToken: refreshed.access_token,
updatedPatch: packed
};
}
async function syncGoogleIntegration({ integration, channelRow }) {
const config = integration.config || {};
const calendarId = config.calendar_id;
if (!calendarId) {
throw new Error('Integration missing calendar_id');
}
const statuses = Array.isArray(config.sync_statuses) && config.sync_statuses.length > 0
? config.sync_statuses.filter(s => SYNCABLE_STATUSES.has(s))
: ['scheduled', 'running', 'paused', 'completed'];
const allShows = await showsDB.listShows(channelRow.id);
const shows = allShows.filter(show => statuses.indexOf(show.status) >= 0);
const tokenResult = await ensureAccessToken(integration);
for (const show of shows) {
const mapping = await calendarDB.getExternalEvent(show.id, integration.id);
let event;
if (!mapping) {
event = await googleCalendar.upsertGoogleCalendarEvent(tokenResult.accessToken, calendarId, show);
} else {
event = await googleCalendar.updateGoogleCalendarEvent(
tokenResult.accessToken,
calendarId,
mapping.external_event_id,
show
);
}
await calendarDB.upsertExternalEvent({
channelId: channelRow.id,
showId: show.id,
integrationId: integration.id,
provider: 'google',
externalEventId: event.id,
externalEtag: event.etag || null
});
}
return {
synced: shows.length,
tokenPatch: tokenResult.updatedPatch
};
}
router.get('/', async (req, res) => {
const auth = await authorizeChannelAdmin(req, res, 3);
if (!auth) return;
const rows = await calendarDB.listByChannel(auth.channelRow.id);
res.json(rows.map(sanitizeIntegration));
});
router.post('/:provider/connect', async (req, res) => {
const auth = await authorizeChannelAdmin(req, res, 3);
if (!auth) return;
const provider = String(req.params.provider || '').toLowerCase();
if (!PROVIDERS.has(provider)) {
return res.status(400).json({ error: 'Unsupported provider' });
}
if (provider !== 'google') {
return res.status(400).json({ error: 'Only google is supported currently' });
}
const calendarId = String((req.body && req.body.calendar_id) || '').trim();
if (!calendarId) {
return res.status(400).json({ error: 'calendar_id is required' });
}
try {
googleCalendar.assertConfigured();
const state = googleCalendar.encodeState({
channel: auth.channelRow.name,
channelId: auth.channelRow.id,
calendarId,
actor: auth.user.name,
exp: Date.now() + 10 * 60 * 1000
}, Config.get('http.cookie-secret'));
const authUrl = googleCalendar.buildAuthUrl(state);
res.json({ auth_url: authUrl });
} catch (err) {
res.status(400).json({ error: err.message || 'Unable to initialize oauth flow' });
}
});
router.post('/:provider/disconnect', async (req, res) => {
const auth = await authorizeChannelAdmin(req, res, 3);
if (!auth) return;
const provider = String(req.params.provider || '').toLowerCase();
if (!PROVIDERS.has(provider)) {
return res.status(400).json({ error: 'Unsupported provider' });
}
const integration = await calendarDB.getByChannelProvider(auth.channelRow.id, provider);
if (!integration) return res.status(404).json({ error: 'Integration not found' });
await calendarDB.disconnectIntegration(integration.id, auth.channelRow.id, auth.user.name);
res.json({ success: true });
});
router.post('/:provider/sync-now', async (req, res) => {
const auth = await authorizeChannelAdmin(req, res, 3);
if (!auth) return;
const provider = String(req.params.provider || '').toLowerCase();
if (!PROVIDERS.has(provider)) {
return res.status(400).json({ error: 'Unsupported provider' });
}
const integration = await calendarDB.getByChannelProvider(auth.channelRow.id, provider);
if (!integration || integration.status !== 'connected') {
return res.status(404).json({ error: 'Connected integration not found' });
}
try {
let result;
if (provider === 'google') {
result = await syncGoogleIntegration({ integration, channelRow: auth.channelRow });
}
const patch = Object.assign({
status: 'connected',
last_sync_at: Date.now(),
last_error: null
}, result && result.tokenPatch ? result.tokenPatch : {});
await calendarDB.updateIntegrationSyncResult(integration.id, patch);
res.json({ success: true, synced: result ? result.synced : 0 });
} catch (err) {
await calendarDB.updateIntegrationSyncResult(integration.id, {
status: 'error',
last_sync_at: Date.now(),
last_error: err.message || 'Sync failed'
});
res.status(400).json({ error: err.message || 'Sync failed' });
}
});
module.exports = router;

View file

@ -239,6 +239,7 @@ html(lang="en")
li: a(href="#cs-banlist", data-toggle="tab", tabindex="-1", onclick="javascript:socket.emit('requestBanlist')") Ban list
li: a(href="#cs-bots", data-toggle="tab", tabindex="-1", onclick="javascript:if(window.CSTBots&&CSTBots.load){CSTBots.load()}") Bots
li: a(href="#cs-shows", data-toggle="tab", tabindex="-1", onclick="javascript:if(window.CSTShows&&CSTShows.load){CSTShows.load()}") Shows
li: a(href="#cs-integrations", data-toggle="tab", tabindex="-1", onclick="javascript:if(window.CSTIntegrations&&CSTIntegrations.load){CSTIntegrations.load()}") Integrations
li: a(href="#cs-chanlog", data-toggle="tab", onclick="javascript:socket.emit('readChanLog')") Log
.modal-body
.tab-content
@ -255,6 +256,7 @@ html(lang="en")
+emotes()
+bots()
+shows()
+integrations()
+chanlog()
+permeditor()
.modal-footer

View file

@ -334,5 +334,30 @@ mixin shows
th Actions
tbody#cs-shows-list
mixin integrations
#cs-integrations.tab-pane
h4 Calendar Integrations
p Connect this channel's shows schedule to external calendars.
.alert.alert-info
| Google Calendar uses OAuth. Tokens are stored encrypted server-side and bound to this channel.
form.form-inline(action="javascript:void(0)", role="form")
.form-group
label(for="cs-int-google-calendar-id", style="margin-right:8px") Google Calendar ID
input#cs-int-google-calendar-id.form-control(type="text", placeholder="primary or calendar id", style="min-width:300px")
button#cs-int-google-connect.btn.btn-primary(type="button", style="margin-left:8px") Connect Google
button#cs-int-google-sync.btn.btn-default(type="button", style="margin-left:8px") Sync Now
button#cs-int-google-disconnect.btn.btn-danger(type="button", style="margin-left:8px") Disconnect
hr
table.table.table-striped.table-condensed
thead
tr
th Provider
th Status
th Calendar ID
th Last Sync
th Connected By
th Last Error
tbody#cs-int-list
mixin permeditor
#cs-permedit.tab-pane

View file

@ -1993,3 +1993,119 @@ var CSTShows = (function () {
return { load: load, selectShow: selectShow, prefillScheduledDate: prefillScheduledDate };
})();
var CSTIntegrations = (function () {
function csrfField() {
return (typeof CSRF_TOKEN === 'string' && CSRF_TOKEN.length > 0) ? CSRF_TOKEN : '';
}
function apiBase() {
return '/api/v1/channels/' + CHANNEL.name + '/integrations';
}
function formatError(xhr, fallback) {
var err = xhr && xhr.responseJSON && xhr.responseJSON.error;
if (!err) return fallback || (xhr && xhr.statusText) || 'Request failed';
if (typeof err === 'string') return err;
if (err.message && typeof err.message === 'string') return err.message;
try {
return JSON.stringify(err);
} catch (_e) {
return String(err);
}
}
function render(rows) {
var tbody = $('#cs-int-list').empty();
if (!Array.isArray(rows) || rows.length === 0) {
tbody.append('<tr><td colspan="6" class="text-muted">No integrations connected</td></tr>');
return;
}
rows.forEach(function (row) {
var calendarId = (row.config && row.config.calendar_id) ? row.config.calendar_id : '';
var tr = $('<tr>');
tr.append($('<td>').text(row.provider));
tr.append($('<td>').text(row.status));
tr.append($('<td>').text(calendarId || ''));
tr.append($('<td>').text(row.last_sync_at ? new Date(row.last_sync_at).toLocaleString() : 'Never'));
tr.append($('<td>').text(row.connected_by || ''));
tr.append($('<td>').text(row.last_error || ''));
tbody.append(tr);
if (row.provider === 'google') {
$('#cs-int-google-calendar-id').val(calendarId || '');
}
});
}
function load() {
$.getJSON(apiBase(), function (rows) {
render(rows);
}).fail(function (xhr) {
var msg = formatError(xhr, 'Failed to load integrations');
$('#cs-int-list').html('<tr><td colspan="6" class="text-danger">' + msg + '</td></tr>');
});
}
function connectGoogle() {
var calendarId = ($('#cs-int-google-calendar-id').val() || '').trim();
if (!calendarId) {
alert('Calendar ID is required');
return;
}
$.ajax({
url: apiBase() + '/google/connect',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({
calendar_id: calendarId,
_csrf: csrfField()
})
}).done(function (data) {
if (!data || !data.auth_url) {
alert('Missing OAuth URL from server');
return;
}
window.open(data.auth_url, '_blank');
}).fail(function (xhr) {
alert('Connect failed: ' + formatError(xhr, 'Connect failed'));
});
}
function syncGoogleNow() {
$.ajax({
url: apiBase() + '/google/sync-now',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ _csrf: csrfField() })
}).done(function (data) {
load();
if (data && typeof data.synced === 'number') {
alert('Synced ' + data.synced + ' shows to Google Calendar');
}
}).fail(function (xhr) {
alert('Sync failed: ' + formatError(xhr, 'Sync failed'));
});
}
function disconnectGoogle() {
if (!confirm('Disconnect Google Calendar integration for this channel?')) return;
$.ajax({
url: apiBase() + '/google/disconnect',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ _csrf: csrfField() })
}).done(function () {
load();
}).fail(function (xhr) {
alert('Disconnect failed: ' + formatError(xhr, 'Disconnect failed'));
});
}
$('#cs-int-google-connect').on('click', connectGoogle);
$('#cs-int-google-sync').on('click', syncGoogleNow);
$('#cs-int-google-disconnect').on('click', disconnectGoogle);
return { load: load };
})();

View file

@ -1014,6 +1014,7 @@ function handleModPermissions() {
setParentVisible("a[href='#cs-chanranks']", CLIENT.rank >= 3);
setParentVisible("a[href='#cs-chanlog']", CLIENT.rank >= 3);
setParentVisible("a[href='#cs-shows']", CLIENT.rank >= 2);
setParentVisible("a[href='#cs-integrations']", CLIENT.rank >= 3);
$("#cs-chatfilters-import").attr("disabled", !hasPermission("filterimport"));
$("#cs-emotes-import").attr("disabled", !hasPermission("filterimport"));
}