Skip to content

Commit 1086bb2

Browse files
committed
feat: Refactor to Fastify with auto-swagger and MCP support
- Replace Express with Fastify 5.x - Add @fastify/swagger for automatic OpenAPI generation from JSON schemas - Add @fastify/swagger-ui at /api/docs - Implement MCP server plugin with container management tools - Port all routers with JSON schema validation: - login/logout, register, reset-password (auth flows) - apikeys, settings (simple CRUD) - users, groups (user management) - sites, nodes, containers (infrastructure) - jobs (async task monitoring with SSE) - external-domains (DNS management) - Create Fastify plugins: - auth.js: requireAuth, requireAdmin, requireLocalhostOrAdmin - flash.js: flash message support for EJS views - load-sites.js: site context for authenticated users - mcp.js: Model Context Protocol server - Preserve dual HTML/JSON content negotiation - Keep existing Sequelize models unchanged - Maintain EJS views with @fastify/view
1 parent 3718f3f commit 1086bb2

19 files changed

Lines changed: 5596 additions & 2373 deletions

create-a-container/package-lock.json

Lines changed: 1270 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

create-a-container/package.json

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,27 @@
55
]
66
},
77
"scripts": {
8-
"dev": "nodemon server.js",
8+
"start": "node server-fastify.js",
9+
"start:express": "node server.js",
10+
"dev": "nodemon server-fastify.js",
11+
"dev:express": "nodemon server.js",
912
"db:migrate": "sequelize db:migrate && sequelize db:seed:all",
1013
"job-runner": "node job-runner.js",
1114
"test": "npx playwright test",
1215
"test:ui": "npx playwright test --ui"
1316
},
1417
"dependencies": {
18+
"@fastify/cookie": "^11.0.2",
19+
"@fastify/cors": "^11.2.0",
20+
"@fastify/formbody": "^8.0.2",
21+
"@fastify/rate-limit": "^10.3.0",
22+
"@fastify/sensible": "^6.0.4",
23+
"@fastify/session": "^11.1.1",
24+
"@fastify/static": "^9.0.0",
25+
"@fastify/swagger": "^9.7.0",
26+
"@fastify/swagger-ui": "^5.2.5",
27+
"@fastify/view": "^11.1.1",
28+
"@modelcontextprotocol/sdk": "^1.27.1",
1529
"argon2": "^0.44.0",
1630
"axios": "^1.13.5",
1731
"connect-flash": "^0.1.1",
@@ -21,6 +35,8 @@
2135
"express-rate-limit": "^8.1.1",
2236
"express-session": "^1.18.2",
2337
"express-session-sequelize": "^2.3.0",
38+
"fastify": "^5.8.2",
39+
"fastify-plugin": "^5.1.0",
2440
"method-override": "^3.0.0",
2541
"morgan": "^1.10.1",
2642
"nodemailer": "^7.0.11",
@@ -34,6 +50,7 @@
3450
},
3551
"devDependencies": {
3652
"@playwright/test": "^1.58.2",
37-
"nodemon": "^3.1.10"
53+
"nodemon": "^3.1.10",
54+
"pino-pretty": "^13.1.3"
3855
}
3956
}

