// // Copyright © 2023 Hardcore Engineering Inc. // import account, { type AccountMethods, EndpointKind, accountId, getAccountDB, getAllTransactors, getMethods, cleanExpiredOtp } from '@hcengineering/account' import accountEn from '@hcengineering/account/lang/en.json' import accountRu from '@hcengineering/account/lang/ru.json' import { Analytics } from '@hcengineering/analytics' import { registerProviders } from '@hcengineering/auth-providers' import { metricsAggregate, type BrandingMap, type MeasureContext } from '@hcengineering/core' import platform, { Severity, Status, addStringsLoader, setMetadata } from '@hcengineering/platform' import serverToken, { decodeToken, decodeTokenVerbose, generateToken } from '@hcengineering/server-token' import cors from '@koa/cors' import type Cookies from 'cookies' import { type IncomingHttpHeaders } from 'http' import Koa from 'koa' import bodyParser from 'koa-bodyparser' import Router from 'koa-router' import os from 'os' import { migrateFromOldAccounts } from './migration/migration' const AUTH_TOKEN_COOKIE = 'account-metadata-Token' const KEEP_ALIVE_HEADERS = { 'Content-Type': 'application/json', Connection: 'keep-alive', 'Keep-Alive': 'timeout=5, max=1000' } /** * @public */ export function serveAccount (measureCtx: MeasureContext, brandings: BrandingMap, onClose?: () => void): void { console.log('Starting account service with brandings: ', brandings) const ACCOUNT_PORT = parseInt(process.env.ACCOUNT_PORT ?? '3000') const dbUrl = process.env.DB_URL if (dbUrl === undefined) { console.log('Please provide DB_URL') process.exit(1) } const oldAccsUrl = process.env.OLD_ACCOUNTS_URL ?? (dbUrl.startsWith('mongodb://') ? dbUrl : undefined) const transactorUri = process.env.TRANSACTOR_URL if (transactorUri === undefined) { console.log('Please provide transactor url') process.exit(1) } const serverSecret = process.env.SERVER_SECRET if (serverSecret === undefined) { console.log('Please provide server secret') process.exit(1) } addStringsLoader(accountId, async (lang: string) => { switch (lang) { case 'en': return accountEn case 'ru': return accountRu default: return accountEn } }) const mailUrl = process.env.MAIL_URL const mailAuthToken = process.env.MAIL_AUTH_TOKEN const frontURL = process.env.FRONT_URL const productName = process.env.PRODUCT_NAME const lang = process.env.LANGUAGE ?? 'en' const wsLivenessDaysRaw = process.env.WS_LIVENESS_DAYS let wsLivenessDays: number | undefined if (wsLivenessDaysRaw !== undefined) { try { wsLivenessDays = parseInt(wsLivenessDaysRaw) } catch (err: any) { // DO NOTHING } } setMetadata(account.metadata.Transactors, transactorUri) setMetadata(platform.metadata.locale, lang) setMetadata(account.metadata.ProductName, productName) setMetadata(account.metadata.OtpTimeToLiveSec, parseInt(process.env.OTP_TIME_TO_LIVE ?? '60')) setMetadata(account.metadata.OtpRetryDelaySec, parseInt(process.env.OTP_RETRY_DELAY ?? '60')) setMetadata(account.metadata.MAIL_URL, mailUrl) setMetadata(account.metadata.MAIL_AUTH_TOKEN, mailAuthToken) setMetadata(account.metadata.FrontURL, frontURL) setMetadata(account.metadata.WsLivenessDays, wsLivenessDays) setMetadata(serverToken.metadata.Secret, serverSecret) const hasSignUp = process.env.DISABLE_SIGNUP !== 'true' const methods = getMethods(hasSignUp) const dbNs = process.env.DB_NS const accountsDb = getAccountDB(dbUrl, dbNs) const migrations = accountsDb.then(async ([db]) => { if (oldAccsUrl !== undefined) { await migrateFromOldAccounts(oldAccsUrl, db) console.log('Migrations verified/done') } }) const app = new Koa() const router = new Router() app.use( cors({ credentials: true }) ) app.use(bodyParser()) registerProviders( measureCtx, app, router, new Promise((resolve) => { void accountsDb.then((res) => { const [db] = res resolve(db) }) }), serverSecret, frontURL, brandings, !hasSignUp ) void accountsDb.then((res) => { const [db] = res setInterval( () => { void cleanExpiredOtp(db) }, 3 * 60 * 1000 ) }) const extractCookieToken = (headers: IncomingHttpHeaders): string | undefined => { if (headers.cookie != null) { const cookies = headers.cookie.split(';') const tokenCookie = cookies.find((cookie) => cookie.includes(AUTH_TOKEN_COOKIE)) return tokenCookie?.split('=')[1] } return undefined } const extractAuthorizationToken = (headers: IncomingHttpHeaders): string | undefined => { try { return headers.authorization?.slice(7) ?? undefined } catch { return undefined } } const extractToken = (headers: IncomingHttpHeaders): string | undefined => { return extractAuthorizationToken(headers) ?? extractCookieToken(headers) } function getCookieOptions (ctx: Koa.Context): Cookies.SetOption { const requestUrl = ctx.request.href const url = new URL(requestUrl) const domain = getCookieDomain(requestUrl) return { httpOnly: true, domain, secure: url?.protocol === 'https', maxAge: 1000 * 60 * 60 * 24 * 365 // 1 year } } const getCookieDomain = (url: string): string => { const hostname = new URL(url).hostname if (hostname === 'localhost') { return hostname } if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) { return hostname } const parts = hostname.split('.') if (parts.length > 2) { return '.' + parts.slice(1).join('.') } return hostname } router.get('/api/v1/statistics', (req, res) => { try { const token = (req.query.token as string) ?? extractToken(req.headers) const payload = decodeToken(token) const admin = payload.extra?.admin === 'true' const data: Record = { metrics: admin ? metricsAggregate((measureCtx as any).metrics) : {}, statistics: {} } data.statistics.totalClients = 0 const mem = process.memoryUsage() data.statistics.memoryUsed = Math.round((mem.heapUsed / 1024 / 1024) * 100) / 100 data.statistics.memoryTotal = Math.round((mem.heapTotal / 1024 / 1024) * 100) / 100 data.statistics.memoryRSS = Math.round((mem.rss / 1024 / 1024) * 100) / 100 data.statistics.memoryArrayBuffers = Math.round((mem.arrayBuffers / 1024 / 1024) * 100) / 100 data.statistics.cpuUsage = Math.round(os.loadavg()[0] * 100) / 100 data.statistics.freeMem = Math.round((os.freemem() / 1024 / 1024) * 100) / 100 data.statistics.totalMem = Math.round((os.totalmem() / 1024 / 1024) * 100) / 100 const json = JSON.stringify(data) req.res.writeHead(200, KEEP_ALIVE_HEADERS) req.res.end(json) } catch (err: any) { Analytics.handleError(err) console.error(err) req.res.writeHead(404, {}) req.res.end() } }) router.put('/cookie', async (ctx) => { const token = extractToken(ctx.request.headers) if (token === undefined) { ctx.body = JSON.stringify({ error: new Status(Severity.ERROR, platform.status.Unauthorized, {}) }) ctx.res.writeHead(401) ctx.res.end() return } // Ensure we don't set the token with workspace to the cookie const { account, extra } = decodeTokenVerbose(measureCtx, token) const tokenWithoutWorkspace = generateToken(account, undefined, extra) const cookieOpts = getCookieOptions(ctx) ctx.cookies.set(AUTH_TOKEN_COOKIE, tokenWithoutWorkspace, cookieOpts) ctx.res.writeHead(204) ctx.res.end() }) router.delete('/cookie', async (ctx) => { const token = extractToken(ctx.request.headers) if (token === undefined) { ctx.body = JSON.stringify({ error: new Status(Severity.ERROR, platform.status.Unauthorized, {}) }) ctx.res.writeHead(401) ctx.res.end() return } const cookieOpts = { ...getCookieOptions(ctx), maxAge: 0 } ctx.cookies.set(AUTH_TOKEN_COOKIE, '', cookieOpts) ctx.res.writeHead(201) ctx.res.end() }) router.put('/api/v1/manage', async (req, res) => { try { const token = req.query.token as string const payload = decodeToken(token) if (payload.extra?.admin !== 'true') { req.res.writeHead(404, {}) req.res.end() return } const operation = req.query.operation switch (operation) { case 'maintenance': { const timeMinutes = parseInt((req.query.timeout as string) ?? '5') const transactors = getAllTransactors(EndpointKind.Internal) for (const tr of transactors) { const serverEndpoint = tr.replaceAll('wss://', 'https://').replace('ws://', 'http://') await fetch(serverEndpoint + `/api/v1/manage?token=${token}&operation=maintenance&timeout=${timeMinutes}`, { method: 'PUT' }) } req.res.writeHead(200) req.res.end() return } } req.res.writeHead(404, {}) req.res.end() } catch (err: any) { Analytics.handleError(err) req.res.writeHead(404, {}) req.res.end() } }) router.post('rpc', '/', async (ctx) => { const token = extractToken(ctx.request.headers) const request = ctx.request.body as any const method = methods[request.method as AccountMethods] if (method === undefined) { const response = { id: request.id, error: new Status(Severity.ERROR, platform.status.UnknownMethod, { method: request.method }) } const body = JSON.stringify(response) ctx.res.writeHead(404, KEEP_ALIVE_HEADERS) ctx.res.end(body) return } const [db] = await accountsDb await migrations let host: string | undefined const origin = ctx.request.headers.origin ?? ctx.request.headers.referer if (origin !== undefined) { host = new URL(origin).host } const branding = host !== undefined ? brandings[host] : null const result = await measureCtx.with(request.method, {}, (mctx) => { if (method === undefined) { const response = { id: request.id, error: new Status(Severity.ERROR, platform.status.UnknownMethod, { method: request.method }) } ctx.body = JSON.stringify(response) return } return method(mctx, db, branding, request, token) }) const body = JSON.stringify(result) ctx.res.writeHead(200, KEEP_ALIVE_HEADERS) ctx.res.end(body) }) app.use(router.routes()).use(router.allowedMethods()) const server = app.listen(ACCOUNT_PORT, () => { console.log(`server started on port ${ACCOUNT_PORT}`) }) const close = (): void => { onClose?.() void accountsDb.then(([, closeAccountsDb]) => { closeAccountsDb() }) server.close() } process.on('uncaughtException', (e) => { measureCtx.error('uncaughtException', { error: e }) }) process.on('unhandledRejection', (reason, promise) => { measureCtx.error('Unhandled Rejection at:', { reason, promise }) }) process.on('SIGINT', close) process.on('SIGTERM', close) process.on('exit', close) }