Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ PACKAGER ?= deb
MAKE_VARS = $(if $(PREFIX),PREFIX=$(PREFIX),) \
$(if $(DESTDIR),DESTDIR=$(DESTDIR),)

.PHONY: help deps build install deb rpm apk clean dev
.PHONY: help deps build test install deb rpm apk clean dev

help:
@echo "opensource-server — delegates to each component's Makefile."
@echo ""
@echo "Targets (run across all components):"
@echo " deps install build/runtime dependencies"
@echo " build build all components"
@echo " test run every component's test suite"
@echo " install stage component files into DESTDIR (default /)"
@echo " deb build .deb packages, collected into ./dist"
@echo " rpm build .rpm packages, collected into ./dist"
Expand All @@ -26,7 +27,7 @@ help:
@echo "Variables: PREFIX (default /opt/opensource-server), DESTDIR (default /)."
@echo "The package version is derived from git by ./package-version."

deps build install:
deps build test install:
@for c in $(COMPONENTS); do \
echo "==> $$c: $@"; \
$(MAKE) -C $$c $@ $(MAKE_VARS) || exit $$?; \
Expand Down
16 changes: 13 additions & 3 deletions create-a-container/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ INSTALL_DATA := $(INSTALL) -m 0644

# Server runtime files and directories. Client sources, dev tooling and repo
# metadata are intentionally excluded; only the built client/dist ships.
APP_FILES := server.js job-runner.js package.json package-lock.json openapi.v1.yaml
APP_FILES := server.js app.js job-runner.js package.json package-lock.json openapi.v1.yaml
APP_DIRS := bin config data middlewares migrations models node_modules \
public routers seeders utils views
public resources routers seeders utils views

.PHONY: help deps build dev install deb rpm apk package clean
.PHONY: help deps build dev test install deb rpm apk package clean

help:
@echo "create-a-container — builds the opensource-server package."
Expand All @@ -27,6 +27,7 @@ help:
@echo " build build the web client bundle"
@echo " install stage the app into DESTDIR (default /)"
@echo " dev run the Manager locally (SQLite, no Proxmox)"
@echo " test run the server test suite (jest)"
@echo " deb build the .deb package"
@echo " rpm build the .rpm package"
@echo " apk build the .apk package"
Expand Down Expand Up @@ -80,10 +81,19 @@ dev:
"node job-runner.js" \
"npm --prefix client run build:watch"

# Run the server test suite (jest + supertest against a throwaway SQLite DB in
# $TMPDIR; see jest.config.js / tests/setup-env.js). Like `dev`, tests need
# devDependencies, so install with `npm install --no-package-lock` — fast,
# incremental, and never rewrites package-lock.json.
test:
npm install --no-package-lock
npm test

install: build
$(INSTALL) -d $(DESTBIN)
$(INSTALL_DATA) $(APP_FILES) $(DESTBIN)/
cp -a $(APP_DIRS) $(DESTBIN)/
find $(DESTBIN) -type d -name __tests__ -prune -exec rm -rf {} +
$(INSTALL) -d $(DESTBIN)/client
cp -a client/dist $(DESTBIN)/client/
$(INSTALL) -d $(UNIT_DIR)
Expand Down
151 changes: 151 additions & 0 deletions create-a-container/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* Express app construction, separated from process startup (server.js) so
* tests can exercise the real app via supertest without binding a port or
* bootstrapping DB-backed session secrets.
*
* Everything HTTP-visible belongs here; server.js only fetches secrets and
* calls listen().
*/

require('dotenv').config();

const express = require('express');
const session = require('express-session');
const morgan = require('morgan');
const fs = require('fs');
const SequelizeStore = require('express-session-sequelize')(session.Store);
const path = require('path');
const RateLimit = require('express-rate-limit');
const swaggerUi = require('swagger-ui-express');
const YAML = require('yamljs');
const { sequelize } = require('./models');