create-a-container/plugins/auth.js

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
const fp = require('fastify-plugin');
2+
3+
/**
4+
* Authentication plugin for Fastify
5+
* Provides session and API key authentication with admin checks
6+
*/
7+
async function authPlugin(fastify, options) {
8+
// Helper to check if request wants JSON response
9+
function isApiRequest(request) {
10+
const acceptHeader = request.headers.accept || '';
11+
const acceptsJSON = acceptHeader.includes('application/json') || acceptHeader.includes('application/vnd.api+json');
12+
const isAjax = request.headers['x-requested-with'] === 'XMLHttpRequest';
13+
const isApiPath = request.url?.startsWith('/api/');
14+
return acceptsJSON || isAjax || isApiPath;
15+
}
16+
17+
// Decorate request with helper
18+
fastify.decorateRequest('isApiRequest', function() {
19+
return isApiRequest(this);
20+
});
21+
22+
// Decorate request with user-related properties
23+
fastify.decorateRequest('user', null);
24+
fastify.decorateRequest('apiKey', null);
25+
fastify.decorateRequest('isAdmin', false);
26+
27+
/**
28+
* Authentication hook - validates session or API key
29+
*/
30+
async function requireAuth(request, reply) {
31+
// First check session authentication
32+
if (request.session?.user) {
33+
request.isAdmin = request.session.isAdmin || false;
34+
return;
35+
}
36+
37+
// Try API key authentication
38+
const authHeader = request.headers.authorization;
39+
if (authHeader && authHeader.startsWith('Bearer ')) {
40+
const apiKey = authHeader.substring(7);
41+
42+
if (apiKey) {
43+
const { ApiKey, User } = require('../models');
44+
const { extractKeyPrefix } = require('../utils/apikey');
45+
46+
const keyPrefix = extractKeyPrefix(apiKey);
47+
48+
const apiKeys = await ApiKey.findAll({
49+
where: { keyPrefix },
50+
include: [{
51+
model: User,
52+
as: 'user',
53+
include: [{ association: 'groups' }]
54+
}]
55+
});
56+
57+
for (const storedKey of apiKeys) {
58+
const isValid = await storedKey.validateKey(apiKey);
59+
if (isValid) {
60+
request.user = storedKey.user;
61+
request.apiKey = storedKey;
62+
request.isAdmin = storedKey.user.groups?.some(g => g.isAdmin) || false;
63+
64+
// Populate session for compatibility
65+
request.session.user = storedKey.user.uid;
66+
request.session.isAdmin = request.isAdmin;
67+
68+
// Record usage asynchronously
69+
storedKey.recordUsage().catch(err => {
70+
fastify.log.error('Failed to update API key last used timestamp:', err);
71+
});
72+
73+
return;
74+
}
75+
}
76+
}
77+
}
78+
79+
// Neither session nor API key authentication succeeded
80+
if (isApiRequest(request)) {
81+
return reply.code(401).send({ error: 'Unauthorized' });
82+
}
83+
84+
// Browser request - redirect to login
85+
const original = request.url || '/';
86+
const redirectTo = '/login?redirect=' + encodeURIComponent(original);
87+
return reply.redirect(redirectTo);
88+
}
89+
90+
/**
91+
* Admin check hook - must be used after requireAuth
92+
*/
93+
async function requireAdmin(request, reply) {
94+
if (request.session?.isAdmin || request.isAdmin) {
95+
return;
96+
}
97+
98+
if (isApiRequest(request)) {
99+
return reply.code(403).send({ error: 'Forbidden: Admin access required' });
100+
}
101+
102+
return reply.code(403).send('Forbidden: Admin access required');
103+
}
104+
105+
/**
106+
* Localhost or admin check hook
107+
* Allows localhost requests without auth; remote requests need admin
108+
*/
109+
async function requireLocalhostOrAdmin(request, reply) {
110+
const isLocalhost = (ip) => {
111+
return ip === '127.0.0.1' ||
112+
ip === '::1' ||
113+
ip === '::ffff:127.0.0.1' ||
114+
ip === 'localhost';
115+
};
116+
117+
const directIp = request.ip;
118+
const realIp = request.headers['x-real-ip'];
119+
120+
// If direct connection is from localhost and no non-localhost X-Real-IP, allow through
121+
if (isLocalhost(directIp) && (!realIp || isLocalhost(realIp))) {
122+
return;
123+
}
124+
125+
// Not localhost — require auth + admin
126+
await requireAuth(request, reply);
127+
if (reply.sent) return;
128+
await requireAdmin(request, reply);
129+
}
130+
131+
// Decorate fastify with auth hooks so routes can use them
132+
fastify.decorate('requireAuth', requireAuth);
133+
fastify.decorate('requireAdmin', requireAdmin);
134+
fastify.decorate('requireLocalhostOrAdmin', requireLocalhostOrAdmin);
135+
136+
// Also expose isApiRequest as a utility
137+
fastify.decorate('isApiRequest', isApiRequest);
138+
}
139+
140+
module.exports = fp(authPlugin, {
141+
name: 'auth',
142+
dependencies: ['@fastify/session']
143+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
const fp = require('fastify-plugin');
2+
3+
/**
4+
* Flash messages plugin for Fastify
5+
* Provides Express-compatible flash() functionality using sessions
6+
*/
7+
async function flashPlugin(fastify, options) {
8+
// Decorate request with flash method
9+
fastify.decorateRequest('flash', function(type, message) {
10+
if (!this.session) {
11+
throw new Error('Flash requires session support');
12+
}
13+
14+
// Initialize flash storage
15+
if (!this.session.flash) {
16+
this.session.flash = {};
17+
}
18+
19+
// If no arguments, return and clear all flash messages
20+
if (arguments.length === 0) {
21+
const messages = this.session.flash || {};
22+
this.session.flash = {};
23+
return messages;
24+
}
25+
26+
// If only type provided, return messages for that type
27+
if (arguments.length === 1) {
28+
const messages = this.session.flash[type] || [];
29+
delete this.session.flash[type];
30+
return messages;
31+
}
32+
33+
// Add message to flash
34+
if (!this.session.flash[type]) {
35+
this.session.flash[type] = [];
36+
}
37+
this.session.flash[type].push(message);
38+
39+
return this.session.flash[type].length;
40+
});
41+
42+
// Pre-handler to expose flash messages to views
43+
fastify.addHook('preHandler', async (request, reply) => {
44+
// Make flash messages available to templates
45+
reply.locals = reply.locals || {};
46+
47+
// Get flash messages without clearing them yet
48+
const flashMessages = request.session?.flash || {};
49+
50+
reply.locals.successMessages = flashMessages.success || [];
51+
reply.locals.errorMessages = flashMessages.error || [];
52+
reply.locals.warningMessages = flashMessages.warning || [];
53+
reply.locals.infoMessages = flashMessages.info || [];
54+
55+
// Clear flash after reading
56+
if (request.session) {
57+
request.session.flash = {};
58+
}
59+
});
60+
}
61+
62+
module.exports = fp(flashPlugin, {
63+
name: 'flash',
64+
dependencies: ['@fastify/session']
65+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
const fp = require('fastify-plugin');
2+
3+
/**
4+
* Load sites plugin for Fastify
5+
* Loads all sites for authenticated users and attaches to reply.locals
6+
*/
7+
async function loadSitesPlugin(fastify, options) {
8+
const { Site } = require('../models');
9+
10+
fastify.addHook('preHandler', async (request, reply) => {
11+
// Only load sites for authenticated users
12+
if (!request.session?.user) {
13+
return;
14+
}
15+
16+
reply.locals = reply.locals || {};
17+
18+
try {
19+
const sites = await Site.findAll({
20+
attributes: ['id', 'name'],
21+
order: [['name', 'ASC']]
22+
});
23+
reply.locals.sites = sites;
24+
reply.locals.currentSite = request.session.currentSite || null;
25+
} catch (error) {
26+
fastify.log.error('Error loading sites:', error);
27+
reply.locals.sites = [];
28+
reply.locals.currentSite = null;
29+
}
30+
});
31+
32+
/**
33+
* Helper to set current site from route param
34+
*/
35+
function setCurrentSite(request, reply) {
36+
if (request.params.siteId) {
37+
request.session.currentSite = parseInt(request.params.siteId, 10);
38+
reply.locals = reply.locals || {};
39+
reply.locals.currentSite = request.session.currentSite;
40+
}
41+
}
42+
43+
fastify.decorate('setCurrentSite', setCurrentSite);
44+
}
45+
46+
module.exports = fp(loadSitesPlugin, {
47+
name: 'load-sites',
48+
dependencies: ['@fastify/session']
49+
});

0 commit comments

Comments
 (0)