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.
Files changed (166) hide show
  1. package/README.md +3 -3
  2. package/admin/css/admin.css +1 -1200
  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 -242
  7. package/admin/js/app.js +9 -279
  8. package/admin/js/config/sidebar-config.js +1 -115
  9. package/admin/js/lib/card.js +1 -63
  10. package/admin/js/lib/image-editor.js +1 -869
  11. package/admin/js/lib/markdown-toolbar.js +54 -421
  12. package/admin/js/templates/action-editor.html +171 -0
  13. package/admin/js/templates/actions-list.html +19 -0
  14. package/admin/js/templates/api-reference.html +1411 -0
  15. package/admin/js/templates/block-editor.html +158 -0
  16. package/admin/js/templates/blocks.html +8 -0
  17. package/admin/js/templates/collection-editor.html +47 -0
  18. package/admin/js/templates/collection-entries.html +3 -0
  19. package/admin/js/templates/collections.html +51 -4
  20. package/admin/js/templates/documentation.html +258 -0
  21. package/admin/js/templates/form-editor.html +238 -0
  22. package/{plugins/form-builder/admin → admin/js}/templates/form-submissions.html +30 -30
  23. package/{plugins/form-builder/admin/templates/forms-list.html → admin/js/templates/forms.html} +17 -17
  24. package/admin/js/templates/layouts.html +44 -7
  25. package/admin/js/templates/login.html +29 -4
  26. package/admin/js/templates/my-profile.html +17 -0
  27. package/admin/js/templates/page-editor.html +48 -0
  28. package/admin/js/templates/pages.html +6 -1
  29. package/admin/js/templates/pro-docs.html +259 -0
  30. package/admin/js/templates/role-editor.html +59 -0
  31. package/admin/js/templates/roles.html +10 -0
  32. package/admin/js/templates/settings.html +137 -18
  33. package/admin/js/templates/tutorials.html +81 -0
  34. package/admin/js/templates/user-editor.html +7 -0
  35. package/admin/js/templates/users.html +3 -1
  36. package/admin/js/templates/view-editor.html +201 -0
  37. package/admin/js/templates/view-preview.html +51 -0
  38. package/admin/js/templates/views-list.html +19 -0
  39. package/admin/js/views/action-editor.js +1 -0
  40. package/admin/js/views/actions-list.js +1 -0
  41. package/admin/js/views/api-reference.js +1 -0
  42. package/admin/js/views/block-editor.js +8 -0
  43. package/admin/js/views/blocks.js +4 -0
  44. package/admin/js/views/collection-editor.js +3 -487
  45. package/admin/js/views/collection-entries.js +1 -484
  46. package/admin/js/views/collections.js +1 -153
  47. package/admin/js/views/dashboard.js +1 -56
  48. package/admin/js/views/documentation.js +1 -12
  49. package/admin/js/views/form-editor.js +8 -0
  50. package/admin/js/views/form-submissions.js +1 -0
  51. package/admin/js/views/forms.js +1 -0
  52. package/admin/js/views/index.js +1 -39
  53. package/admin/js/views/layouts.js +9 -42
  54. package/admin/js/views/login.js +7 -251
  55. package/admin/js/views/media.js +1 -240
  56. package/admin/js/views/my-profile.js +1 -0
  57. package/admin/js/views/navigation.js +14 -212
  58. package/admin/js/views/page-editor.js +72 -661
  59. package/admin/js/views/pages.js +5 -72
  60. package/admin/js/views/plugins.js +13 -90
  61. package/admin/js/views/pro-docs.js +1 -0
  62. package/admin/js/views/role-editor.js +1 -0
  63. package/admin/js/views/roles.js +4 -0
  64. package/admin/js/views/settings.js +3 -199
  65. package/admin/js/views/tutorials.js +1 -12
  66. package/admin/js/views/user-editor.js +1 -88
  67. package/admin/js/views/users.js +4 -76
  68. package/admin/js/views/view-editor.js +1 -0
  69. package/admin/js/views/view-preview.js +1 -0
  70. package/admin/js/views/views-list.js +1 -0
  71. package/bin/cli.js +1 -1
  72. package/config/auth.json +2 -17
  73. package/config/connections.json.bak +9 -0
  74. package/config/connections.json.example +9 -0
  75. package/config/navigation.json +15 -0
  76. package/config/plugins.json +19 -29
  77. package/config/server.json +6 -6
  78. package/config/site.json +17 -6
  79. package/package.json +24 -10
  80. package/plugins/domma-effects/public/celebrations/core/canvas.js +2 -104
  81. package/plugins/domma-effects/public/celebrations/core/particles.js +1 -144
  82. package/plugins/domma-effects/public/celebrations/core/physics.js +1 -166
  83. package/plugins/domma-effects/public/celebrations/index.js +1 -535
  84. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1 -1805
  85. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1 -1477
  86. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1 -1837
  87. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1 -1175
  88. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1 -1258
  89. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1 -1754
  90. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1 -1290
  91. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1 -1361
  92. package/plugins/example-analytics/stats.json +21 -12
  93. package/plugins/theme-roller/admin/templates/theme-roller.html +71 -0
  94. package/plugins/theme-roller/admin/views/theme-roller-view.js +403 -0
  95. package/plugins/theme-roller/config.js +1 -0
  96. package/plugins/theme-roller/plugin.js +233 -0
  97. package/plugins/theme-roller/plugin.json +31 -0
  98. package/plugins/theme-roller/public/active-theme.css +0 -0
  99. package/plugins/theme-roller/public/inject-head-late.html +1 -0
  100. package/public/css/forms.css +1 -0
  101. package/public/css/site.css +1 -302
  102. package/public/js/btt.js +1 -90
  103. package/public/js/cookie-consent.js +1 -61
  104. package/public/js/form-logic-engine.js +1 -0
  105. package/public/js/forms.js +1 -0
  106. package/public/js/site.js +1 -204
  107. package/scripts/build.js +194 -129
  108. package/scripts/pro.js +254 -0
  109. package/scripts/reset.js +33 -8
  110. package/scripts/seed.js +343 -78
  111. package/scripts/setup.js +5 -4
  112. package/server/middleware/auth.js +136 -97
  113. package/server/routes/api/actions.js +200 -0
  114. package/server/routes/api/auth.js +292 -116
  115. package/server/routes/api/blocks.js +84 -0
  116. package/server/routes/api/collections.js +88 -23
  117. package/{plugins/form-builder/plugin.js → server/routes/api/forms.js} +483 -505
  118. package/server/routes/api/layouts.js +49 -25
  119. package/server/routes/api/media.js +118 -93
  120. package/server/routes/api/navigation.js +40 -37
  121. package/server/routes/api/pages.js +132 -118
  122. package/server/routes/api/plugins.js +6 -3
  123. package/server/routes/api/settings.js +104 -89
  124. package/server/routes/api/users.js +27 -21
  125. package/server/routes/api/views.js +148 -0
  126. package/server/routes/public.js +124 -108
  127. package/server/server.js +269 -173
  128. package/server/services/actions.js +387 -0
  129. package/server/services/adapterRegistry.js +98 -0
  130. package/server/services/adapters/FileAdapter.js +192 -0
  131. package/server/services/adapters/MongoAdapter.js +220 -0
  132. package/server/services/blocks.js +162 -0
  133. package/server/services/collections.js +74 -86
  134. package/server/services/connectionManager.js +102 -0
  135. package/server/services/content.js +312 -307
  136. package/{plugins/form-builder → server/services}/email.js +126 -103
  137. package/server/services/forms.js +173 -0
  138. package/server/services/markdown.js +1378 -648
  139. package/server/services/permissionRegistry.js +173 -0
  140. package/server/services/presetCollections.js +251 -0
  141. package/server/services/renderer.js +75 -1
  142. package/server/services/roles.js +227 -0
  143. package/server/services/rowAccess.js +104 -0
  144. package/server/services/userProfiles.js +199 -0
  145. package/server/services/users.js +281 -212
  146. package/server/services/views.js +280 -0
  147. package/server/templates/page.html +119 -113
  148. package/plugins/form-builder/admin/templates/form-editor.html +0 -171
  149. package/plugins/form-builder/admin/templates/form-settings.html +0 -29
  150. package/plugins/form-builder/admin/views/form-editor.js +0 -1442
  151. package/plugins/form-builder/admin/views/form-settings.js +0 -38
  152. package/plugins/form-builder/admin/views/form-submissions.js +0 -295
  153. package/plugins/form-builder/admin/views/forms-list.js +0 -164
  154. package/plugins/form-builder/config.js +0 -9
  155. package/plugins/form-builder/data/forms/consent.json +0 -104
  156. package/plugins/form-builder/data/forms/contact-details.json +0 -63
  157. package/plugins/form-builder/data/forms/contacts.json +0 -66
  158. package/plugins/form-builder/data/submissions/consent.json +0 -13
  159. package/plugins/form-builder/data/submissions/contact-details.json +0 -1
  160. package/plugins/form-builder/data/submissions/contacts.json +0 -26
  161. package/plugins/form-builder/plugin.json +0 -52
  162. package/plugins/form-builder/public/form-logic-engine.js +0 -568
  163. package/plugins/form-builder/public/inject-body.html +0 -352
  164. package/plugins/form-builder/public/inject-head.html +0 -58
  165. package/plugins/form-builder/public/package.json +0 -1
  166. 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
