mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-13 19:00:09 +00:00
Some checks failed
CI / build (push) Has been cancelled
CI / uitest (push) Has been cancelled
CI / uitest-pg (push) Has been cancelled
CI / uitest-qms (push) Has been cancelled
CI / uitest-workspaces (push) Has been cancelled
CI / test (push) Has been cancelled
CI / svelte-check (push) Has been cancelled
CI / formatting (push) Has been cancelled
CI / docker-build (push) Has been cancelled
CI / dist-build (push) Has been cancelled
385 lines
11 KiB
TypeScript
385 lines
11 KiB
TypeScript
//
|
|
// 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<string, any> = {
|
|
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)
|
|
}
|