domma-cms 0.3.0 → 0.5.2

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.
Files changed (150) hide show
  1. package/README.md +3 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/dist/domma/domma-tools.css +2313 -0
  4. package/admin/dist/domma/domma-tools.min.js +10 -0
  5. package/admin/index.html +4 -0
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +8 -4
  8. package/admin/js/config/sidebar-config.js +1 -1
  9. package/admin/js/lib/markdown-toolbar.js +18 -10
  10. package/admin/js/templates/action-editor.html +171 -0
  11. package/admin/js/templates/actions-list.html +19 -0
  12. package/admin/js/templates/api-reference.html +1411 -0
  13. package/admin/js/templates/block-editor.html +158 -0
  14. package/admin/js/templates/blocks.html +8 -0
  15. package/admin/js/templates/collection-editor.html +47 -0
  16. package/admin/js/templates/collection-entries.html +3 -0
  17. package/admin/js/templates/collections.html +51 -4
  18. package/admin/js/templates/documentation.html +258 -0
  19. package/{plugins/form-builder/admin → admin/js}/templates/form-editor.html +238 -199
  20. package/{plugins/form-builder/admin → admin/js}/templates/form-submissions.html +30 -30
  21. package/{plugins/form-builder/admin/templates/forms-list.html → admin/js/templates/forms.html} +17 -17
  22. package/admin/js/templates/login.html +29 -4
  23. package/admin/js/templates/my-profile.html +17 -0
  24. package/admin/js/templates/page-editor.html +39 -0
  25. package/admin/js/templates/pages.html +6 -1
  26. package/admin/js/templates/pro-docs.html +259 -0
  27. package/admin/js/templates/role-editor.html +59 -0
  28. package/admin/js/templates/roles.html +10 -0
  29. package/admin/js/templates/settings.html +167 -23
  30. package/admin/js/templates/tutorials.html +81 -0
  31. package/admin/js/templates/user-editor.html +7 -0
  32. package/admin/js/templates/users.html +3 -26
  33. package/admin/js/templates/view-editor.html +201 -0
  34. package/admin/js/templates/view-preview.html +51 -0
  35. package/admin/js/templates/views-list.html +19 -0
  36. package/admin/js/views/action-editor.js +1 -0
  37. package/admin/js/views/actions-list.js +1 -0
  38. package/admin/js/views/api-reference.js +1 -0
  39. package/admin/js/views/block-editor.js +8 -0
  40. package/admin/js/views/blocks.js +4 -0
  41. package/admin/js/views/collection-editor.js +3 -3
  42. package/admin/js/views/collection-entries.js +1 -1
  43. package/admin/js/views/collections.js +1 -1
  44. package/admin/js/views/dashboard.js +1 -1
  45. package/admin/js/views/form-editor.js +8 -0
  46. package/admin/js/views/form-submissions.js +1 -0
  47. package/admin/js/views/forms.js +1 -0
  48. package/admin/js/views/index.js +1 -1
  49. package/admin/js/views/login.js +2 -2
  50. package/admin/js/views/media.js +1 -1
  51. package/admin/js/views/my-profile.js +1 -0
  52. package/admin/js/views/page-editor.js +34 -15
  53. package/admin/js/views/pages.js +5 -5
  54. package/admin/js/views/plugins.js +10 -10
  55. package/admin/js/views/pro-docs.js +1 -0
  56. package/admin/js/views/role-editor.js +1 -0
  57. package/admin/js/views/roles.js +4 -0
  58. package/admin/js/views/settings.js +3 -1
  59. package/admin/js/views/user-editor.js +1 -1
  60. package/admin/js/views/users.js +4 -7
  61. package/admin/js/views/view-editor.js +1 -0
  62. package/admin/js/views/view-preview.js +1 -0
  63. package/admin/js/views/views-list.js +1 -0
  64. package/bin/cli.js +1 -1
  65. package/config/auth.json +1 -0
  66. package/config/connections.json.bak +9 -0
  67. package/config/connections.json.example +9 -0
  68. package/config/navigation.json +5 -15
  69. package/config/plugins.json +19 -29
  70. package/config/server.json +6 -6
  71. package/config/site.json +16 -6
  72. package/package.json +25 -10
  73. package/plugins/example-analytics/stats.json +17 -12
  74. package/plugins/form-builder/data/forms/contacts.json +62 -62
  75. package/plugins/form-builder/data/forms/enquiries.json +103 -0
  76. package/plugins/form-builder/data/forms/feedback.json +17 -16
  77. package/plugins/form-builder/data/forms/notes.json +79 -0
  78. package/plugins/form-builder/data/forms/to-do.json +100 -0
  79. package/plugins/form-builder/data/submissions/contacts.json +1 -26
  80. package/plugins/form-builder/data/submissions/notes.json +1 -0
  81. package/plugins/form-builder/data/submissions/to-do.json +1 -0
  82. package/plugins/theme-roller/admin/templates/theme-roller.html +71 -0
  83. package/plugins/theme-roller/admin/views/theme-roller-view.js +403 -0
  84. package/plugins/theme-roller/config.js +1 -0
  85. package/plugins/theme-roller/plugin.js +233 -0
  86. package/plugins/theme-roller/plugin.json +31 -0
  87. package/plugins/theme-roller/public/active-theme.css +0 -0
  88. package/plugins/theme-roller/public/inject-head-late.html +1 -0
  89. package/public/css/forms.css +1 -0
  90. package/public/css/site.css +1 -1
  91. package/public/js/forms.js +1 -0
  92. package/public/js/site.js +1 -1
  93. package/scripts/build.js +194 -129
  94. package/scripts/pro.js +254 -0
  95. package/scripts/reset.js +33 -8
  96. package/scripts/seed.js +677 -128
  97. package/scripts/setup.js +1 -0
  98. package/server/middleware/auth.js +136 -120
  99. package/server/routes/api/actions.js +200 -0
  100. package/server/routes/api/auth.js +292 -146
  101. package/server/routes/api/blocks.js +84 -0
  102. package/server/routes/api/collections.js +79 -27
  103. package/{plugins/form-builder/plugin.js → server/routes/api/forms.js} +491 -505
  104. package/server/routes/api/layouts.js +49 -39
  105. package/server/routes/api/media.js +118 -92
  106. package/server/routes/api/navigation.js +40 -36
  107. package/server/routes/api/pages.js +132 -118
  108. package/server/routes/api/plugins.js +6 -3
  109. package/server/routes/api/settings.js +104 -88
  110. package/server/routes/api/users.js +27 -19
  111. package/server/routes/api/views.js +148 -0
  112. package/server/routes/public.js +124 -108
  113. package/server/server.js +269 -181
  114. package/server/services/actions.js +387 -0
  115. package/server/services/adapterRegistry.js +98 -0
  116. package/server/services/adapters/FileAdapter.js +192 -0
  117. package/server/services/adapters/MongoAdapter.js +220 -0
  118. package/server/services/blocks.js +162 -0
  119. package/server/services/collections.js +74 -86
  120. package/server/services/connectionManager.js +102 -0
  121. package/server/services/content.js +312 -307
  122. package/server/services/email.js +126 -0
  123. package/server/services/forms.js +173 -0
  124. package/server/services/markdown.js +1378 -747
  125. package/server/services/permissionRegistry.js +173 -0
  126. package/server/services/presetCollections.js +251 -0
  127. package/server/services/renderer.js +98 -2
  128. package/server/services/roles.js +227 -0
  129. package/server/services/rowAccess.js +104 -0
  130. package/server/services/userProfiles.js +199 -0
  131. package/server/services/users.js +281 -212
  132. package/server/services/views.js +280 -0
  133. package/server/templates/page.html +124 -113
  134. package/plugins/form-builder/admin/templates/form-settings.html +0 -29
  135. package/plugins/form-builder/admin/views/form-editor.js +0 -1444
  136. package/plugins/form-builder/admin/views/form-settings.js +0 -38
  137. package/plugins/form-builder/admin/views/form-submissions.js +0 -295
  138. package/plugins/form-builder/admin/views/forms-list.js +0 -164
  139. package/plugins/form-builder/config.js +0 -9
  140. package/plugins/form-builder/data/forms/consent.json +0 -104
  141. package/plugins/form-builder/data/forms/contact-details.json +0 -99
  142. package/plugins/form-builder/data/submissions/consent.json +0 -13
  143. package/plugins/form-builder/plugin.json +0 -52
  144. package/plugins/form-builder/public/inject-body.html +0 -352
  145. package/plugins/form-builder/public/inject-head.html +0 -58
  146. package/plugins/form-builder/public/package.json +0 -1
  147. package/scripts/copy-domma.js +0 -48
  148. package/server/services/userTypes.js +0 -167
  149. /package/plugins/form-builder/data/submissions/{contact-details.json → enquiries.json} +0 -0
  150. /package/{plugins/form-builder/public → public/js}/form-logic-engine.js +0 -0