- import { config } from '../../config.js';
10
- import { authenticate } from '../../middleware/auth.js';
11
- import {
12
- countUsers,
13
- createUser,
14
- getUserByEmail,
15
- getUserById,
16
- touchLastLogin,
17
- validatePassword
18
- } from '../../services/users.js';
19
-
20
- const { accessTokenExpiry, refreshTokenExpiry } = config.auth;
21
-
22
- export async function authRoutes(fastify) {
23
- // GET /api/auth/setup-status
24
- fastify.get('/auth/setup-status', async () => {
25
- const count = await countUsers();
26
- return { needsSetup: count === 0 };
27
- });
28
-
29
- // POST /api/auth/setup create the very first admin (only allowed when no users exist)
30
- fastify.post('/auth/setup', async (request, reply) => {
31
- const count = await countUsers();
32
- if (count > 0) {
33
- return reply.status(403).send({ error: 'Setup already complete' });
34
- }
35
-
36
- const { name, email, password } = request.body || {};
37
- if (!name || !email || !password) {
38
- return reply.status(400).send({ error: 'name, email and password are required' });
39
- }
40
- if (password.length < 8) {
41
- return reply.status(400).send({ error: 'Password must be at least 8 characters' });
42
- }
43
-
44
- const user = await createUser({ name, email, password, role: 'admin' });
45
- const { token, refreshToken } = signTokens(fastify, user);
46
- return reply.status(201).send({ token, refreshToken, user });
47
- });
48
-
49
- // POST /api/auth/login
50
- fastify.post('/auth/login', async (request, reply) => {
51
- const { email, password } = request.body || {};
52
- if (!email || !password) {
53
- return reply.status(400).send({ error: 'email and password are required' });
54
- }
55
-
56
- const user = await getUserByEmail(email);
57
- if (!user || !user.isActive) {
58
- return reply.status(401).send({ error: 'Invalid credentials' });
59
- }
60
-
61
- const valid = await validatePassword(password, user.password);
62
- if (!valid) {
63
- return reply.status(401).send({ error: 'Invalid credentials' });
64
- }
65
-
66
- await touchLastLogin(user.id);
67
-
68
- const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role };
69
- const { token, refreshToken } = signTokens(fastify, safeUser);
70
- return { token, refreshToken, user: safeUser };
71
- });
72
-
73
- // GET /api/auth/me
74
- fastify.get('/auth/me', { preHandler: [authenticate] }, async (request, reply) => {
75
- const user = await getUserById(request.user.id);
76
- if (!user) return reply.status(404).send({ error: 'User not found' });
77
- return user;
78
- });
79
-
80
- // POST /api/auth/refresh
81
- fastify.post('/auth/refresh', async (request, reply) => {
82
- const { refreshToken } = request.body || {};
83
- if (!refreshToken) {
84
- return reply.status(400).send({ error: 'refreshToken is required' });
85
- }
86
-
87
- let payload;
88
- try {
89
- payload = fastify.jwt.verify(refreshToken);
90
- } catch {
91
- return reply.status(401).send({ error: 'Invalid or expired refresh token' });
92
- }
93
-
94
- const user = await getUserById(payload.id);
95
- if (!user || !user.isActive) {
96
- return reply.status(401).send({ error: 'User not found or inactive' });
97
- }
98
-
99
- const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role };
100
- const token = fastify.jwt.sign(safeUser, { expiresIn: accessTokenExpiry });
101
- return { token };
102
- });
103
- }
104
-
105
- /**
106
- * Sign both access and refresh tokens for a user payload.
107
- *
108
- * @param {object} fastify
109
- * @param {object} payload
110
- * @returns {{ token: string, refreshToken: string }}
111
- */
112
- function signTokens(fastify, payload) {
113
- const token = fastify.jwt.sign(payload, { expiresIn: accessTokenExpiry });
114
- const refreshToken = fastify.jwt.sign(payload, { expiresIn: refreshTokenExpiry });
115
- return { token, refreshToken };
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
+ }