From 2f384d35ee347045b8a2128084258b314fa2c86f Mon Sep 17 00:00:00 2001 From: KennHuang Date: Fri, 26 Jun 2026 14:39:19 +0800 Subject: [PATCH] refactor: restructure codebase and add REST API layer - Introduce config.ts to centralize all env var parsing/validation - Introduce db.ts as a MongoDB connection factory with typed DbCollections - Move all modules under src/modules/, merging confusingly-named google-drive-check + google-drive-checker into modules/google-drive/ (handler.ts for Slack integration, checker.ts for pure Drive logic) - Add SettingsStore (modules/settings/store.ts) for MongoDB-backed runtime settings with in-memory cache - Add REST API under /api/* (Express, Bearer token auth): GET/ /api/messages (collection, limit, cursor pagination) GET /api/messages/:id GET /api/drive/jobs (status filter, cursor pagination) GET /api/drive/jobs/:id GET /api/settings PATCH /api/settings (runtime feature flag control) - Remove unused deps: args, async, nanoid; add express + @types/express - Add clean script; build now removes dist/ before compiling Co-Authored-By: Claude Sonnet 4.6 --- package.json | 10 +- src/app.ts | 134 ++++++++---------- src/config.ts | 92 ++++++++++++ src/db.ts | 25 ++++ src/{ => modules}/app-home/index.ts | 0 .../google-drive/checker.ts} | 0 .../google-drive/handler.ts} | 2 +- .../google-drive}/types.ts | 0 src/{ => modules}/reaction-check/index.ts | 0 src/modules/settings/store.ts | 50 +++++++ src/{ => modules}/slack-storage/index.ts | 2 +- src/web/middleware/auth.ts | 22 +++ src/web/routes/drive.ts | 65 +++++++++ src/web/routes/messages.ts | 64 +++++++++ src/web/routes/settings.ts | 43 ++++++ src/web/server.ts | 30 ++++ src/web/types.ts | 27 ++++ 17 files changed, 483 insertions(+), 83 deletions(-) create mode 100644 src/config.ts create mode 100644 src/db.ts rename src/{ => modules}/app-home/index.ts (100%) rename src/{google-drive-checker/index.ts => modules/google-drive/checker.ts} (100%) rename src/{google-drive-check/index.ts => modules/google-drive/handler.ts} (99%) rename src/{google-drive-checker => modules/google-drive}/types.ts (100%) rename src/{ => modules}/reaction-check/index.ts (100%) create mode 100644 src/modules/settings/store.ts rename src/{ => modules}/slack-storage/index.ts (98%) create mode 100644 src/web/middleware/auth.ts create mode 100644 src/web/routes/drive.ts create mode 100644 src/web/routes/messages.ts create mode 100644 src/web/routes/settings.ts create mode 100644 src/web/server.ts create mode 100644 src/web/types.ts diff --git a/package.json b/package.json index 1850989..7fee9c3 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "build": "tsc -p .", + "clean": "rm -rf dist", + "build": "npm run clean && tsc -p .", "build:watch": "tsc -w -p .", "start": "npm run build && node dist/app.js", "test": "echo \"Error: no test specified\" && exit 1" @@ -14,17 +15,14 @@ "dependencies": { "@slack/bolt": "^4.7.0", "@slack/web-api": "^7.15.0", - "args": "^5.0.3", - "async": "^3.2.4", "dotenv": "^16.3.1", + "express": "^5.2.1", "googleapis": "^173.0.0", "mongodb": "^4.8.1", - "nanoid": "^3.3.4", "node-fetch": "^2.6.7" }, "devDependencies": { - "@types/args": "^5.0.1", - "@types/async": "^3.2.21", + "@types/express": "^5.0.6", "@types/node": "^17.0.25", "@types/node-fetch": "^2.6.2", "ts-node": "^10.7.0", diff --git a/src/app.ts b/src/app.ts index 09c32cc..77303c3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,89 +1,70 @@ -import { config } from 'dotenv'; -config(); +import { loadConfig } from './config'; +import { connectDb } from './db'; import { App } from '@slack/bolt'; -import * as mongoDB from 'mongodb'; -import { getBoltLogLevel } from './utils/slack'; -import { SlackStorageModule } from './slack-storage'; -import { AppHomeModule } from './app-home'; - -import 'dotenv/config' -import { ReactionCheckModule } from './reaction-check'; -import { GoogleDriveCheckModule } from './google-drive-check'; import { google } from 'googleapis'; - -const app = new App({ - token: process.env.SLACK_BOT_TOKEN, - signingSecret: process.env.SLACK_SIGNING_SECRET, - appToken: process.env.SLACK_APP_TOKEN, - logLevel: getBoltLogLevel(process.env.LOG_LEVEL), - socketMode: true, - ignoreSelf: false, -}); - -app.use(async ({ next }) => { - await next!(); -}); +import { SlackStorageModule } from './modules/slack-storage'; +import { AppHomeModule } from './modules/app-home'; +import { ReactionCheckModule } from './modules/reaction-check'; +import { GoogleDriveCheckModule } from './modules/google-drive/handler'; +import { SettingsStore } from './modules/settings/store'; +import { createWebServer } from './web/server'; (async () => { + const config = loadConfig(); + + const { client: dbClient, collections } = await connectDb(config.db.connString); + + const settingsStore = new SettingsStore(collections.settings); + await settingsStore.load({ + useReactionCheck: config.features.useReactionCheck, + useGoogleDriveCheck: config.features.useGoogleDriveCheck, + autoJoinChannels: config.app.autoJoinChannels, + reactionUserIgnoreList: config.features.reactionUserIgnoreList, + googleDriveReportOnlyViolations: config.features.googleDriveReportOnlyViolations, + googleDriveAuditChannel: config.features.googleDriveAuditChannel, + googleDriveAllowedSharedDriveIds: config.features.googleDriveAllowedSharedDriveIds, + googleDriveAllowedFolderIds: config.features.googleDriveAllowedFolderIds, + }); + + const app = new App({ + token: config.slack.botToken, + signingSecret: config.slack.signingSecret, + appToken: config.slack.appToken, + logLevel: config.slack.logLevel, + socketMode: true, + ignoreSelf: false, + }); + + app.use(async ({ next }) => { await next!(); }); - if (!process.env.DB_CONN_STRING) throw new Error('Env DB_CONN_STRING is required.'); - const client = new mongoDB.MongoClient(process.env.DB_CONN_STRING); - await client.connect(); - - const msgCollection = client.db().collection('messages'); - const changedMsgCollection = client.db().collection('changedMessages'); - const deletedMsgCollection = client.db().collection('deletedMessages'); - const driveJobCollection = client.db().collection('driveComplianceJobs'); - - const fileSavePrefix = process.env.SLACK_FILE_SAVE_PREFIX; - if (!fileSavePrefix) throw new Error('Env SLACK_FILE_SAVE_PREFIX is required.'); - - const slackUserToken = process.env.SLACK_USER_TOKEN - if (!slackUserToken) throw new Error('Env SLACK_USER_TOKEN is required.'); - - const useReactionCheck = process.env.USE_REACTION_CHECK === 'true'; - - // Start your app await app.start(); - const autoJoinChannels = process.env.AUTO_JOIN_CHANNELS === 'true'; - const slackStorageModule = new SlackStorageModule( app, - msgCollection, - changedMsgCollection, - deletedMsgCollection, - fileSavePrefix, - slackUserToken, - { - autoJoinChannels, - } + collections.messages, + collections.changedMessages, + collections.deletedMessages, + config.app.fileSavePrefix, + config.slack.userToken, + { autoJoinChannels: config.app.autoJoinChannels }, ); await slackStorageModule.init(); const appHomeModule = new AppHomeModule(app); await appHomeModule.init(); - if (useReactionCheck) { - let ignoredUsers: string[] = []; - if (process.env.REACTION_USER_IGN_LIST) { - ignoredUsers = process.env.REACTION_USER_IGN_LIST.split(','); - } + const settings = settingsStore.get(); - const reactionCheckModule = new ReactionCheckModule(app, ignoredUsers); + if (settings.useReactionCheck) { + const reactionCheckModule = new ReactionCheckModule(app, settings.reactionUserIgnoreList); await reactionCheckModule.init(); } - const useGoogleDriveCheck = process.env.USE_GOOGLE_DRIVE_CHECK === 'true'; - if (useGoogleDriveCheck) { - if (!process.env.GOOGLE_SERVICE_ACCOUNT_KEY_FILE && !process.env.GOOGLE_SERVICE_ACCOUNT_KEY_JSON) { - throw new Error('GOOGLE_SERVICE_ACCOUNT_KEY_FILE or GOOGLE_SERVICE_ACCOUNT_KEY_JSON is required when USE_GOOGLE_DRIVE_CHECK=true.'); - } - + if (settings.useGoogleDriveCheck) { const auth = new google.auth.GoogleAuth({ - keyFile: process.env.GOOGLE_SERVICE_ACCOUNT_KEY_FILE, - credentials: process.env.GOOGLE_SERVICE_ACCOUNT_KEY_JSON - ? JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT_KEY_JSON) + keyFile: config.features.googleServiceAccountKeyFile, + credentials: config.features.googleServiceAccountKeyJson + ? JSON.parse(config.features.googleServiceAccountKeyJson) : undefined, scopes: ['https://www.googleapis.com/auth/drive.readonly'], }); @@ -92,28 +73,31 @@ app.use(async ({ next }) => { const gdCheckModule = new GoogleDriveCheckModule( app, driveClient, - driveJobCollection, + collections.driveComplianceJobs, { - allowedSharedDriveIds: (process.env.GOOGLE_DRIVE_ALLOWED_SHARED_DRIVE_IDS || '').split(',').filter(Boolean), - allowedFolderIds: (process.env.GOOGLE_DRIVE_ALLOWED_FOLDER_IDS || '').split(',').filter(Boolean), - allowDomainSharing: process.env.GOOGLE_DRIVE_ALLOW_DOMAIN_SHARING === 'true', - maxParentTraversalDepth: 10, + allowedSharedDriveIds: config.features.googleDriveAllowedSharedDriveIds, + allowedFolderIds: config.features.googleDriveAllowedFolderIds, + allowDomainSharing: config.features.googleDriveAllowDomainSharing, + maxParentTraversalDepth: config.features.googleDriveMaxParentTraversalDepth, }, - process.env.GOOGLE_DRIVE_REPORT_ONLY_VIOLATIONS !== 'false', - process.env.GOOGLE_DRIVE_AUDIT_CHANNEL || undefined, + config.features.googleDriveReportOnlyViolations, + config.features.googleDriveAuditChannel, ); await gdCheckModule.init(); } + const httpServer = createWebServer(config, collections, settingsStore); + console.log('⚡️ Bolt app is running!'); const shutdown = async () => { console.log('Shutting down...'); + httpServer.close(); await app.stop(); - await client.close(); + await dbClient.close(); process.exit(0); }; process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); -})(); \ No newline at end of file +})(); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..c6e745c --- /dev/null +++ b/src/config.ts @@ -0,0 +1,92 @@ +import { config } from 'dotenv'; +config(); + +import { getBoltLogLevel } from './utils/slack'; + +export interface Config { + slack: { + botToken: string; + signingSecret: string; + appToken: string; + userToken: string; + logLevel: ReturnType; + }; + db: { + connString: string; + }; + app: { + fileSavePrefix: string; + autoJoinChannels: boolean; + }; + features: { + useReactionCheck: boolean; + reactionUserIgnoreList: string[]; + useGoogleDriveCheck: boolean; + googleDriveAllowedSharedDriveIds: string[]; + googleDriveAllowedFolderIds: string[]; + googleDriveAllowDomainSharing: boolean; + googleDriveMaxParentTraversalDepth: number; + googleDriveReportOnlyViolations: boolean; + googleDriveAuditChannel?: string; + googleServiceAccountKeyFile?: string; + googleServiceAccountKeyJson?: string; + }; + api: { + port: number; + apiKey: string; + }; +} + +function required(name: string): string { + const val = process.env[name]; + if (!val) throw new Error(`Env ${name} is required.`); + return val; +} + +function optional(name: string): string | undefined { + return process.env[name] || undefined; +} + +export function loadConfig(): Config { + const useGoogleDriveCheck = process.env.USE_GOOGLE_DRIVE_CHECK === 'true'; + + if (useGoogleDriveCheck && !process.env.GOOGLE_SERVICE_ACCOUNT_KEY_FILE && !process.env.GOOGLE_SERVICE_ACCOUNT_KEY_JSON) { + throw new Error('GOOGLE_SERVICE_ACCOUNT_KEY_FILE or GOOGLE_SERVICE_ACCOUNT_KEY_JSON is required when USE_GOOGLE_DRIVE_CHECK=true.'); + } + + return { + slack: { + botToken: required('SLACK_BOT_TOKEN'), + signingSecret: required('SLACK_SIGNING_SECRET'), + appToken: required('SLACK_APP_TOKEN'), + userToken: required('SLACK_USER_TOKEN'), + logLevel: getBoltLogLevel(process.env.LOG_LEVEL), + }, + db: { + connString: required('DB_CONN_STRING'), + }, + app: { + fileSavePrefix: required('SLACK_FILE_SAVE_PREFIX'), + autoJoinChannels: process.env.AUTO_JOIN_CHANNELS !== 'false', + }, + features: { + useReactionCheck: process.env.USE_REACTION_CHECK === 'true', + reactionUserIgnoreList: process.env.REACTION_USER_IGN_LIST + ? process.env.REACTION_USER_IGN_LIST.split(',') + : [], + useGoogleDriveCheck, + googleDriveAllowedSharedDriveIds: (process.env.GOOGLE_DRIVE_ALLOWED_SHARED_DRIVE_IDS || '').split(',').filter(Boolean), + googleDriveAllowedFolderIds: (process.env.GOOGLE_DRIVE_ALLOWED_FOLDER_IDS || '').split(',').filter(Boolean), + googleDriveAllowDomainSharing: process.env.GOOGLE_DRIVE_ALLOW_DOMAIN_SHARING === 'true', + googleDriveMaxParentTraversalDepth: 10, + googleDriveReportOnlyViolations: process.env.GOOGLE_DRIVE_REPORT_ONLY_VIOLATIONS !== 'false', + googleDriveAuditChannel: optional('GOOGLE_DRIVE_AUDIT_CHANNEL'), + googleServiceAccountKeyFile: optional('GOOGLE_SERVICE_ACCOUNT_KEY_FILE'), + googleServiceAccountKeyJson: optional('GOOGLE_SERVICE_ACCOUNT_KEY_JSON'), + }, + api: { + port: parseInt(process.env.API_PORT || '3000', 10), + apiKey: required('API_KEY'), + }, + }; +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..d72232a --- /dev/null +++ b/src/db.ts @@ -0,0 +1,25 @@ +import * as mongoDB from 'mongodb'; + +export interface DbCollections { + messages: mongoDB.Collection; + changedMessages: mongoDB.Collection; + deletedMessages: mongoDB.Collection; + driveComplianceJobs: mongoDB.Collection; + settings: mongoDB.Collection; +} + +export async function connectDb(connString: string): Promise<{ client: mongoDB.MongoClient; collections: DbCollections }> { + const client = new mongoDB.MongoClient(connString); + await client.connect(); + const db = client.db(); + return { + client, + collections: { + messages: db.collection('messages'), + changedMessages: db.collection('changedMessages'), + deletedMessages: db.collection('deletedMessages'), + driveComplianceJobs: db.collection('driveComplianceJobs'), + settings: db.collection('settings'), + }, + }; +} diff --git a/src/app-home/index.ts b/src/modules/app-home/index.ts similarity index 100% rename from src/app-home/index.ts rename to src/modules/app-home/index.ts diff --git a/src/google-drive-checker/index.ts b/src/modules/google-drive/checker.ts similarity index 100% rename from src/google-drive-checker/index.ts rename to src/modules/google-drive/checker.ts diff --git a/src/google-drive-check/index.ts b/src/modules/google-drive/handler.ts similarity index 99% rename from src/google-drive-check/index.ts rename to src/modules/google-drive/handler.ts index a94a996..fdd65f1 100644 --- a/src/google-drive-check/index.ts +++ b/src/modules/google-drive/handler.ts @@ -2,7 +2,7 @@ import { App } from '@slack/bolt'; import { KnownBlock } from '@slack/types'; import { drive_v3 } from 'googleapis'; import { Collection } from 'mongodb'; -import { checkDriveCompliance, ComplianceCheckerOptions, ComplianceResult } from '../google-drive-checker'; +import { checkDriveCompliance, ComplianceCheckerOptions, ComplianceResult } from './checker'; const DRIVE_LINK_PATTERNS = [ /https?:\/\/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/g, diff --git a/src/google-drive-checker/types.ts b/src/modules/google-drive/types.ts similarity index 100% rename from src/google-drive-checker/types.ts rename to src/modules/google-drive/types.ts diff --git a/src/reaction-check/index.ts b/src/modules/reaction-check/index.ts similarity index 100% rename from src/reaction-check/index.ts rename to src/modules/reaction-check/index.ts diff --git a/src/modules/settings/store.ts b/src/modules/settings/store.ts new file mode 100644 index 0000000..cd5f3c2 --- /dev/null +++ b/src/modules/settings/store.ts @@ -0,0 +1,50 @@ +import { Collection } from 'mongodb'; + +export interface BotSettings { + useReactionCheck: boolean; + useGoogleDriveCheck: boolean; + autoJoinChannels: boolean; + reactionUserIgnoreList: string[]; + googleDriveReportOnlyViolations: boolean; + googleDriveAuditChannel?: string; + googleDriveAllowedSharedDriveIds: string[]; + googleDriveAllowedFolderIds: string[]; + updatedAt: Date; +} + +export type PatchSettingsBody = Partial>; + +const SETTINGS_DOC_ID = 'main'; + +export class SettingsStore { + private cache?: BotSettings; + + constructor(private col: Collection) {} + + async load(defaults: Omit): Promise { + const existing = await this.col.findOne({ _id: SETTINGS_DOC_ID as any }); + if (existing) { + this.cache = existing; + } else { + const initial: BotSettings = { ...defaults, updatedAt: new Date() }; + await this.col.insertOne({ _id: SETTINGS_DOC_ID as any, ...initial }); + this.cache = initial; + } + return this.cache; + } + + get(): BotSettings { + if (!this.cache) throw new Error('SettingsStore not loaded. Call load() first.'); + return this.cache; + } + + async patch(update: PatchSettingsBody): Promise { + const now = new Date(); + await this.col.updateOne( + { _id: SETTINGS_DOC_ID as any }, + { $set: { ...update, updatedAt: now } }, + ); + this.cache = { ...this.get(), ...update, updatedAt: now }; + return this.cache; + } +} diff --git a/src/slack-storage/index.ts b/src/modules/slack-storage/index.ts similarity index 98% rename from src/slack-storage/index.ts rename to src/modules/slack-storage/index.ts index e9231cf..8410ee3 100644 --- a/src/slack-storage/index.ts +++ b/src/modules/slack-storage/index.ts @@ -1,7 +1,7 @@ import { App, ignoreSelf, KnownEventFromType, subtype } from "@slack/bolt"; import { mkdir } from "fs/promises"; import { Collection } from "mongodb"; -import { downloadFileFromSlack } from "../utils/slack"; +import { downloadFileFromSlack } from "../../utils/slack"; export type SlackStorageModuleOptions = { autoJoinChannels: boolean; diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts new file mode 100644 index 0000000..a7a63f6 --- /dev/null +++ b/src/web/middleware/auth.ts @@ -0,0 +1,22 @@ +import { timingSafeEqual } from 'crypto'; +import { RequestHandler } from 'express'; + +export function requireApiKey(apiKey: string): RequestHandler { + const keyBuf = Buffer.from(apiKey); + return (req, res, next) => { + const header = req.headers['authorization'] ?? ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : ''; + let valid = false; + try { + const tokenBuf = Buffer.from(token); + valid = tokenBuf.length === keyBuf.length && timingSafeEqual(tokenBuf, keyBuf); + } catch { + valid = false; + } + if (!valid) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + next(); + }; +} diff --git a/src/web/routes/drive.ts b/src/web/routes/drive.ts new file mode 100644 index 0000000..b9ab823 --- /dev/null +++ b/src/web/routes/drive.ts @@ -0,0 +1,65 @@ +import { Router } from 'express'; +import { ObjectId } from 'mongodb'; +import type { DbCollections } from '../../db'; +import type { DriveJobResponse, ListResponse, PaginationQuery } from '../types'; + +const VALID_STATUSES = ['pending', 'completed', 'error'] as const; + +export function createDriveRouter(collections: DbCollections): Router { + const router = Router(); + + router.get('/jobs', async (req, res) => { + const { limit: limitStr, before, status } = req.query as PaginationQuery & { status?: string }; + const limit = Math.min(parseInt(String(limitStr || '50'), 10) || 50, 200); + + const filter: Record = {}; + + if (status) { + if (!VALID_STATUSES.includes(status as any)) { + res.status(400).json({ error: `status must be one of: ${VALID_STATUSES.join(', ')}` }); + return; + } + filter.status = status; + } + + if (before) { + try { + filter._id = { $lt: new ObjectId(before) }; + } catch { + res.status(400).json({ error: 'invalid before cursor' }); + return; + } + } + + const [data, total] = await Promise.all([ + collections.driveComplianceJobs.find(filter).sort({ _id: -1 }).limit(limit).toArray(), + collections.driveComplianceJobs.countDocuments(filter), + ]); + + const response: ListResponse = { + data: data as unknown as DriveJobResponse[], + total, + hasMore: data.length === limit, + }; + res.json(response); + }); + + router.get('/jobs/:id', async (req, res) => { + let oid: ObjectId; + try { + oid = new ObjectId(req.params.id); + } catch { + res.status(400).json({ error: 'invalid id' }); + return; + } + + const doc = await collections.driveComplianceJobs.findOne({ _id: oid }); + if (!doc) { + res.status(404).json({ error: 'not found' }); + return; + } + res.json(doc); + }); + + return router; +} diff --git a/src/web/routes/messages.ts b/src/web/routes/messages.ts new file mode 100644 index 0000000..fcfa9e5 --- /dev/null +++ b/src/web/routes/messages.ts @@ -0,0 +1,64 @@ +import { Router } from 'express'; +import { ObjectId } from 'mongodb'; +import type { DbCollections } from '../../db'; +import type { ListResponse, PaginationQuery } from '../types'; + +const VALID_COLLECTIONS = ['messages', 'changedMessages', 'deletedMessages'] as const; +type MessageCollection = typeof VALID_COLLECTIONS[number]; + +export function createMessagesRouter(collections: DbCollections): Router { + const router = Router(); + + router.get('/', async (req, res) => { + const collectionName = (req.query.collection as string) || 'messages'; + if (!VALID_COLLECTIONS.includes(collectionName as MessageCollection)) { + res.status(400).json({ error: `collection must be one of: ${VALID_COLLECTIONS.join(', ')}` }); + return; + } + + const { limit: limitStr, before } = req.query as PaginationQuery; + const limit = Math.min(parseInt(String(limitStr || '50'), 10) || 50, 200); + + const col = collections[collectionName as MessageCollection]; + const filter: Record = {}; + if (before) { + try { + filter._id = { $lt: new ObjectId(before) }; + } catch { + res.status(400).json({ error: 'invalid before cursor' }); + return; + } + } + + const [data, total] = await Promise.all([ + col.find(filter).sort({ _id: -1 }).limit(limit).toArray(), + col.countDocuments(filter), + ]); + + const response: ListResponse = { + data, + total, + hasMore: data.length === limit, + }; + res.json(response); + }); + + router.get('/:id', async (req, res) => { + let oid: ObjectId; + try { + oid = new ObjectId(req.params.id); + } catch { + res.status(400).json({ error: 'invalid id' }); + return; + } + + const doc = await collections.messages.findOne({ _id: oid }); + if (!doc) { + res.status(404).json({ error: 'not found' }); + return; + } + res.json(doc); + }); + + return router; +} diff --git a/src/web/routes/settings.ts b/src/web/routes/settings.ts new file mode 100644 index 0000000..069cae2 --- /dev/null +++ b/src/web/routes/settings.ts @@ -0,0 +1,43 @@ +import { Router } from 'express'; +import type { SettingsStore } from '../../modules/settings/store'; +import type { PatchSettingsBody, BotSettings } from '../types'; + +const ALLOWED_KEYS: Array = [ + 'useReactionCheck', + 'useGoogleDriveCheck', + 'autoJoinChannels', + 'reactionUserIgnoreList', + 'googleDriveReportOnlyViolations', + 'googleDriveAuditChannel', + 'googleDriveAllowedSharedDriveIds', + 'googleDriveAllowedFolderIds', +]; + +export function createSettingsRouter(store: SettingsStore): Router { + const router = Router(); + + router.get('/', (_req, res) => { + res.json(store.get()); + }); + + router.patch('/', async (req, res) => { + const body = req.body as Record; + const unknownKeys = Object.keys(body).filter(k => !ALLOWED_KEYS.includes(k as any)); + if (unknownKeys.length > 0) { + res.status(400).json({ error: `unknown fields: ${unknownKeys.join(', ')}` }); + return; + } + + const update: PatchSettingsBody = {}; + for (const key of ALLOWED_KEYS) { + if (key in body) { + (update as any)[key] = body[key]; + } + } + + const updated = await store.patch(update); + res.json(updated); + }); + + return router; +} diff --git a/src/web/server.ts b/src/web/server.ts new file mode 100644 index 0000000..a6ad528 --- /dev/null +++ b/src/web/server.ts @@ -0,0 +1,30 @@ +import express, { Router } from 'express'; +import * as http from 'http'; +import type { Config } from '../config'; +import type { DbCollections } from '../db'; +import type { SettingsStore } from '../modules/settings/store'; +import { requireApiKey } from './middleware/auth'; +import { createMessagesRouter } from './routes/messages'; +import { createDriveRouter } from './routes/drive'; +import { createSettingsRouter } from './routes/settings'; + +export function createWebServer( + config: Config, + collections: DbCollections, + store: SettingsStore, +): http.Server { + const app = express(); + app.use(express.json()); + + const api = Router(); + api.use(requireApiKey(config.api.apiKey)); + api.use('/messages', createMessagesRouter(collections)); + api.use('/drive', createDriveRouter(collections)); + api.use('/settings', createSettingsRouter(store)); + + app.use('/api', api); + + return app.listen(config.api.port, () => { + console.log(`Web server listening on port ${config.api.port}`); + }); +} diff --git a/src/web/types.ts b/src/web/types.ts new file mode 100644 index 0000000..e52bd3d --- /dev/null +++ b/src/web/types.ts @@ -0,0 +1,27 @@ +import type { BotSettings, PatchSettingsBody } from '../modules/settings/store'; +import type { ComplianceResult } from '../modules/google-drive/types'; + +export type { BotSettings, PatchSettingsBody }; + +export interface PaginationQuery { + limit?: number; + before?: string; +} + +export interface ListResponse { + data: T[]; + total: number; + hasMore: boolean; +} + +export interface DriveJobResponse { + _id: string; + fileId: string; + url: string; + channel: string; + thread_ts: string; + requestedAt: string; + status: 'pending' | 'completed' | 'error'; + result?: ComplianceResult; + completedAt?: string; +}