domma-cms 0.2.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/admin/css/admin.css +1 -1200
- package/admin/dist/domma/domma-tools.css +2313 -0
- package/admin/dist/domma/domma-tools.min.js +10 -0
- package/admin/index.html +4 -0
- package/admin/js/api.js +1 -242
- package/admin/js/app.js +9 -279
- package/admin/js/config/sidebar-config.js +1 -115
- package/admin/js/lib/card.js +1 -63
- package/admin/js/lib/image-editor.js +1 -869
- package/admin/js/lib/markdown-toolbar.js +54 -421
- package/admin/js/templates/action-editor.html +171 -0
- package/admin/js/templates/actions-list.html +19 -0
- package/admin/js/templates/api-reference.html +1411 -0
- package/admin/js/templates/block-editor.html +158 -0
- package/admin/js/templates/blocks.html +8 -0
- package/admin/js/templates/collection-editor.html +47 -0
- package/admin/js/templates/collection-entries.html +3 -0
- package/admin/js/templates/collections.html +51 -4
- package/admin/js/templates/documentation.html +258 -0
- package/admin/js/templates/form-editor.html +238 -0
- package/{plugins/form-builder/admin → admin/js}/templates/form-submissions.html +30 -30
- package/{plugins/form-builder/admin/templates/forms-list.html → admin/js/templates/forms.html} +17 -17
- package/admin/js/templates/layouts.html +44 -7
- package/admin/js/templates/login.html +29 -4
- package/admin/js/templates/my-profile.html +17 -0
- package/admin/js/templates/page-editor.html +48 -0
- package/admin/js/templates/pages.html +6 -1
- package/admin/js/templates/pro-docs.html +259 -0
- package/admin/js/templates/role-editor.html +59 -0
- package/admin/js/templates/roles.html +10 -0
- package/admin/js/templates/settings.html +137 -18
- package/admin/js/templates/tutorials.html +81 -0
- package/admin/js/templates/user-editor.html +7 -0
- package/admin/js/templates/users.html +3 -1
- package/admin/js/templates/view-editor.html +201 -0
- package/admin/js/templates/view-preview.html +51 -0
- package/admin/js/templates/views-list.html +19 -0
- package/admin/js/views/action-editor.js +1 -0
- package/admin/js/views/actions-list.js +1 -0
- package/admin/js/views/api-reference.js +1 -0
- package/admin/js/views/block-editor.js +8 -0
- package/admin/js/views/blocks.js +4 -0
- package/admin/js/views/collection-editor.js +3 -487
- package/admin/js/views/collection-entries.js +1 -484
- package/admin/js/views/collections.js +1 -153
- package/admin/js/views/dashboard.js +1 -56
- package/admin/js/views/documentation.js +1 -12
- package/admin/js/views/form-editor.js +8 -0
- package/admin/js/views/form-submissions.js +1 -0
- package/admin/js/views/forms.js +1 -0
- package/admin/js/views/index.js +1 -39
- package/admin/js/views/layouts.js +9 -42
- package/admin/js/views/login.js +7 -251
- package/admin/js/views/media.js +1 -240
- package/admin/js/views/my-profile.js +1 -0
- package/admin/js/views/navigation.js +14 -212
- package/admin/js/views/page-editor.js +72 -661
- package/admin/js/views/pages.js +5 -72
- package/admin/js/views/plugins.js +13 -90
- package/admin/js/views/pro-docs.js +1 -0
- package/admin/js/views/role-editor.js +1 -0
- package/admin/js/views/roles.js +4 -0
- package/admin/js/views/settings.js +3 -199
- package/admin/js/views/tutorials.js +1 -12
- package/admin/js/views/user-editor.js +1 -88
- package/admin/js/views/users.js +4 -76
- package/admin/js/views/view-editor.js +1 -0
- package/admin/js/views/view-preview.js +1 -0
- package/admin/js/views/views-list.js +1 -0
- package/bin/cli.js +1 -1
- package/config/auth.json +2 -17
- package/config/connections.json.bak +9 -0
- package/config/connections.json.example +9 -0
- package/config/navigation.json +15 -0
- package/config/plugins.json +19 -29
- package/config/server.json +6 -6
- package/config/site.json +17 -6
- package/package.json +24 -10
- package/plugins/domma-effects/public/celebrations/core/canvas.js +2 -104
- package/plugins/domma-effects/public/celebrations/core/particles.js +1 -144
- package/plugins/domma-effects/public/celebrations/core/physics.js +1 -166
- package/plugins/domma-effects/public/celebrations/index.js +1 -535
- package/plugins/domma-effects/public/celebrations/themes/christmas.js +1 -1805
- package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1 -1477
- package/plugins/domma-effects/public/celebrations/themes/halloween.js +1 -1837
- package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1 -1175
- package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1 -1258
- package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1 -1754
- package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1 -1290
- package/plugins/domma-effects/public/celebrations/themes/valentines.js +1 -1361
- package/plugins/example-analytics/stats.json +21 -12
- package/plugins/theme-roller/admin/templates/theme-roller.html +71 -0
- package/plugins/theme-roller/admin/views/theme-roller-view.js +403 -0
- package/plugins/theme-roller/config.js +1 -0
- package/plugins/theme-roller/plugin.js +233 -0
- package/plugins/theme-roller/plugin.json +31 -0
- package/plugins/theme-roller/public/active-theme.css +0 -0
- package/plugins/theme-roller/public/inject-head-late.html +1 -0
- package/public/css/forms.css +1 -0
- package/public/css/site.css +1 -302
- package/public/js/btt.js +1 -90
- package/public/js/cookie-consent.js +1 -61
- package/public/js/form-logic-engine.js +1 -0
- package/public/js/forms.js +1 -0
- package/public/js/site.js +1 -204
- package/scripts/build.js +194 -129
- package/scripts/pro.js +254 -0
- package/scripts/reset.js +33 -8
- package/scripts/seed.js +343 -78
- package/scripts/setup.js +5 -4
- package/server/middleware/auth.js +136 -97
- package/server/routes/api/actions.js +200 -0
- package/server/routes/api/auth.js +292 -116
- package/server/routes/api/blocks.js +84 -0
- package/server/routes/api/collections.js +88 -23
- package/{plugins/form-builder/plugin.js → server/routes/api/forms.js} +483 -505
- package/server/routes/api/layouts.js +49 -25
- package/server/routes/api/media.js +118 -93
- package/server/routes/api/navigation.js +40 -37
- package/server/routes/api/pages.js +132 -118
- package/server/routes/api/plugins.js +6 -3
- package/server/routes/api/settings.js +104 -89
- package/server/routes/api/users.js +27 -21
- package/server/routes/api/views.js +148 -0
- package/server/routes/public.js +124 -108
- package/server/server.js +269 -173
- package/server/services/actions.js +387 -0
- package/server/services/adapterRegistry.js +98 -0
- package/server/services/adapters/FileAdapter.js +192 -0
- package/server/services/adapters/MongoAdapter.js +220 -0
- package/server/services/blocks.js +162 -0
- package/server/services/collections.js +74 -86
- package/server/services/connectionManager.js +102 -0
- package/server/services/content.js +312 -307
- package/{plugins/form-builder → server/services}/email.js +126 -103
- package/server/services/forms.js +173 -0
- package/server/services/markdown.js +1378 -648
- package/server/services/permissionRegistry.js +173 -0
- package/server/services/presetCollections.js +251 -0
- package/server/services/renderer.js +75 -1
- package/server/services/roles.js +227 -0
- package/server/services/rowAccess.js +104 -0
- package/server/services/userProfiles.js +199 -0
- package/server/services/users.js +281 -212
- package/server/services/views.js +280 -0
- package/server/templates/page.html +119 -113
- package/plugins/form-builder/admin/templates/form-editor.html +0 -171
- package/plugins/form-builder/admin/templates/form-settings.html +0 -29
- package/plugins/form-builder/admin/views/form-editor.js +0 -1442
- package/plugins/form-builder/admin/views/form-settings.js +0 -38
- package/plugins/form-builder/admin/views/form-submissions.js +0 -295
- package/plugins/form-builder/admin/views/forms-list.js +0 -164
- package/plugins/form-builder/config.js +0 -9
- package/plugins/form-builder/data/forms/consent.json +0 -104
- package/plugins/form-builder/data/forms/contact-details.json +0 -63
- package/plugins/form-builder/data/forms/contacts.json +0 -66
- package/plugins/form-builder/data/submissions/consent.json +0 -13
- package/plugins/form-builder/data/submissions/contact-details.json +0 -1
- package/plugins/form-builder/data/submissions/contacts.json +0 -26
- package/plugins/form-builder/plugin.json +0 -52
- package/plugins/form-builder/public/form-logic-engine.js +0 -568
- package/plugins/form-builder/public/inject-body.html +0 -352
- package/plugins/form-builder/public/inject-head.html +0 -58
- package/plugins/form-builder/public/package.json +0 -1
- package/scripts/copy-domma.js +0 -48
|
@@ -1,116 +1,292 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auth API
|
|
3
|
-
* GET /api/auth/setup-status - check if any users exist
|
|
4
|
-
* POST /api/auth/setup - create initial admin (only when 0 users)
|
|
5
|
-
* POST /api/auth/login - { email, password } → { token, refreshToken, user }
|
|
6
|
-
* GET /api/auth/me - return current user from token
|
|
7
|
-
* POST /api/auth/refresh - { refreshToken } → { token }
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Auth API
|
|
3
|
+
* GET /api/auth/setup-status - check if any users exist
|
|
4
|
+
* POST /api/auth/setup - create initial admin (only when 0 users)
|
|
5
|
+
* POST /api/auth/login - { email, password } → { token, refreshToken, user }
|
|
6
|
+
* GET /api/auth/me - return current user from token
|
|
7
|
+
* POST /api/auth/refresh - { refreshToken } → { token }
|
|
8
|
+
* POST /api/auth/logout - { refreshToken } → { ok: true } — blacklists the refresh token
|
|
9
|
+
*/
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import {config, getConfig} from '../../config.js';
|
|
12
|
+
import {authenticate, getPermissionsForRole} from '../../middleware/auth.js';
|
|
13
|
+
import {GROUP_ORDER, REGISTRY} from '../../services/permissionRegistry.js';
|
|
14
|
+
import {
|
|
15
|
+
clearResetToken,
|
|
16
|
+
countUsers,
|
|
17
|
+
createUser,
|
|
18
|
+
getUserByEmail,
|
|
19
|
+
getUserById,
|
|
20
|
+
getUserByResetToken,
|
|
21
|
+
setResetToken,
|
|
22
|
+
touchLastLogin,
|
|
23
|
+
updateUser,
|
|
24
|
+
validatePassword
|
|
25
|
+
} from '../../services/users.js';
|
|
26
|
+
import {getProfile, updateProfile} from '../../services/userProfiles.js';
|
|
27
|
+
import {createTransport, sendEmail} from '../../services/email.js';
|
|
28
|
+
|
|
29
|
+
const { accessTokenExpiry, refreshTokenExpiry } = config.auth;
|
|
30
|
+
|
|
31
|
+
/** In-memory blacklist of invalidated refresh tokens (cleared on server restart). */
|
|
32
|
+
const blacklistedRefreshTokens = new Set();
|
|
33
|
+
|
|
34
|
+
// Purge expired tokens every 15 minutes to avoid unbounded growth.
|
|
35
|
+
setInterval(() => {
|
|
36
|
+
for (const token of blacklistedRefreshTokens) {
|
|
37
|
+
try {
|
|
38
|
+
fastifyRef.jwt.verify(token);
|
|
39
|
+
} catch {
|
|
40
|
+
blacklistedRefreshTokens.delete(token);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}, 15 * 60 * 1000);
|
|
44
|
+
|
|
45
|
+
/** Holds the fastify instance once routes are registered (needed by the cleanup interval). */
|
|
46
|
+
let fastifyRef;
|
|
47
|
+
|
|
48
|
+
export async function authRoutes(fastify) {
|
|
49
|
+
fastifyRef = fastify;
|
|
50
|
+
// GET /api/auth/setup-status
|
|
51
|
+
fastify.get('/auth/setup-status', async () => {
|
|
52
|
+
const count = await countUsers();
|
|
53
|
+
return { needsSetup: count === 0 };
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// POST /api/auth/setup — create the very first admin (only allowed when no users exist)
|
|
57
|
+
fastify.post('/auth/setup', async (request, reply) => {
|
|
58
|
+
const count = await countUsers();
|
|
59
|
+
if (count > 0) {
|
|
60
|
+
return reply.status(403).send({ error: 'Setup already complete' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { name, email, password } = request.body || {};
|
|
64
|
+
if (!name || !email || !password) {
|
|
65
|
+
return reply.status(400).send({ error: 'name, email and password are required' });
|
|
66
|
+
}
|
|
67
|
+
if (password.length < 8) {
|
|
68
|
+
return reply.status(400).send({ error: 'Password must be at least 8 characters' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const user = await createUser({ name, email, password, role: 'admin' });
|
|
72
|
+
const { token, refreshToken } = signTokens(fastify, user);
|
|
73
|
+
return reply.status(201).send({ token, refreshToken, user });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// POST /api/auth/login
|
|
77
|
+
fastify.post('/auth/login', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (request, reply) => {
|
|
78
|
+
const { email, password } = request.body || {};
|
|
79
|
+
if (!email || !password) {
|
|
80
|
+
return reply.status(400).send({ error: 'email and password are required' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const user = await getUserByEmail(email);
|
|
84
|
+
if (!user || !user.isActive) {
|
|
85
|
+
return reply.status(401).send({ error: 'Invalid credentials' });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const valid = await validatePassword(password, user.password);
|
|
89
|
+
if (!valid) {
|
|
90
|
+
return reply.status(401).send({ error: 'Invalid credentials' });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await touchLastLogin(user.id);
|
|
94
|
+
|
|
95
|
+
const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role };
|
|
96
|
+
const { token, refreshToken } = signTokens(fastify, safeUser);
|
|
97
|
+
return { token, refreshToken, user: safeUser };
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// GET /api/auth/permissions — returns the raw permissions array for the authenticated user's role
|
|
101
|
+
fastify.get('/auth/permissions', {preHandler: [authenticate]}, async (request) => {
|
|
102
|
+
const permissions = getPermissionsForRole(request.user.role);
|
|
103
|
+
return {permissions};
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// GET /api/auth/permissions-registry — returns the full permissions registry
|
|
107
|
+
fastify.get('/auth/permissions-registry', {preHandler: [authenticate]}, async () => {
|
|
108
|
+
return {groups: GROUP_ORDER, resources: REGISTRY};
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// GET /api/auth/me
|
|
112
|
+
fastify.get('/auth/me', { preHandler: [authenticate] }, async (request, reply) => {
|
|
113
|
+
const user = await getUserById(request.user.id);
|
|
114
|
+
if (!user) return reply.status(404).send({ error: 'User not found' });
|
|
115
|
+
const profileEntry = await getProfile(request.user.id);
|
|
116
|
+
return {...user, profile: profileEntry?.data || {}};
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// PUT /api/auth/me — self-service update (name, email, password, profile)
|
|
120
|
+
fastify.put('/auth/me', {preHandler: [authenticate]}, async (request, reply) => {
|
|
121
|
+
const id = request.user.id;
|
|
122
|
+
const {name, email, password, profile} = request.body || {};
|
|
123
|
+
|
|
124
|
+
if (password && password.length < 8) {
|
|
125
|
+
return reply.status(400).send({error: 'Password must be at least 8 characters'});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const coreUpdates = {};
|
|
129
|
+
if (name) coreUpdates.name = name;
|
|
130
|
+
if (email) coreUpdates.email = email;
|
|
131
|
+
if (password) coreUpdates.password = password;
|
|
132
|
+
|
|
133
|
+
const user = await updateUser(id, coreUpdates);
|
|
134
|
+
|
|
135
|
+
if (profile && typeof profile === 'object') {
|
|
136
|
+
await updateProfile(id, profile);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const profileEntry = await getProfile(id);
|
|
140
|
+
return {...user, profile: profileEntry?.data || {}};
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// POST /api/auth/forgot-password — send a password reset email (no auth)
|
|
144
|
+
fastify.post('/auth/forgot-password', { config: { rateLimit: { max: 3, timeWindow: '1 hour' } } }, async (request) => {
|
|
145
|
+
const {email} = request.body || {};
|
|
146
|
+
if (!email) return {ok: true}; // always ok — prevents enumeration
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const user = await getUserByEmail(email);
|
|
150
|
+
if (!user || !user.isActive) return {ok: true};
|
|
151
|
+
|
|
152
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
153
|
+
const hash = crypto.createHash('sha256').update(token).digest('hex');
|
|
154
|
+
|
|
155
|
+
const authCfg = getConfig('auth');
|
|
156
|
+
const expiryMs = parseDuration(authCfg.resetTokenExpiry || '1h');
|
|
157
|
+
const expiry = new Date(Date.now() + expiryMs).toISOString();
|
|
158
|
+
|
|
159
|
+
await setResetToken(user.id, hash, expiry);
|
|
160
|
+
|
|
161
|
+
const siteConfig = getConfig('site');
|
|
162
|
+
const smtp = siteConfig.smtp || {};
|
|
163
|
+
const sitePort = config.server.port || 4096;
|
|
164
|
+
const resetUrl = `${request.protocol}://${request.hostname}:${sitePort}/admin/#/reset-password?token=${token}`;
|
|
165
|
+
|
|
166
|
+
const transport = await createTransport(smtp);
|
|
167
|
+
const from = smtp.fromAddress || 'noreply@example.com';
|
|
168
|
+
const fromName = smtp.fromName || siteConfig.title || 'Domma CMS';
|
|
169
|
+
|
|
170
|
+
await sendEmail(transport, {
|
|
171
|
+
from,
|
|
172
|
+
fromName,
|
|
173
|
+
to: user.email,
|
|
174
|
+
subject: 'Reset your password',
|
|
175
|
+
html: `
|
|
176
|
+
<!DOCTYPE html>
|
|
177
|
+
<html>
|
|
178
|
+
<body style="font-family:sans-serif;max-width:560px;margin:0 auto;padding:24px;">
|
|
179
|
+
<h2 style="color:#333;">Password Reset</h2>
|
|
180
|
+
<p>Hi ${user.name},</p>
|
|
181
|
+
<p>We received a request to reset your password. Click the link below — it expires in 1 hour.</p>
|
|
182
|
+
<p style="margin:24px 0;">
|
|
183
|
+
<a href="${resetUrl}" style="background:#5b8cff;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;display:inline-block;">Reset Password</a>
|
|
184
|
+
</p>
|
|
185
|
+
<p style="color:#888;font-size:.85rem;">If you didn't request this, you can safely ignore this email.</p>
|
|
186
|
+
<p style="color:#bbb;font-size:.8rem;">${resetUrl}</p>
|
|
187
|
+
</body>
|
|
188
|
+
</html>`.trim(),
|
|
189
|
+
text: `Hi ${user.name},\n\nReset your password here:\n${resetUrl}\n\nThis link expires in 1 hour.\n\nIf you didn't request this, ignore this email.`
|
|
190
|
+
});
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.error('[auth] forgot-password error:', err.message);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {ok: true};
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// POST /api/auth/reset-password — consume token and set new password (no auth)
|
|
199
|
+
fastify.post('/auth/reset-password', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (request, reply) => {
|
|
200
|
+
const {token, password} = request.body || {};
|
|
201
|
+
if (!token || !password) {
|
|
202
|
+
return reply.status(400).send({error: 'token and password are required'});
|
|
203
|
+
}
|
|
204
|
+
if (password.length < 8) {
|
|
205
|
+
return reply.status(400).send({error: 'Password must be at least 8 characters'});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const hash = crypto.createHash('sha256').update(token).digest('hex');
|
|
209
|
+
const user = await getUserByResetToken(hash);
|
|
210
|
+
if (!user) {
|
|
211
|
+
return reply.status(400).send({error: 'Invalid or expired reset link'});
|
|
212
|
+
}
|
|
213
|
+
if (new Date(user.resetTokenExpiry) < new Date()) {
|
|
214
|
+
await clearResetToken(user.id);
|
|
215
|
+
return reply.status(400).send({error: 'Invalid or expired reset link'});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await updateUser(user.id, {password});
|
|
219
|
+
await clearResetToken(user.id);
|
|
220
|
+
return {ok: true};
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// POST /api/auth/logout — blacklists the refresh token (fire-and-forget safe: no auth required)
|
|
224
|
+
fastify.post('/auth/logout', async (request) => {
|
|
225
|
+
const {refreshToken} = request.body || {};
|
|
226
|
+
if (refreshToken) blacklistedRefreshTokens.add(refreshToken);
|
|
227
|
+
return {ok: true};
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// POST /api/auth/refresh
|
|
231
|
+
fastify.post('/auth/refresh', async (request, reply) => {
|
|
232
|
+
const { refreshToken } = request.body || {};
|
|
233
|
+
if (!refreshToken) {
|
|
234
|
+
return reply.status(400).send({ error: 'refreshToken is required' });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (blacklistedRefreshTokens.has(refreshToken)) {
|
|
238
|
+
return reply.status(401).send({error: 'Refresh token has been revoked'});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let payload;
|
|
242
|
+
try {
|
|
243
|
+
payload = fastify.jwt.verify(refreshToken);
|
|
244
|
+
} catch {
|
|
245
|
+
return reply.status(401).send({ error: 'Invalid or expired refresh token' });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (payload.type !== 'refresh') {
|
|
249
|
+
return reply.status(401).send({ error: 'Invalid token type' });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const user = await getUserById(payload.id);
|
|
253
|
+
if (!user || !user.isActive) {
|
|
254
|
+
return reply.status(401).send({ error: 'User not found or inactive' });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role };
|
|
258
|
+
const token = fastify.jwt.sign({ ...safeUser, type: 'access' }, { expiresIn: accessTokenExpiry });
|
|
259
|
+
return { token };
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Parse a duration string into milliseconds.
|
|
265
|
+
* Supports "Nm" (minutes), "Nh" (hours), "Nd" (days).
|
|
266
|
+
*
|
|
267
|
+
* @param {string} str - e.g. "1h", "30m", "1d"
|
|
268
|
+
* @returns {number} Milliseconds
|
|
269
|
+
*/
|
|
270
|
+
function parseDuration(str) {
|
|
271
|
+
const match = String(str).match(/^(\d+)([mhd])$/);
|
|
272
|
+
if (!match) return 3_600_000; // default 1h
|
|
273
|
+
const n = parseInt(match[1], 10);
|
|
274
|
+
const unit = match[2];
|
|
275
|
+
if (unit === 'm') return n * 60_000;
|
|
276
|
+
if (unit === 'h') return n * 3_600_000;
|
|
277
|
+
if (unit === 'd') return n * 86_400_000;
|
|
278
|
+
return 3_600_000;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Sign both access and refresh tokens for a user payload.
|
|
283
|
+
*
|
|
284
|
+
* @param {object} fastify
|
|
285
|
+
* @param {object} payload
|
|
286
|
+
* @returns {{ token: string, refreshToken: string }}
|
|
287
|
+
*/
|
|
288
|
+
function signTokens(fastify, payload) {
|
|
289
|
+
const token = fastify.jwt.sign({ ...payload, type: 'access' }, { expiresIn: accessTokenExpiry });
|
|
290
|
+
const refreshToken = fastify.jwt.sign({ id: payload.id, type: 'refresh' }, { expiresIn: refreshTokenExpiry });
|
|
291
|
+
return { token, refreshToken };
|
|
292
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blocks API
|
|
3
|
+
* CRUD for reusable HTML block templates stored in content/blocks/*.html.
|
|
4
|
+
* GET /api/blocks - list all blocks
|
|
5
|
+
* GET /api/blocks/:name - get block content
|
|
6
|
+
* PUT /api/blocks/:name - create or update block
|
|
7
|
+
* DELETE /api/blocks/:name - delete block
|
|
8
|
+
*/
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import fs from 'fs/promises';
|
|
11
|
+
import {fileURLToPath} from 'url';
|
|
12
|
+
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
13
|
+
|
|
14
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const ROOT = path.resolve(__dirname, '../../..');
|
|
16
|
+
const BLOCKS_DIR = path.join(ROOT, 'content', 'blocks');
|
|
17
|
+
|
|
18
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
19
|
+
|
|
20
|
+
function blockPath(name) {
|
|
21
|
+
return path.join(BLOCKS_DIR, `${name}.html`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function blocksRoutes(fastify) {
|
|
25
|
+
const canRead = {preHandler: [authenticate, requirePermission('pages', 'read')]};
|
|
26
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update')]};
|
|
27
|
+
const canDelete = {preHandler: [authenticate, requirePermission('pages', 'delete')]};
|
|
28
|
+
|
|
29
|
+
// List all blocks
|
|
30
|
+
fastify.get('/blocks', canRead, async () => {
|
|
31
|
+
await fs.mkdir(BLOCKS_DIR, {recursive: true});
|
|
32
|
+
const files = await fs.readdir(BLOCKS_DIR);
|
|
33
|
+
const blocks = [];
|
|
34
|
+
for (const file of files.filter(f => f.endsWith('.html'))) {
|
|
35
|
+
const name = file.slice(0, -5);
|
|
36
|
+
const stat = await fs.stat(path.join(BLOCKS_DIR, file)).catch(() => null);
|
|
37
|
+
blocks.push({
|
|
38
|
+
name,
|
|
39
|
+
size: stat?.size ?? 0,
|
|
40
|
+
updatedAt: stat?.mtime?.toISOString() ?? null
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return blocks.sort((a, b) => a.name.localeCompare(b.name));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Get single block
|
|
47
|
+
fastify.get('/blocks/:name', canRead, async (request, reply) => {
|
|
48
|
+
const {name} = request.params;
|
|
49
|
+
if (!NAME_RE.test(name)) return reply.status(400).send({error: 'Invalid block name'});
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const content = await fs.readFile(blockPath(name), 'utf8');
|
|
53
|
+
return {name, content};
|
|
54
|
+
} catch {
|
|
55
|
+
return reply.status(404).send({error: 'Block not found'});
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Create or update block
|
|
60
|
+
fastify.put('/blocks/:name', canUpdate, async (request, reply) => {
|
|
61
|
+
const {name} = request.params;
|
|
62
|
+
if (!NAME_RE.test(name)) return reply.status(400).send({error: 'Invalid block name. Use lowercase letters, digits, and hyphens only.'});
|
|
63
|
+
|
|
64
|
+
const {content} = request.body || {};
|
|
65
|
+
if (typeof content !== 'string') return reply.status(400).send({error: 'content (string) is required'});
|
|
66
|
+
|
|
67
|
+
await fs.mkdir(BLOCKS_DIR, {recursive: true});
|
|
68
|
+
await fs.writeFile(blockPath(name), content, 'utf8');
|
|
69
|
+
return {success: true, name};
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Delete block
|
|
73
|
+
fastify.delete('/blocks/:name', canDelete, async (request, reply) => {
|
|
74
|
+
const {name} = request.params;
|
|
75
|
+
if (!NAME_RE.test(name)) return reply.status(400).send({error: 'Invalid block name'});
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await fs.unlink(blockPath(name));
|
|
79
|
+
return reply.status(204).send();
|
|
80
|
+
} catch {
|
|
81
|
+
return reply.status(404).send({error: 'Block not found'});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|