/**
* @param {object} options
* @param {string|string[]} options.sessionSecrets - express-session secret(s); required.
* @param {boolean} [options.rateLimit=true] - disable in tests: assertions on 4xx
* responses must not burn the budget.
* @param {boolean} [options.accessLog=true] - morgan; disable in tests for quiet output.
*/
function buildApp({ sessionSecrets, rateLimit = true, accessLog = true } = {}) {
if (!sessionSecrets || sessionSecrets.length === 0) {
throw new Error('buildApp: sessionSecrets is required');
}

const app = express();

// setup views (still used by templates router for nginx-conf / dnsmasq files)
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.set('trust proxy', 1);
// Parse query strings with qs so bracket notation (e.g. `user[0]=alice`)
// yields real arrays. Express 5 defaults to the 'simple' parser.
app.set('query parser', 'extended');

// setup middleware
if (accessLog) {
const accessLogStream = process.env.ACCESS_LOG
? fs.createWriteStream(process.env.ACCESS_LOG, { flags: 'a' })
: process.stdout;
app.use(morgan('combined', { stream: accessLogStream }));
}
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Configure session store
const sessionStore = new SequelizeStore({
db: sequelize,
});
// The store's expired-session sweeper (setInterval, no stop API) must not
// keep the process alive on its own: in production the listening socket
// does that, and test runners need the event loop to drain to exit.
if (sessionStore._expirationInterval?.unref) {
sessionStore._expirationInterval.unref();
}

app.use(session({
secret: sessionSecrets,
store: sessionStore,
resave: false,
saveUninitialized: false,
cookie: {
// 'auto' derives `secure` from the request protocol (honoring `trust
// proxy` and X-Forwarded-Proto from nginx) rather than NODE_ENV, so
// the flag tracks the actual transport — set on HTTPS, omitted on
// plain HTTP bootstrap/dev access. Same semantics as the previous
// per-request function form, but statically analyzable (CodeQL
// js/clear-text-cookie).
secure: 'auto',
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: 'lax',
}
}));
Comment thread
runleveldev marked this conversation as resolved.
Dismissed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

app.use(express.static('public'));

// We rate limit unsuccessful (4xx/5xx statuses, excluding 404) to only 10 per 5 minutes, this
// should allow legitimate users a few tries to login or experiment without
// allowing bad-actors to abuse requests. 404s are excluded because browsers
// (especially Safari) automatically request favicon/apple-touch-icon paths that
// don't exist, and those harmless misses should not burn the rate-limit budget.
if (rateLimit) {
app.use(RateLimit({
windowMs: 5 * 60 * 1000,
max: 10,
skipSuccessfulRequests: true,
requestWasSuccessful: (req, res) => res.statusCode < 400 || res.statusCode === 404,
}));
}

// CSRF guard for every handler that can see the session cookie (CodeQL
// js/missing-token-validation). Behavior-preserving: csrfGuard skips
// GET/HEAD/OPTIONS and Bearer-only requests, and every state-changing
// route lives under /api/v1 where the v1 router already applies it — this
// additionally covers the GET-only surfaces (templates, swagger, SPA,
// static) and rejects mutations to unknown paths early. Mounted after the
// rate limiter so CSRF 403s still count against the failure budget; the
// v1 router keeps its own csrfGuard so it stays safe mounted standalone.
const { csrfGuard, jsonErrorHandler } = require('./middlewares/api');
app.use(csrfGuard);

// Set version info once at startup in app.locals
// Note: Version info is cached at startup. Server restart required to update version.
const { getVersionInfo } = require('./utils');
app.locals.versionInfo = getVersionInfo();

// --- Mount Routers ---
const apiV1Router = require('./routers/api/v1');
const templatesRouter = require('./routers/templates');

app.use('/api/v1', apiV1Router);
app.use('/', templatesRouter); // serves /sites/:siteId/nginx and /sites/:siteId/dnsmasq/:file

// --- API Documentation (Swagger UI) ---
// Swagger UI at /api documents the versioned v1 API (the spec also served at /api/v1/openapi.*).
const openapiSpec = YAML.load(path.join(__dirname, 'openapi.v1.yaml'));
app.get('/api/openapi.json', (req, res) => res.json(openapiSpec));
app.get('/api/openapi.yaml', (req, res) => {
res.type('text/yaml').sendFile(path.join(__dirname, 'openapi.v1.yaml'));
});
app.use('/api', swaggerUi.serve, swaggerUi.setup(openapiSpec, {
customSiteTitle: 'Create-a-Container API',
}));

// --- SPA: serve compiled React app for everything else ---
const clientDist = path.join(__dirname, 'client', 'dist');
app.use(express.static(clientDist));
app.get(/^\/(?!api(\/|$)|sites\/[^/]+\/(nginx$|dnsmasq\/)).*$/, (req, res) => {
res.sendFile(path.join(clientDist, 'index.html'));
});

// Final error handler. Failures inside /api/v1 are already formatted by the
// v1 router's own jsonErrorHandler; this catches errors raised before the
// router is reached (notably the app-level csrfGuard) and errors from the
// non-API surfaces, keeping the JSON error envelope instead of Express's
// default HTML page with a stack trace.
app.use(jsonErrorHandler);

return app;
}

module.exports = { buildApp };
Loading
Loading