mirror of
https://github.com/Spengreb/sync.git
synced 2026-06-09 23:02:05 +00:00
Google calendar sync v1
This commit is contained in:
parent
60c6a50d9e
commit
71b0a092ca
14 changed files with 1064 additions and 1 deletions
|
|
@ -215,3 +215,14 @@ twitch-client-id: null
|
||||||
|
|
||||||
poll:
|
poll:
|
||||||
max-options: 50
|
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: ''
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,15 @@ var defaults = {
|
||||||
"twitch-client-id": null,
|
"twitch-client-id": null,
|
||||||
poll: {
|
poll: {
|
||||||
"max-options": 50
|
"max-options": 50
|
||||||
|
},
|
||||||
|
"calendar-sync": {
|
||||||
|
enabled: false,
|
||||||
|
"encryption-key": "",
|
||||||
|
google: {
|
||||||
|
"client-id": "",
|
||||||
|
"client-secret": "",
|
||||||
|
"redirect-uri": ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
174
src/database/calendar-integrations.js
Normal file
174
src/database/calendar-integrations.js
Normal 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
|
||||||
|
};
|
||||||
|
|
@ -217,4 +217,56 @@ export async function initTables() {
|
||||||
t.string('banned_by', 20).notNullable();
|
t.string('banned_by', 20).notNullable();
|
||||||
t.timestamps(/* useTimestamps */ true, /* defaultToNow */ true);
|
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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import Promise from 'bluebird';
|
||||||
|
|
||||||
const LOGGER = require('@calzoneman/jsli')('database/update');
|
const LOGGER = require('@calzoneman/jsli')('database/update');
|
||||||
|
|
||||||
const DB_VERSION = 13;
|
const DB_VERSION = 15;
|
||||||
var hasUpdates = [];
|
var hasUpdates = [];
|
||||||
|
|
||||||
module.exports.checkVersion = function () {
|
module.exports.checkVersion = function () {
|
||||||
|
|
@ -55,6 +55,10 @@ function update(version, cb) {
|
||||||
addUserInactiveColumn(cb);
|
addUserInactiveColumn(cb);
|
||||||
} else if (version < 13) {
|
} else if (version < 13) {
|
||||||
addShowsNotesAndColorColumns(cb);
|
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();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
267
src/integrations/google-calendar.js
Normal file
267
src/integrations/google-calendar.js
Normal 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
48
src/util/secretbox.js
Normal 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
|
||||||
|
};
|
||||||
|
|
@ -77,6 +77,8 @@ router.use('/channels/:channel/emotes', require('./emotes'));
|
||||||
router.use('/channels/:channel/playlist', require('./playlist'));
|
router.use('/channels/:channel/playlist', require('./playlist'));
|
||||||
router.use('/channels/:channel/settings', require('./settings'));
|
router.use('/channels/:channel/settings', require('./settings'));
|
||||||
router.use('/channels/:channel/shows', require('./shows'));
|
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'));
|
router.use('/channels/:channel', require('./moderation'));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
59
src/web/routes/api/integrations-oauth.js
Normal file
59
src/web/routes/api/integrations-oauth.js
Normal 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;
|
||||||
208
src/web/routes/api/integrations.js
Normal file
208
src/web/routes/api/integrations.js
Normal 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;
|
||||||
|
|
@ -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-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-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-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
|
li: a(href="#cs-chanlog", data-toggle="tab", onclick="javascript:socket.emit('readChanLog')") Log
|
||||||
.modal-body
|
.modal-body
|
||||||
.tab-content
|
.tab-content
|
||||||
|
|
@ -255,6 +256,7 @@ html(lang="en")
|
||||||
+emotes()
|
+emotes()
|
||||||
+bots()
|
+bots()
|
||||||
+shows()
|
+shows()
|
||||||
|
+integrations()
|
||||||
+chanlog()
|
+chanlog()
|
||||||
+permeditor()
|
+permeditor()
|
||||||
.modal-footer
|
.modal-footer
|
||||||
|
|
|
||||||
|
|
@ -334,5 +334,30 @@ mixin shows
|
||||||
th Actions
|
th Actions
|
||||||
tbody#cs-shows-list
|
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
|
mixin permeditor
|
||||||
#cs-permedit.tab-pane
|
#cs-permedit.tab-pane
|
||||||
|
|
|
||||||
116
www/js/ui.js
116
www/js/ui.js
|
|
@ -1993,3 +1993,119 @@ var CSTShows = (function () {
|
||||||
|
|
||||||
return { load: load, selectShow: selectShow, prefillScheduledDate: prefillScheduledDate };
|
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 };
|
||||||
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1014,6 +1014,7 @@ function handleModPermissions() {
|
||||||
setParentVisible("a[href='#cs-chanranks']", CLIENT.rank >= 3);
|
setParentVisible("a[href='#cs-chanranks']", CLIENT.rank >= 3);
|
||||||
setParentVisible("a[href='#cs-chanlog']", CLIENT.rank >= 3);
|
setParentVisible("a[href='#cs-chanlog']", CLIENT.rank >= 3);
|
||||||
setParentVisible("a[href='#cs-shows']", CLIENT.rank >= 2);
|
setParentVisible("a[href='#cs-shows']", CLIENT.rank >= 2);
|
||||||
|
setParentVisible("a[href='#cs-integrations']", CLIENT.rank >= 3);
|
||||||
$("#cs-chatfilters-import").attr("disabled", !hasPermission("filterimport"));
|
$("#cs-chatfilters-import").attr("disabled", !hasPermission("filterimport"));
|
||||||
$("#cs-emotes-import").attr("disabled", !hasPermission("filterimport"));
|
$("#cs-emotes-import").attr("disabled", !hasPermission("filterimport"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue