@mieweb/wikigdrive
Version:
Google Drive to MarkDown synchronization
416 lines (341 loc) • 14.8 kB
text/typescript
import process from 'node:process';
import http from 'node:http';
import path from 'node:path';
import {WebSocketServer} from 'ws';
import type {Express, NextFunction, Request, Response} from 'express';
import express from 'express';
import winston from 'winston';
import cookieParser from 'cookie-parser';
import rateLimit from 'express-rate-limit';
import compress from 'compression';
import {Container, ContainerConfig, ContainerEngine} from '../../ContainerEngine.ts';
import {saveRunningInstance} from './loadRunningInstance.ts';
import {urlToFolderId} from '../../utils/idParsers.ts';
import {GoogleDriveService} from '../../google/GoogleDriveService.ts';
import {FolderRegistryContainer} from '../folder_registry/FolderRegistryContainer.ts';
import {DriveJobsMap, initJob, JobManagerContainer} from '../job/JobManagerContainer.ts';
import GitController from './routes/GitController.ts';
import FolderController from './routes/FolderController.ts';
import {ConfigController} from './routes/ConfigController.ts';
import {DriveController} from './routes/DriveController.ts';
import {BackLinksController} from './routes/BackLinksController.ts';
import {GoogleDriveController} from './routes/GoogleDriveController.ts';
import {LogsController} from './routes/LogsController.ts';
import {PreviewController} from './routes/PreviewController.ts';
import {SocketManager} from './SocketManager.ts';
import {
authenticate,
GoogleUser,
getAuth,
authenticateOptionally,
validateGetAuthState,
handleDriveUiInstall, handleShare, handlePopupClose, redirError
} from './auth.ts';
import {filterParams} from '../../google/driveFetch.ts';
import {SearchController} from './routes/SearchController.ts';
import {DriveUiController} from './routes/DriveUiController.ts';
import {GoogleApiContainer} from '../google_api/GoogleApiContainer.ts';
import {UserAuthClient} from '../../google/AuthClient.ts';
import {getTokenInfo} from '../../google/GoogleAuthService.ts';
import {GoogleTreeProcessor} from '../google_folder/GoogleTreeProcessor.ts';
import {initStaticDistPages} from './static.ts';
import {initUiServer} from './vuejs.ts';
import {initErrorHandler} from './error.ts';
import {WebHookController} from './routes/WebHookController.ts';
const __filename = import.meta.filename;
const __dirname = import.meta.dirname;
const MAIN_DIR = __dirname + '/../../..';
function getDurationInMilliseconds(start) {
const NS_PER_SEC = 1e9;
const NS_TO_MS = 1e6;
const diff = process.hrtime(start);
return Math.round((diff[0] * NS_PER_SEC + diff[1]) / NS_TO_MS);
}
export class ServerContainer extends Container {
private logger: winston.Logger;
private app: Express;
private authContainer: Container;
private socketManager: SocketManager;
constructor(params: ContainerConfig, private port: number) {
super(params);
}
async init(engine: ContainerEngine): Promise<void> {
await super.init(engine);
this.logger = engine.logger.child({ filename: __filename });
this.authContainer = engine.getContainer('google_api');
this.socketManager = new SocketManager(this.engine);
await this.socketManager.mount(this.filesService);
await saveRunningInstance(this.port);
}
async destroy(): Promise<void> {
// TODO
}
private async startServer(port) {
const app = this.app = express();
app.use(express.json({
limit: '50mb'
}));
app.use(express.text({
limit: '50mb'
}));
app.use(cookieParser());
app.use((req, res, next) => {
res.header('GIT_SHA', process.env.GIT_SHA);
// res.header('x-frame-options', 'ALLOW-FROM https://docs.google.com/');
res.header('Content-Security-Policy', 'frame-ancestors \'self\' https://*.googleusercontent.com https://docs.google.com;');
next();
});
if (express['addExpressTelemetry']) {
express['addExpressTelemetry'](app);
}
app.use(rateLimit({
windowMs: 60 * 1000,
max: 3000
}));
app.use((req, res, next) => {
res.header('wgd-share-email', this.params.share_email || '');
next();
});
await this.initRouter(app);
await this.initAuth(app);
if (process.env.GIT_SHA === 'dev') {
await initStaticDistPages(app);
await initUiServer(app, this.logger);
}
app.use(express.static(path.resolve(MAIN_DIR, 'website', '.vitepress', 'dist'), { extensions: ['html'] }));
app.use(express.static(path.resolve(MAIN_DIR, 'apps', 'ui', 'dist')));
await initErrorHandler(app, this.logger);
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
wss.on('connection', (ws, req) => {
if (!req.url || !req.url.startsWith('/api/')) {
return;
}
const parts = req.url.split('/');
if (!parts[2]) {
return;
}
this.socketManager.addSocketConnection(ws, parts[2]);
});
server.listen(port, () => {
this.logger.info('Started server on port: ' + port);
});
}
async initAuth(app) {
app.use('/auth/logout', authenticateOptionally(this.logger));
app.post('/auth/logout', async (req, res) => {
if (req.user?.google_access_token) {
const authClient = new UserAuthClient(process.env.GOOGLE_AUTH_CLIENT_ID, process.env.GOOGLE_AUTH_CLIENT_SECRET);
await authClient.revokeToken(req.user.google_access_token);
}
res.clearCookie('accessToken');
res.json({ loggedOut: true });
});
app.get('/auth/:driveId', async (req, res, next) => {
try {
const serverUrl = process.env.AUTH_DOMAIN || process.env.DOMAIN;
const driveId = urlToFolderId(req.params.driveId);
const redirectTo = req.query.redirectTo;
const popupWindow = req.query.popupWindow;
const state = new URLSearchParams(filterParams({
driveId: driveId !== 'none' ? (driveId || '') : '',
redirectTo,
popupWindow: popupWindow === 'true' ? 'true' : '',
instance: process.env.AUTH_INSTANCE
})).toString();
const authClient = new UserAuthClient(process.env.GOOGLE_AUTH_CLIENT_ID, process.env.GOOGLE_AUTH_CLIENT_SECRET);
const authUrl = await authClient.getWebAuthUrl(`${serverUrl}/auth`, state);
if (process.env.VERSION === 'dev') {
console.debug(authUrl);
}
res.redirect(authUrl);
} catch (err) {
next(err);
}
});
app.get('/auth', validateGetAuthState, handleDriveUiInstall, handleShare, handlePopupClose, (...args) => {
getAuth.call(this, ...args);
});
app.use('/user/me', authenticateOptionally(this.logger));
app.get('/user/me', async (req, res, next) => {
try {
if (!req.user || !req.user.google_access_token) {
res.json({ user: undefined });
return;
}
const tokenInfo = await getTokenInfo(req.user.google_access_token);
if (!tokenInfo.expiry_date) {
res.json({ user: undefined, tokenInfo });
return;
}
const user: GoogleUser = {
id: req.user.id,
email: req.user.email,
name: req.user.name,
};
res.json({ user, tokenInfo });
} catch (err) {
next(err);
}
});
}
async initRouter(app) {
app.use(async (req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith('/api/')) {
const start = process.hrtime();
res.on('finish', () => {
const durationInMilliseconds = getDurationInMilliseconds(start);
this.logger.info(`${req.method} ${req.originalUrl} ${durationInMilliseconds}ms`);
});
}
next();
});
app.use(compress());
const driveController = new DriveController('/api/drive', this.filesService,
<FolderRegistryContainer>this.engine.getContainer('folder_registry'), this.authContainer);
app.use('/api/drive', authenticate(this.logger), await driveController.getRouter());
const gitController = new GitController('/api/git', this.filesService,
<JobManagerContainer>this.engine.getContainer('job_manager'), this.engine);
app.use('/api/git', authenticate(this.logger), await gitController.getRouter());
const folderController = new FolderController('/api/file', this.filesService, this.engine);
app.use('/api/file', authenticate(this.logger), await folderController.getRouter());
const googleDriveController = new GoogleDriveController('/api/gdrive', this.filesService);
app.use('/api/gdrive', authenticate(this.logger), await googleDriveController.getRouter());
const backlinksController = new BackLinksController('/api/backlinks', this.filesService);
app.use('/api/backlinks', authenticate(this.logger), await backlinksController.getRouter());
const configController = new ConfigController('/api/config', this.filesService, <FolderRegistryContainer>this.engine.getContainer('folder_registry'), this.engine);
app.use('/api/config', authenticate(this.logger), await configController.getRouter());
const logsController = new LogsController('/api/logs', this.logger);
app.use('/api/logs', authenticate(this.logger), await logsController.getRouter());
const searchController = new SearchController('/api/search', this.filesService);
app.use('/api/search', authenticate(this.logger), await searchController.getRouter());
const previewController = new PreviewController('/preview', this.logger);
app.use('/preview', authenticate(this.logger), await previewController.getRouter());
const driveUiController = new DriveUiController('/driveui', this.logger, this.filesService, <GoogleApiContainer>this.authContainer);
app.use('/driveui', await driveUiController.getRouter());
const webHookController = new WebHookController('/webhook', this.logger);
app.use('/webhook', await webHookController.getRouter());
app.use('/api/share-token', authenticate(this.logger), (req, res) => {
if ('POST' !== req.method) {
throw new Error('Incorrect method');
}
if (req.user) {
const { google_access_token } = req.user;
if (google_access_token) {
res.json({ google_access_token, share_email: this.params.share_email });
return;
}
}
res.json({});
});
app.get('/api/ps', async (req, res, next) => {
try {
const jobManagerContainer = <JobManagerContainer>this.engine.getContainer('job_manager');
const driveJobsMap: DriveJobsMap = await jobManagerContainer.ps();
const folderRegistryContainer = <FolderRegistryContainer>this.engine.getContainer('folder_registry');
const folders = await folderRegistryContainer.getFolders();
const retVal = [];
for (const folderId in folders) {
const driveJobs = driveJobsMap[folderId] || { jobs: [] };
const folder = folders[folderId];
retVal.push({
folderId, name: folder.name, jobs_count: driveJobs.jobs.length
});
}
res.json(retVal);
} catch (err) {
next(err);
}
});
app.post('/api/run_action/:driveId/:action_id', authenticate(this.logger, 2), async (req, res, next) => {
try {
const driveId = urlToFolderId(req.params.driveId);
const jobManagerContainer = <JobManagerContainer>this.engine.getContainer('job_manager');
await jobManagerContainer.schedule(driveId, {
...initJob(),
type: 'run_action',
title: 'Run action: ' + req.params.action_id,
action_id: req.params.action_id,
payload: req.body ? JSON.stringify(req.body) : '',
user: req.user
});
res.json({ driveId });
} catch (err) {
next(err);
}
});
app.post('/api/sync/:driveId', authenticate(this.logger, 2), async (req, res, next) => {
try {
const driveId = urlToFolderId(req.params.driveId);
const jobManagerContainer = <JobManagerContainer>this.engine.getContainer('job_manager');
await jobManagerContainer.schedule(driveId, {
...initJob(),
type: 'sync_all',
title: 'Syncing all'
});
res.json({ driveId });
} catch (err) {
next(err);
}
});
app.post('/api/sync/:driveId/:fileId', authenticate(this.logger, 2), async (req, res, next) => {
try {
const driveId = urlToFolderId(req.params.driveId);
const fileId = req.params.fileId;
let fileTitle = '#' + fileId;
const driveFileSystem = await this.filesService.getSubFileService(driveId, '');
const googleTreeProcessor = new GoogleTreeProcessor(driveFileSystem);
await googleTreeProcessor.load();
const [file, drivePath] = await googleTreeProcessor.findById(fileId);
if (file && drivePath) {
fileTitle = file['name'];
}
const jobManagerContainer = <JobManagerContainer>this.engine.getContainer('job_manager');
await jobManagerContainer.schedule(driveId, {
...initJob(),
type: 'sync',
payload: fileId,
title: 'Syncing file: ' + fileTitle
});
res.json({ driveId, fileId });
} catch (err) {
next(err);
}
});
app.get('/api/inspect/:driveId', authenticate(this.logger, 2), async (req, res, next) => {
try {
const driveId = urlToFolderId(req.params.driveId);
const jobManagerContainer = <JobManagerContainer>this.engine.getContainer('job_manager');
const inspected = await jobManagerContainer.inspect(driveId);
const folderRegistryContainer = <FolderRegistryContainer>this.engine.getContainer('folder_registry');
const folders = await folderRegistryContainer.getFolders();
inspected['folder'] = folders[driveId];
res.json(inspected);
} catch (err) {
next(err);
}
});
app.post('/api/share_drive', authenticate(this.logger, -1), async (req, res, next) => {
try {
const folderUrl = req.body.url;
const driveId = urlToFolderId(folderUrl);
if (!driveId) {
throw new Error('No DriveId');
}
if (!req.user?.google_access_token) {
throw redirError(req, 'Not authenticated');
}
const googleDriveService = new GoogleDriveService(this.logger, null);
const drive = await googleDriveService.getDrive(req.user.google_access_token, driveId);
const folderRegistryContainer = <FolderRegistryContainer>this.engine.getContainer('folder_registry');
const folder = await folderRegistryContainer.registerFolder(drive.id);
res.json(folder);
} catch (err) {
next(err);
}
});
}
async run() {
await this.startServer(this.port);
}
}