mirror of
https://github.com/Spengreb/sync.git
synced 2026-06-09 14:52:05 +00:00
Merge pull request #16 from Spengreb/google-calendar-sync
Google calendar sync
This commit is contained in:
commit
4ec1e83337
14 changed files with 1064 additions and 1 deletions
|
|
@ -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: ''
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
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.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 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();
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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/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;
|
||||
|
|
|
|||
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-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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
116
www/js/ui.js
116
www/js/ui.js
|
|
@ -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 };
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue