diff --git a/src/DB.ts b/src/DB.ts index 31e1846..150b6f2 100644 --- a/src/DB.ts +++ b/src/DB.ts @@ -1,9 +1,13 @@ import { Sequelize, DataTypes, Op, } from 'sequelize'; +import { Store } from 'express-session' +import { notStupidParseInt } from './Web.ts'; import type { ModelStatic, Filterable } from 'sequelize'; import type { LogType } from './BuildController.ts'; +import type { SessionData } from 'express-session' type Status = 'queued' | 'running' | 'cancelled' | 'success' | 'error'; type Dependencies = 'stable' | 'testing' | 'staging'; +type Callback = (err?: unknown, data?: any) => any interface DBConfig { db?: string; @@ -41,7 +45,7 @@ interface LogChunk { chunk: string } -const MONTH = 1000 * 60 * 60 * 24 * 24; +const MONTH = 1000 * 60 * 60 * 24 * 30; const FRESH = { [Op.or]: [ { startTime: { [Op.gt]: new Date(Date.now() - MONTH) } }, @@ -50,13 +54,27 @@ const FRESH = { } const SELECT = ['id', 'repo', 'commit', 'distro', 'dependencies', 'startTime', 'endTime', 'status']; -class DB { +function handleCallback(err: unknown, data: T, cb?: Callback): T { + if (cb) { + cb(err, data); + } + if (err) { + throw err; + } + return data; +} + +class DB extends Store { private build: ModelStatic; private logChunk: ModelStatic; private user: ModelStatic; + private session: ModelStatic; private sequelize: Sequelize; + private ttl: number; constructor(config: DBConfig = {}) { + super(); + this.ttl = notStupidParseInt(process.env['COOKIETTL']) || 1000 * 60 * 60 * 24 * 30; this.sequelize = new Sequelize(config.db || 'archery', config.user || 'archery', process.env.PASSWORD || config.password || '', { host: config.host || 'localhost', port: config.port || 5432, @@ -154,6 +172,16 @@ class DB { } }); + this.session = this.sequelize.define('session', { + sid: { + type: DataTypes.STRING, + primaryKey: true, + }, + sessionData: { + type: DataTypes.JSONB, + } + }); + this.build.belongsTo(this.user); this.user.hasMany(this.build); @@ -164,7 +192,8 @@ class DB { await this.user.sync(); await this.build.sync(); await this.logChunk.sync(); - + await this.session.sync(); + if (!(await this.getUser('-1'))) { await this.createUser({ id: '-1', @@ -329,8 +358,117 @@ class DB { await this.build.destroy({ where: { startTime: { [Op.lt]: new Date(Date.now() - MONTH * 6) } - } + }, + force: true }); + await this.session.destroy({ + where: { + updatedAt: { [Op.lt]: new Date(Date.now() - this.ttl) } + }, + force: true + }); + } + + public getTTL(sessionData: SessionData) { + if (sessionData?.cookie?.expires) { + const ms = Number(new Date(sessionData.cookie.expires)) - Date.now(); + return ms; + } + else { + return this.ttl; + } + } + + public async set(sid: string, sessionData: SessionData, cb?: Callback): Promise { + const ttl = this.getTTL(sessionData); + try { + if (ttl > 0) { + await this.session.upsert({ + sid, + sessionData + }); + handleCallback(null, null, cb); + return; + } + await this.destroy(sid, cb); + } + catch (err) { + return handleCallback(err, null, cb); + } + } + + public async get(sid: string, cb?: Callback): Promise { + try { + return handleCallback(null, ((await this.session.findByPk(sid))?.sessionData) as SessionData || null, cb); + } + catch (err) { + return handleCallback(err, null, cb); + } + } + + public async destroy(sid: string, cb?: Callback): Promise { + try { + await this.session.destroy({ + where: { + sid + }, + force: true + }); + handleCallback(null, null, cb); + } + catch (err) { + handleCallback(err, null, cb); + } + } + + public async clear(cb?: Callback): Promise { + try { + await this.session.destroy({ + truncate: true, + force: true + }); + handleCallback(null, null, cb); + } + catch (err) { + handleCallback(err, null, cb); + } + } + + public async length(cb?: Callback): Promise { + try { + return handleCallback(null, await this.session.count(), cb); + } + catch (err) { + handleCallback(err, null, cb); + } + } + + public async touch(sid: string, sessionData: SessionData, cb?: Callback): Promise { + try { + await this.session.update({}, + { + where: { + sid + } + } + ); + handleCallback(null, null, cb); + } + catch (err) { + handleCallback(err, null, cb); + } + } + + public async all(cb?: Callback): Promise { + try { + const all = await this.session.findAll({ + attributes: ['sessionData'] + }); + return handleCallback(null, all.map(row => row.sessionData as SessionData), cb); + } + catch (err) { + handleCallback(err, null, cb); + } } public async close(): Promise { diff --git a/src/Web.ts b/src/Web.ts index 506e677..20986fd 100644 --- a/src/Web.ts +++ b/src/Web.ts @@ -15,6 +15,7 @@ import type { BuildController, BuildEvent } from "./BuildController.ts"; interface WebConfig { sessionSecret?: string; port?: number; + secure?: boolean; oidc?: { server: string; clientId: string; @@ -58,12 +59,14 @@ class Web { private buildController: BuildController; private app: expressWs.Application; private port: number; + private options:WebConfig; constructor(options: WebConfig = {}) { - this.initialize(options) + this.options = options; } - initialize = async (options: WebConfig) => { + initialize = async () => { + const options = this.options; const sessionSecret = process.env['SESSIONSECRET'] || options.sessionSecret; const sqids = new Sqids({ minLength: 6, @@ -141,7 +144,7 @@ class Web { wsApp.ws(`/${slug}/:id/ws`, async (ws, req) => { const build = await getBuildFn(req.params.id); - if (! build || (build.status !== 'queued' && build.status !== 'running')) { + if (!build || (build.status !== 'queued' && build.status !== 'running')) { return ws.close(); } console.log('WS Opened'); @@ -159,15 +162,29 @@ class Web { }); } + app.get('/healthcheck', (_, res) => { + res.send('Healthy'); + }); + if (oidc) { if (!sessionSecret) { throw new Error('sessionSecret must be set.'); } app.use(session({ + name: 'sessionId', secret: sessionSecret, - resave: false, - saveUninitialized: false + resave: true, + saveUninitialized: false, + store: this.db, + cookie: { + maxAge: notStupidParseInt(process.env['COOKIETTL']) || 1000 * 60 * 60 * 24 * 30, // 30 days + httpOnly: true, + secure: !!options.secure + } })); + passport.use(oidc); + app.use(passport.initialize()); + app.use(passport.session()); passport.serializeUser(function (user: User, done) { done(null, user.id); }); @@ -177,12 +194,9 @@ class Web { done(null, { id: user.id, username: user.username, - name: user.displayName + displayName: user.displayName }); }); - passport.use(oidc); - app.use(passport.initialize()); - app.use(passport.session()); app.get('/login', (req, res) => { if (req?.user) { return res.redirect('/'); @@ -197,11 +211,7 @@ class Web { }); }); app.post('/login', passport.authenticate('openidconnect')); - app.get('/cb', passport.authenticate('openidconnect', { failureRedirect: '/login', failureMessage: true }), - function (_, res) { - res.redirect('/'); - } - ); + app.get('/cb', passport.authenticate('openidconnect', { successRedirect: '/', failureRedirect: '/login', failureMessage: true })); app.get('/logout', (req, res) => { req.logOut((err) => { if (err) { @@ -287,9 +297,7 @@ class Web { res.redirect(`/build/${req.params.id}/`); }); - app.get('/healthcheck', (_, res) => { - res.send('Healthy'); - }); + this._webserver = this.app.listen(this.port, () => console.log(`archery is running on port ${this.port}`)); } close = () => { @@ -300,9 +308,6 @@ class Web { setDB = (db: DB) => { this.db = db; - if (!this._webserver) { - this._webserver = this.app.listen(this.port, () => console.log(`archery is running on port ${this.port}`)); - } } initializeOIDC = async (options: WebConfig): Promise => { @@ -340,5 +345,5 @@ class Web { } export default Web; -export { Web }; +export { Web, notStupidParseInt }; export type { WebConfig }; diff --git a/src/index.ts b/src/index.ts index 86d3084..ade1a32 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ await new Promise((resolve) => setTimeout(resolve, 1500)); const db = new DB(config.db); web.setDB(db); web.setBuildController(buildController); +web.initialize(); buildController.setDB(db); process.on('SIGTERM', () => {