@@ -1,146 +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
- * POST /api/auth/logout - { refreshToken } → { ok: true } — blacklists the refresh token
9
- */
10
- import {config} from '../../config.js';
11
- import {authenticate} from '../../middleware/auth.js';
12
- import {
13
- countUsers,
14
- createUser,
15
- getUserByEmail,
16
- getUserById,
17
- touchLastLogin,
18
- validatePassword
19
- } from '../../services/users.js';
20
-
21
- const { accessTokenExpiry, refreshTokenExpiry } = config.auth;
22
-
23
- /** In-memory blacklist of invalidated refresh tokens (cleared on server restart). */
24
- const blacklistedRefreshTokens = new Set();
25
-
26
- // Purge expired tokens every 15 minutes to avoid unbounded growth.
27
- setInterval(() => {
28
- for (const token of blacklistedRefreshTokens) {
29
- try {
30
- fastifyRef.jwt.verify(token);
31
- } catch {
32
- blacklistedRefreshTokens.delete(token);
33
- }
34
- }
35
- }, 15 * 60 * 1000);
36
-
37
- /** Holds the fastify instance once routes are registered (needed by the cleanup interval). */
38
- let fastifyRef;
39
-
40
- export async function authRoutes(fastify) {
41
- fastifyRef = fastify;
42
- // GET /api/auth/setup-status
43
- fastify.get('/auth/setup-status', async () => {
44
- const count = await countUsers();
45
- return { needsSetup: count === 0 };
46
- });
47
-
48
- // POST /api/auth/setup — create the very first admin (only allowed when no users exist)
49
- fastify.post('/auth/setup', async (request, reply) => {
50
- const count = await countUsers();
51
- if (count > 0) {
52
- return reply.status(403).send({ error: 'Setup already complete' });
53
- }
54
-
55
- const { name, email, password } = request.body || {};
56
- if (!name || !email || !password) {
57
- return reply.status(400).send({ error: 'name, email and password are required' });
58
- }
59
- if (password.length < 8) {
60
- return reply.status(400).send({ error: 'Password must be at least 8 characters' });
61
- }
62
-
63
- const user = await createUser({ name, email, password, role: 'admin' });
64
- const { token, refreshToken } = signTokens(fastify, user);
65
- return reply.status(201).send({ token, refreshToken, user });
66
- });
67
-
68
- // POST /api/auth/login
69
- fastify.post('/auth/login', async (request, reply) => {
70
- const { email, password } = request.body || {};
71
- if (!email || !password) {
72
- return reply.status(400).send({ error: 'email and password are required' });
73
- }
74
-
75
- const user = await getUserByEmail(email);
76
- if (!user || !user.isActive) {
77
- return reply.status(401).send({ error: 'Invalid credentials' });
78
- }
79
-
80
- const valid = await validatePassword(password, user.password);
81
- if (!valid) {
82
- return reply.status(401).send({ error: 'Invalid credentials' });
83
- }
84
-
85
- await touchLastLogin(user.id);
86
-
87
- const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role };
88
- const { token, refreshToken } = signTokens(fastify, safeUser);
89
- return { token, refreshToken, user: safeUser };
90
- });
91
-
92
- // GET /api/auth/me
93
- fastify.get('/auth/me', { preHandler: [authenticate] }, async (request, reply) => {
94
- const user = await getUserById(request.user.id);
95
- if (!user) return reply.status(404).send({ error: 'User not found' });
96
- return user;
97
- });
98
-
99
- // POST /api/auth/logout — blacklists the refresh token (fire-and-forget safe: no auth required)
100
- fastify.post('/auth/logout', async (request) => {
101
- const {refreshToken} = request.body || {};
102
- if (refreshToken) blacklistedRefreshTokens.add(refreshToken);
103
- return {ok: true};
104
- });
105
-
106
- // POST /api/auth/refresh
107
- fastify.post('/auth/refresh', async (request, reply) => {
108
- const { refreshToken } = request.body || {};
109
- if (!refreshToken) {
110
- return reply.status(400).send({ error: 'refreshToken is required' });
111
- }
112
-
113
- if (blacklistedRefreshTokens.has(refreshToken)) {
114
- return reply.status(401).send({error: 'Refresh token has been revoked'});
115
- }
116
-
117
- let payload;
118
- try {
119
- payload = fastify.jwt.verify(refreshToken);
120
- } catch {
121
- return reply.status(401).send({ error: 'Invalid or expired refresh token' });
122
- }
123
-
124
- const user = await getUserById(payload.id);
125
- if (!user || !user.isActive) {
126
- return reply.status(401).send({ error: 'User not found or inactive' });
127
- }
128
-
129
- const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role };
130
- const token = fastify.jwt.sign(safeUser, { expiresIn: accessTokenExpiry });
131
- return { token };
132
- });
133
- }
134
-
135
- /**
136
- * Sign both access and refresh tokens for a user payload.
137
- *
138
- * @param {object} fastify
139
- * @param {object} payload
140
- * @returns {{ token: string, refreshToken: string }}
141
- */
142
- function signTokens(fastify, payload) {
143
- const token = fastify.jwt.sign(payload, { expiresIn: accessTokenExpiry });
144
- const refreshToken = fastify.jwt.sign(payload, { expiresIn: refreshTokenExpiry });
145
- return { token, refreshToken };
146
- }
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
+ }