domma-cms 1.8.32 → 1.8.34
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/CHANGELOG.md +9 -0
- package/docs/DommaFeatures.md +13 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/games/games.css +1 -1
- package/plugins/_lib/admin/games/kit.js +329 -124
- package/plugins/_lib/games/http.js +11 -1
- package/plugins/_lib/games/plays.js +28 -7
- package/plugins/crossword/CLAUDE.md +37 -0
- package/plugins/crossword/admin/css/index.css +18 -1
- package/plugins/crossword/admin/views/crossword.js +337 -205
- package/plugins/crossword/docs/guide.md +53 -0
- package/plugins/crossword/inject/body.html +1 -1
- package/plugins/crossword/plugin.js +38 -5
- package/plugins/crossword/plugin.json +4 -3
- package/plugins/crossword/public/crossword.css +36 -1
- package/plugins/crossword/public/crossword.js +468 -6
- package/plugins/docs/CLAUDE.md +318 -0
- package/plugins/docs/admin/css/docs.css +783 -0
- package/plugins/docs/admin/lib/api.js +340 -0
- package/plugins/docs/admin/lib/convert.js +482 -0
- package/plugins/docs/admin/lib/drafts.js +115 -0
- package/plugins/docs/admin/lib/editor.js +851 -0
- package/plugins/docs/admin/lib/find-replace.js +426 -0
- package/plugins/docs/admin/lib/folders.js +203 -0
- package/plugins/docs/admin/lib/panel-guard.js +73 -0
- package/plugins/docs/admin/lib/pdf-view.js +126 -0
- package/plugins/docs/admin/lib/send.js +156 -0
- package/plugins/docs/admin/lib/settings.js +142 -0
- package/plugins/docs/admin/lib/share.js +323 -0
- package/plugins/docs/admin/lib/shortcuts.js +44 -0
- package/plugins/docs/admin/lib/templates.js +194 -0
- package/plugins/docs/admin/lib/versions.js +192 -0
- package/plugins/docs/admin/templates/docs.html +92 -0
- package/plugins/docs/admin/templates/editor.html +86 -0
- package/plugins/docs/admin/views/docs.js +777 -0
- package/plugins/docs/admin/views/editor.js +978 -0
- package/plugins/docs/config.js +24 -0
- package/plugins/docs/docs/guide.md +270 -0
- package/plugins/docs/lib/extensions.js +213 -0
- package/plugins/docs/lib/importer.js +72 -0
- package/plugins/docs/lib/pdf-worker.js +48 -0
- package/plugins/docs/lib/pdf.js +222 -0
- package/plugins/docs/lib/store.js +748 -0
- package/plugins/docs/plugin.js +1108 -0
- package/plugins/docs/plugin.json +89 -0
- package/plugins/docs/plugin.public.js +114 -0
- package/plugins/docs/templates/public-doc.html +36 -0
- package/plugins/docs/tests/_env.js +10 -0
- package/plugins/docs/tests/_pdf.js +73 -0
- package/plugins/docs/tests/convert.test.js +95 -0
- package/plugins/docs/tests/drafts.test.js +82 -0
- package/plugins/docs/tests/pdf.test.js +86 -0
- package/plugins/docs/tests/plugin.test.js +816 -0
- package/plugins/docs/tests/shortcuts.test.js +30 -0
- package/plugins/free-tier.lock.json +147 -1
- package/plugins/garage/CLAUDE.md +51 -0
- package/plugins/garage/admin/css/garage.css +73 -0
- package/plugins/garage/admin/lib/garage.js +236 -0
- package/plugins/garage/admin/templates/garage.html +86 -0
- package/plugins/garage/admin/views/garage.js +581 -0
- package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
- package/plugins/garage/config.js +18 -0
- package/plugins/garage/docs/guide.md +118 -0
- package/plugins/garage/plugin.js +471 -0
- package/plugins/garage/plugin.json +68 -0
- package/plugins/garage/tests/access.test.js +134 -0
- package/plugins/garage/tests/garage.test.js +106 -0
- package/plugins/messages/CLAUDE.md +230 -0
- package/plugins/messages/admin/css/messages.css +117 -0
- package/plugins/messages/admin/lib/messages.js +452 -0
- package/plugins/messages/admin/templates/messages.html +76 -0
- package/plugins/messages/admin/views/messages.js +1219 -0
- package/plugins/messages/collections/user-message-threads/schema.json +22 -0
- package/plugins/messages/collections/user-messages/schema.json +23 -0
- package/plugins/messages/config.js +14 -0
- package/plugins/messages/docs/guide.md +176 -0
- package/plugins/messages/lib/call.js +138 -0
- package/plugins/messages/plugin.js +994 -0
- package/plugins/messages/plugin.json +59 -0
- package/plugins/messages/plugin.public.js +52 -0
- package/plugins/messages/templates/call.html +6 -0
- package/plugins/messages/tests/after-send.test.js +22 -0
- package/plugins/messages/tests/api.test.js +187 -0
- package/plugins/messages/tests/call.test.js +497 -0
- package/plugins/messages/tests/drafts.test.js +25 -0
- package/plugins/messages/tests/messages.test.js +167 -0
- package/plugins/quiz/CLAUDE.md +43 -0
- package/plugins/quiz/admin/css/index.css +13 -1
- package/plugins/quiz/admin/views/quiz.js +423 -245
- package/plugins/quiz/docs/guide.md +61 -0
- package/plugins/quiz/inject/body.html +1 -1
- package/plugins/quiz/plugin.js +39 -5
- package/plugins/quiz/plugin.json +4 -3
- package/plugins/quiz/public/quiz.css +36 -1
- package/plugins/quiz/public/quiz.js +298 -6
- package/plugins/theme-roller/CLAUDE.md +65 -0
- package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
- package/plugins/theme-roller/admin/lib/prompt.js +22 -0
- package/plugins/theme-roller/admin/lib/themes.js +196 -0
- package/plugins/theme-roller/admin/lib/view.js +88 -0
- package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
- package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
- package/plugins/theme-roller/config.js +1 -0
- package/plugins/theme-roller/docs/guide.md +104 -0
- package/plugins/theme-roller/plugin.js +263 -0
- package/plugins/theme-roller/plugin.json +42 -0
- package/plugins/theme-roller/tests/api.test.js +161 -0
- package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
- package/plugins/theme-roller/tests/themes.test.js +77 -0
- package/plugins/theme-roller/tests/view.test.js +55 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Garage Plugin — Backend
|
|
3
|
+
*
|
|
4
|
+
* Proxies DVLA vehicle lookups and stores per-user vehicle history via the
|
|
5
|
+
* CMS collections service (garage-vehicles collection). Uses MongoDB when
|
|
6
|
+
* configured, falls back to file-based storage automatically.
|
|
7
|
+
* All routes are mounted at /api/plugins/garage/ by the plugin system.
|
|
8
|
+
*
|
|
9
|
+
* Routes:
|
|
10
|
+
* POST /lookup — Proxy DVLA lookup, upsert into history
|
|
11
|
+
* GET /vehicles — Saved vehicles for the authenticated user
|
|
12
|
+
* GET /history — Full lookup history for the authenticated user
|
|
13
|
+
* PATCH /vehicles/:id/save — Set ({isSaved}) or toggle isSaved on a vehicle entry
|
|
14
|
+
* DELETE /vehicles/:id — Remove a vehicle entry from history
|
|
15
|
+
* GET /count — The sidebar badge: saved vehicles whose MOT or tax has run out or is due
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
createEntry,
|
|
19
|
+
deleteEntry,
|
|
20
|
+
getEntry,
|
|
21
|
+
listEntries,
|
|
22
|
+
updateEntry
|
|
23
|
+
} from '../../server/services/collections.js';
|
|
24
|
+
import defaultConfig from './config.js';
|
|
25
|
+
import {badgeFor, isoDay, shortDay, vehicleNotices} from './admin/lib/garage.js';
|
|
26
|
+
import {getPluginSettings} from '../../server/services/plugins.js';
|
|
27
|
+
|
|
28
|
+
const COLLECTION = 'garage-vehicles';
|
|
29
|
+
|
|
30
|
+
/** In-memory rate limit tracker: Map<userId, { count: number, windowStart: number }> */
|
|
31
|
+
const rateLimits = new Map();
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Flatten a collection entry into the vehicle model the frontend expects.
|
|
36
|
+
*
|
|
37
|
+
* @param {{ id: string, data: object }} entry
|
|
38
|
+
* @returns {object}
|
|
39
|
+
*/
|
|
40
|
+
function toVehicle(entry) {
|
|
41
|
+
return {
|
|
42
|
+
id: entry.id,
|
|
43
|
+
...entry.data,
|
|
44
|
+
isSaved: entry.data.isSaved === true || entry.data.isSaved === 'true'
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Check and increment the per-user rate limit.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} userId
|
|
52
|
+
* @param {{ max: number, windowSeconds: number }} config
|
|
53
|
+
* @returns {{ allowed: boolean, retryAfter: number }} retryAfter is seconds until reset (0 if allowed)
|
|
54
|
+
*/
|
|
55
|
+
function checkRateLimit(userId, config) {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const windowMs = config.windowSeconds * 1000;
|
|
58
|
+
const entry = rateLimits.get(userId);
|
|
59
|
+
|
|
60
|
+
if (!entry || now - entry.windowStart >= windowMs) {
|
|
61
|
+
rateLimits.set(userId, {count: 1, windowStart: now});
|
|
62
|
+
return {allowed: true, retryAfter: 0};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (entry.count >= config.max) {
|
|
66
|
+
const retryAfter = Math.ceil((entry.windowStart + windowMs - now) / 1000);
|
|
67
|
+
return {allowed: false, retryAfter};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
entry.count += 1;
|
|
71
|
+
return {allowed: true, retryAfter: 0};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Normalise a registration number: uppercase and strip all spaces.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} reg
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function normaliseReg(reg) {
|
|
81
|
+
return String(reg).toUpperCase().replace(/\s+/g, '');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Map a DVLA API response to our internal vehicle data shape.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} userId
|
|
88
|
+
* @param {object} dvlaData - Raw response from DVLA API
|
|
89
|
+
* @returns {object}
|
|
90
|
+
*/
|
|
91
|
+
function mapDvlaResponse(userId, dvlaData) {
|
|
92
|
+
return {
|
|
93
|
+
userId,
|
|
94
|
+
registrationNumber: dvlaData.registrationNumber ?? '',
|
|
95
|
+
make: dvlaData.make ?? '',
|
|
96
|
+
colour: dvlaData.colour ?? '',
|
|
97
|
+
yearOfManufacture: dvlaData.yearOfManufacture ?? null,
|
|
98
|
+
fuelType: dvlaData.fuelType ?? '',
|
|
99
|
+
engineCapacity: dvlaData.engineCapacity ?? null,
|
|
100
|
+
co2Emissions: dvlaData.co2Emissions ?? null,
|
|
101
|
+
motStatus: dvlaData.motStatus ?? '',
|
|
102
|
+
motExpiryDate: dvlaData.motExpiryDate ?? null,
|
|
103
|
+
taxStatus: dvlaData.taxStatus ?? '',
|
|
104
|
+
taxDueDate: dvlaData.taxDueDate ?? null,
|
|
105
|
+
isSaved: false,
|
|
106
|
+
lookupDate: new Date().toISOString()
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Escape HTML special characters for safe server-side rendering.
|
|
112
|
+
*
|
|
113
|
+
* @param {*} str
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function escHtml(str) {
|
|
117
|
+
return String(str ?? '')
|
|
118
|
+
.replace(/&/g, '&')
|
|
119
|
+
.replace(/</g, '<')
|
|
120
|
+
.replace(/>/g, '>')
|
|
121
|
+
.replace(/"/g, '"');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Render a list of saved vehicle objects as a static HTML card grid.
|
|
126
|
+
* Used by the [garage-vehicles /] shortcode for front-end display.
|
|
127
|
+
*
|
|
128
|
+
* @param {object[]} vehicles
|
|
129
|
+
* @returns {string} HTML string
|
|
130
|
+
*/
|
|
131
|
+
function renderVehiclesHtml(vehicles) {
|
|
132
|
+
const cards = vehicles.map(v => {
|
|
133
|
+
const motLv = (v.motStatus || '').toLowerCase();
|
|
134
|
+
const motCls = motLv === 'valid' ? 'badge-success' : motLv ? 'badge-danger' : 'badge-secondary';
|
|
135
|
+
const taxLv = (v.taxStatus || '').toLowerCase();
|
|
136
|
+
const taxCls = taxLv === 'taxed' ? 'badge-success' : taxLv === 'sorn' ? 'badge-warning' : taxLv ? 'badge-danger' : 'badge-secondary';
|
|
137
|
+
|
|
138
|
+
const details = [
|
|
139
|
+
v.colour,
|
|
140
|
+
v.fuelType,
|
|
141
|
+
v.engineCapacity ? `${v.engineCapacity}cc` : null,
|
|
142
|
+
v.co2Emissions ? `${v.co2Emissions}g/km` : null
|
|
143
|
+
].filter(Boolean).join(' · ');
|
|
144
|
+
|
|
145
|
+
const motExpiry = v.motExpiryDate
|
|
146
|
+
? `<span style="font-size:0.78rem;color:var(--dm-text-muted,#888);">Exp ${escHtml(shortDay(v.motExpiryDate))}</span>` : '';
|
|
147
|
+
const taxDue = v.taxDueDate
|
|
148
|
+
? `<span style="font-size:0.78rem;color:var(--dm-text-muted,#888);">Due ${escHtml(shortDay(v.taxDueDate))}</span>` : '';
|
|
149
|
+
|
|
150
|
+
return `<div class="card" style="flex:1;min-width:260px;max-width:340px;">
|
|
151
|
+
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;gap:0.75rem;">
|
|
152
|
+
<span style="background:#FFD900;color:#000;font-family:'Arial Black',Arial,sans-serif;font-weight:900;font-size:1rem;letter-spacing:3px;padding:0.15rem 0.5rem;border-radius:4px;border:2px solid #000;">${escHtml(v.registrationNumber || '')}</span>
|
|
153
|
+
<span style="font-size:0.85rem;color:var(--dm-text-muted,#888);">${escHtml(v.make || '')}${v.yearOfManufacture ? ` · ${escHtml(String(v.yearOfManufacture))}` : ''}</span>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="card-body">
|
|
156
|
+
${details ? `<p style="font-size:0.85rem;color:var(--dm-text-muted,#888);margin:0 0 0.6rem;">${escHtml(details)}</p>` : ''}
|
|
157
|
+
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;align-items:center;">
|
|
158
|
+
<span class="badge ${motCls}">MOT: ${escHtml(v.motStatus || 'Unknown')}</span>
|
|
159
|
+
${motExpiry}
|
|
160
|
+
<span class="badge ${taxCls}">Tax: ${escHtml(v.taxStatus || 'Unknown')}</span>
|
|
161
|
+
${taxDue}
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</div>`;
|
|
165
|
+
}).join('\n');
|
|
166
|
+
|
|
167
|
+
return `<div style="display:flex;flex-wrap:wrap;gap:1rem;">\n${cards}\n</div>`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export default async function garagePlugin(fastify, options) {
|
|
171
|
+
const {authenticate, requirePermission} = options.auth;
|
|
172
|
+
// The Garage permission (1.3): granted to admin and user; take it from a role
|
|
173
|
+
// under Roles and that role has no Garage (a lookup spends the site's DVLA key).
|
|
174
|
+
const need = (action) => (typeof requirePermission === 'function' ? requirePermission('garage', action) : async () => {});
|
|
175
|
+
|
|
176
|
+
// Notifications (core 0.80+; feature-detected): each morning, a saved
|
|
177
|
+
// vehicle whose MOT or tax has run out (critical) or is due soon (warning)
|
|
178
|
+
// - to its OWNER. A garage is personal. Checks that pass resolve.
|
|
179
|
+
const hooks = options.hooks || {};
|
|
180
|
+
if (typeof hooks.registerNotificationSource === 'function') {
|
|
181
|
+
try {
|
|
182
|
+
hooks.registerNotificationSource({
|
|
183
|
+
id: 'due', label: 'MOT and tax due', severity: 'warning', every: 'daily', atBoot: true,
|
|
184
|
+
description: 'Each morning: a saved vehicle whose MOT or road tax has run out, or is due within 30 days - told to its owner.',
|
|
185
|
+
check: async (ctx) => {
|
|
186
|
+
const {entries} = await listEntries(COLLECTION, {limit: 10000});
|
|
187
|
+
const saved = entries.map(toVehicle).filter(v => v.isSaved);
|
|
188
|
+
const {raise, resolve} = vehicleNotices(saved, isoDay(ctx.now || new Date()));
|
|
189
|
+
for (const key of resolve) await ctx.resolve(key);
|
|
190
|
+
for (const p of raise) await ctx.notify(p);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
} catch { /* older engine */ }
|
|
194
|
+
}
|
|
195
|
+
const {registerShortcode} = options.hooks;
|
|
196
|
+
const cfg = {...defaultConfig, ...(options.settings || {})};
|
|
197
|
+
cfg.dvlaApiKey = process.env.DVLA_API_KEY || cfg.dvlaApiKey;
|
|
198
|
+
|
|
199
|
+
// --- [garage-vehicles /] shortcode --------------------------------------
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Renders saved vehicles as a static HTML card grid on public pages.
|
|
203
|
+
*
|
|
204
|
+
* Reads through the collections service (listEntries -> the storage adapter),
|
|
205
|
+
* exactly as the API routes do, so file and MongoDB storage render the same.
|
|
206
|
+
* Until 1.4.1 it read content/collections/garage-vehicles/data.json directly,
|
|
207
|
+
* and on a site whose vehicles live in MongoDB it rendered nothing.
|
|
208
|
+
* Shortcode handlers may be async (processPluginShortcodes awaits them).
|
|
209
|
+
*
|
|
210
|
+
* Usage:
|
|
211
|
+
* [garage-vehicles user="uuid" /] — one user's saved vehicles
|
|
212
|
+
*
|
|
213
|
+
* A user must be named: until 1.3 the bare shortcode put EVERY user's saved
|
|
214
|
+
* vehicles on a public page, and a user's garage is theirs.
|
|
215
|
+
*/
|
|
216
|
+
registerShortcode('garage-vehicles', async (attrStr, _body, ctx) => {
|
|
217
|
+
const attrs = ctx.parseShortcodeAttrs(attrStr);
|
|
218
|
+
const userId = attrs.user || null;
|
|
219
|
+
if (!userId) return '';
|
|
220
|
+
|
|
221
|
+
let entries;
|
|
222
|
+
try {
|
|
223
|
+
({entries} = await listEntries(COLLECTION, {limit: 10000}));
|
|
224
|
+
} catch {
|
|
225
|
+
return '';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const vehicles = (entries || [])
|
|
229
|
+
.filter(e => e?.data && e.data.userId === userId)
|
|
230
|
+
.map(toVehicle)
|
|
231
|
+
.filter(v => v.isSaved);
|
|
232
|
+
if (!vehicles.length) return '';
|
|
233
|
+
|
|
234
|
+
return renderVehiclesHtml(vehicles);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// --- POST /lookup -------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Proxy a DVLA vehicle lookup and upsert the result into the user's history.
|
|
241
|
+
* Returns 503 if no DVLA API key is configured.
|
|
242
|
+
* Returns 429 if the user has exceeded the rate limit.
|
|
243
|
+
*/
|
|
244
|
+
fastify.post('/lookup', {preHandler: [authenticate, need('create')]}, async (request, reply) => {
|
|
245
|
+
const userId = request.user.id;
|
|
246
|
+
const {registrationNumber} = request.body || {};
|
|
247
|
+
|
|
248
|
+
if (!registrationNumber || typeof registrationNumber !== 'string') {
|
|
249
|
+
return reply.status(400).send({error: 'registrationNumber is required'});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const reg = normaliseReg(registrationNumber);
|
|
253
|
+
if (!reg) {
|
|
254
|
+
return reply.status(400).send({error: 'Invalid registration number'});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const rl = checkRateLimit(userId, cfg.rateLimit);
|
|
258
|
+
if (!rl.allowed) {
|
|
259
|
+
return reply.status(429).send({
|
|
260
|
+
error: `Rate limit reached. Try again in ${rl.retryAfter} seconds.`,
|
|
261
|
+
retryAfter: rl.retryAfter
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Read the key now, not at boot: one saved from the settings panel works at once,
|
|
266
|
+
// without a restart (1.1 needed one). The environment variable still wins.
|
|
267
|
+
let apiKey = process.env.DVLA_API_KEY || cfg.dvlaApiKey;
|
|
268
|
+
try { apiKey = process.env.DVLA_API_KEY || (await getPluginSettings('garage'))?.dvlaApiKey || apiKey; } catch { /* keep the boot value */ }
|
|
269
|
+
if (!apiKey) {
|
|
270
|
+
return reply.status(503).send({
|
|
271
|
+
error: 'No DVLA API key yet - add one in Garage settings (the cog in the banner).'
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let dvlaData;
|
|
276
|
+
try {
|
|
277
|
+
const dvlaRes = await fetch(cfg.dvlaApiUrl, {
|
|
278
|
+
method: 'POST',
|
|
279
|
+
headers: {
|
|
280
|
+
'x-api-key': apiKey,
|
|
281
|
+
'Content-Type': 'application/json'
|
|
282
|
+
},
|
|
283
|
+
body: JSON.stringify({registrationNumber: reg})
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
if (dvlaRes.status === 404) {
|
|
287
|
+
return reply.status(404).send({error: 'Vehicle not found. Check the registration number.'});
|
|
288
|
+
}
|
|
289
|
+
if (!dvlaRes.ok) {
|
|
290
|
+
const body = await dvlaRes.text().catch(() => '');
|
|
291
|
+
fastify.log.warn({status: dvlaRes.status, body}, '[garage] DVLA API error');
|
|
292
|
+
return reply.status(502).send({error: 'DVLA API returned an error. Please try again.'});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
dvlaData = await dvlaRes.json();
|
|
296
|
+
} catch (err) {
|
|
297
|
+
fastify.log.error({err}, '[garage] Failed to reach DVLA API');
|
|
298
|
+
return reply.status(502).send({error: 'Could not reach the DVLA API. Check your network connection.'});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Upsert: update existing entry if same reg+user, otherwise create
|
|
302
|
+
const {entries: all} = await listEntries(COLLECTION, {limit: 10000});
|
|
303
|
+
const existing = all.find(e => e.data.userId === userId && e.data.registrationNumber === reg);
|
|
304
|
+
|
|
305
|
+
let vehicle;
|
|
306
|
+
if (existing) {
|
|
307
|
+
const updated = await updateEntry(COLLECTION, existing.id, {
|
|
308
|
+
...mapDvlaResponse(userId, dvlaData),
|
|
309
|
+
isSaved: existing.data.isSaved
|
|
310
|
+
});
|
|
311
|
+
vehicle = toVehicle(updated);
|
|
312
|
+
} else {
|
|
313
|
+
const created = await createEntry(COLLECTION, mapDvlaResponse(userId, dvlaData), {createdBy: userId});
|
|
314
|
+
vehicle = toVehicle(created);
|
|
315
|
+
|
|
316
|
+
// Prune oldest unsaved entries if over the history limit
|
|
317
|
+
const {entries: fresh} = await listEntries(COLLECTION, {limit: 10000});
|
|
318
|
+
const userEntries = fresh.filter(e => e.data.userId === userId);
|
|
319
|
+
if (userEntries.length > cfg.maxHistoryItems) {
|
|
320
|
+
const unsaved = userEntries
|
|
321
|
+
.filter(e => !e.data.isSaved)
|
|
322
|
+
.sort((a, b) => new Date(a.data.lookupDate) - new Date(b.data.lookupDate));
|
|
323
|
+
const toDelete = unsaved.slice(0, userEntries.length - cfg.maxHistoryItems);
|
|
324
|
+
for (const entry of toDelete) {
|
|
325
|
+
await deleteEntry(COLLECTION, entry.id);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return vehicle;
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// --- GET /vehicles (saved) ----------------------------------------------
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Return all vehicles the authenticated user has saved to their garage.
|
|
337
|
+
* Supports optional ?q= for server-side search across reg/make/colour/year/fuel.
|
|
338
|
+
*/
|
|
339
|
+
fastify.get('/vehicles', {preHandler: [authenticate, need('read')]}, async (request) => {
|
|
340
|
+
const userId = request.user.id;
|
|
341
|
+
const q = (request.query.q || '').toLowerCase().trim();
|
|
342
|
+
|
|
343
|
+
const {entries} = await listEntries(COLLECTION, {limit: 10000});
|
|
344
|
+
let results = entries
|
|
345
|
+
.filter(e => e.data.userId === userId && (e.data.isSaved === true || e.data.isSaved === 'true'))
|
|
346
|
+
.map(toVehicle);
|
|
347
|
+
|
|
348
|
+
if (q) {
|
|
349
|
+
results = results.filter(v => {
|
|
350
|
+
const haystack = [
|
|
351
|
+
v.registrationNumber, v.make, v.colour,
|
|
352
|
+
v.yearOfManufacture, v.fuelType, v.taxStatus, v.motStatus
|
|
353
|
+
].join(' ').toLowerCase();
|
|
354
|
+
return haystack.includes(q);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return results;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// --- GET /history -------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Return the authenticated user's full lookup history, newest first.
|
|
365
|
+
* Supports optional ?q= search.
|
|
366
|
+
*/
|
|
367
|
+
fastify.get('/history', {preHandler: [authenticate, need('read')]}, async (request) => {
|
|
368
|
+
const userId = request.user.id;
|
|
369
|
+
const q = (request.query.q || '').toLowerCase().trim();
|
|
370
|
+
|
|
371
|
+
const {entries} = await listEntries(COLLECTION, {limit: 10000});
|
|
372
|
+
let results = entries
|
|
373
|
+
.filter(e => e.data.userId === userId)
|
|
374
|
+
.map(toVehicle)
|
|
375
|
+
.sort((a, b) => new Date(b.lookupDate) - new Date(a.lookupDate));
|
|
376
|
+
|
|
377
|
+
if (q) {
|
|
378
|
+
results = results.filter(v => {
|
|
379
|
+
const haystack = [
|
|
380
|
+
v.registrationNumber, v.make, v.colour,
|
|
381
|
+
v.yearOfManufacture, v.fuelType, v.taxStatus, v.motStatus
|
|
382
|
+
].join(' ').toLowerCase();
|
|
383
|
+
return haystack.includes(q);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return results;
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// --- PATCH /vehicles/:id/save -------------------------------------------
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Save a vehicle to My Garage or take it out. `{isSaved: true|false}` in the
|
|
394
|
+
* body sets it - what the screen sends, so a screen that is out of date (a
|
|
395
|
+
* change in another tab) still does what the person asked; with no body it
|
|
396
|
+
* toggles, as before 1.5. A body whose isSaved is not a boolean is refused
|
|
397
|
+
* (400): reading "false" as "toggle" would save an unsaved vehicle.
|
|
398
|
+
* Returns 404 if the entry does not exist or belongs to another user.
|
|
399
|
+
*/
|
|
400
|
+
fastify.patch('/vehicles/:id/save', {preHandler: [authenticate, need('create')]}, async (request, reply) => {
|
|
401
|
+
const userId = request.user.id;
|
|
402
|
+
const {id} = request.params;
|
|
403
|
+
|
|
404
|
+
const hasBody = request.body !== undefined && request.body !== null;
|
|
405
|
+
const wanted = hasBody ? request.body.isSaved : undefined;
|
|
406
|
+
if (hasBody && typeof wanted !== 'boolean') {
|
|
407
|
+
return reply.status(400).send({error: 'isSaved must be true or false'});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const entry = await getEntry(COLLECTION, id);
|
|
411
|
+
if (!entry || entry.data.userId !== userId) {
|
|
412
|
+
return reply.status(404).send({error: 'Vehicle not found'});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const updated = await updateEntry(COLLECTION, id, {
|
|
416
|
+
...entry.data,
|
|
417
|
+
isSaved: typeof wanted === 'boolean' ? wanted : !(entry.data.isSaved === true || entry.data.isSaved === 'true')
|
|
418
|
+
});
|
|
419
|
+
return toVehicle(updated);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// --- GET /count (sidebar badge) -----------------------------------------
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The badge on Garage in the sidebar (plugin.json countUrl), for the reader's
|
|
426
|
+
* own saved vehicles. `?today=YYYY-MM-DD` is the browser's day (the sidebar
|
|
427
|
+
* sends it) - MOT and tax dates are calendar days. Polled every minute from
|
|
428
|
+
* every admin tab, so each answer is kept for a minute per user and day.
|
|
429
|
+
*/
|
|
430
|
+
const badgeCache = new Map();
|
|
431
|
+
fastify.get('/count', {preHandler: [authenticate, need('read')]}, async (request) => {
|
|
432
|
+
const userId = request.user.id;
|
|
433
|
+
const given = String(request.query?.today ?? '');
|
|
434
|
+
const today = /^\d{4}-\d{2}-\d{2}$/.test(given) ? given : isoDay();
|
|
435
|
+
const key = `${userId}|${today}`;
|
|
436
|
+
const hit = badgeCache.get(key);
|
|
437
|
+
if (hit && Date.now() - hit.at < 60_000) return hit.value;
|
|
438
|
+
const {entries} = await listEntries(COLLECTION, {limit: 10000});
|
|
439
|
+
const saved = entries
|
|
440
|
+
.filter(e => e.data.userId === userId && (e.data.isSaved === true || e.data.isSaved === 'true'))
|
|
441
|
+
.map(toVehicle);
|
|
442
|
+
const value = badgeFor(saved, today);
|
|
443
|
+
badgeCache.set(key, {at: Date.now(), value});
|
|
444
|
+
return value;
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
/** A change to a user's vehicles: their next badge is worked out afresh. */
|
|
448
|
+
const forget = (userId) => { for (const k of badgeCache.keys()) if (k.startsWith(`${userId}|`)) badgeCache.delete(k); };
|
|
449
|
+
fastify.addHook('onResponse', async (request) => {
|
|
450
|
+
if (request.method !== 'GET' && request.user?.id) forget(request.user.id);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// --- DELETE /vehicles/:id -----------------------------------------------
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Permanently remove a vehicle entry from the user's history.
|
|
457
|
+
* Returns 404 if the entry does not exist or belongs to another user.
|
|
458
|
+
*/
|
|
459
|
+
fastify.delete('/vehicles/:id', {preHandler: [authenticate, need('delete')]}, async (request, reply) => {
|
|
460
|
+
const userId = request.user.id;
|
|
461
|
+
const {id} = request.params;
|
|
462
|
+
|
|
463
|
+
const entry = await getEntry(COLLECTION, id);
|
|
464
|
+
if (!entry || entry.data.userId !== userId) {
|
|
465
|
+
return reply.status(404).send({error: 'Vehicle not found'});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
await deleteEntry(COLLECTION, id);
|
|
469
|
+
return {ok: true};
|
|
470
|
+
});
|
|
471
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "garage",
|
|
3
|
+
"displayName": "Garage",
|
|
4
|
+
"version": "1.6.0",
|
|
5
|
+
"tier": "free",
|
|
6
|
+
"description": "UK vehicle management with DVLA API lookup, save vehicles, and search history.",
|
|
7
|
+
"author": "Darryl Waterhouse",
|
|
8
|
+
"date": "2026-10-21",
|
|
9
|
+
"icon": "truck",
|
|
10
|
+
"admin": {
|
|
11
|
+
"sidebar": [
|
|
12
|
+
{
|
|
13
|
+
"id": "garage",
|
|
14
|
+
"text": "Garage",
|
|
15
|
+
"icon": "truck",
|
|
16
|
+
"url": "#/plugins/garage",
|
|
17
|
+
"section": "#/plugins/garage",
|
|
18
|
+
"countUrl": "/api/plugins/garage/count",
|
|
19
|
+
"permission": "garage"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"routes": [
|
|
23
|
+
{
|
|
24
|
+
"path": "/plugins/garage",
|
|
25
|
+
"view": "plugin-garage",
|
|
26
|
+
"title": "Garage - Domma CMS"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"views": {
|
|
30
|
+
"plugin-garage": {
|
|
31
|
+
"entry": "garage/admin/views/garage.js",
|
|
32
|
+
"exportName": "garageView"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"css": [
|
|
36
|
+
"admin/css/garage.css"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"permissions": [
|
|
40
|
+
{
|
|
41
|
+
"key": "garage",
|
|
42
|
+
"label": "Garage",
|
|
43
|
+
"group": "Plugins",
|
|
44
|
+
"description": "Your own vehicle lookups and saved vehicles.",
|
|
45
|
+
"actions": [
|
|
46
|
+
{
|
|
47
|
+
"key": "read",
|
|
48
|
+
"label": "View",
|
|
49
|
+
"description": "Open My Garage and your lookup history"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"key": "create",
|
|
53
|
+
"label": "Look up",
|
|
54
|
+
"description": "Look vehicles up (uses the site's DVLA key) and save them"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"key": "delete",
|
|
58
|
+
"label": "Delete",
|
|
59
|
+
"description": "Remove your saved vehicles and lookups"
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
"grant": [
|
|
63
|
+
"admin",
|
|
64
|
+
"user"
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Garage 1.3: the Garage permission on every route, and a shortcode that shows
|
|
3
|
+
* one named user's vehicles - never everyone's.
|
|
4
|
+
*
|
|
5
|
+
* 1.4.1: the shortcode reads through the collections service and its storage
|
|
6
|
+
* adapter, like every route, not the data file - so it is tested on file
|
|
7
|
+
* storage (a real garage-vehicles collection in the domma-cms checkout, made
|
|
8
|
+
* for the run and removed again, or put back if one was there) and on a
|
|
9
|
+
* non-file adapter (the registry's adapter for the slug, stubbed).
|
|
10
|
+
*/
|
|
11
|
+
import {test, before, after, mock} from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import fs from 'node:fs/promises';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import Fastify from 'fastify';
|
|
16
|
+
import garagePlugin from '../plugin.js';
|
|
17
|
+
import {getAdapter, invalidate} from '../../../server/services/adapterRegistry.js';
|
|
18
|
+
|
|
19
|
+
const SLUG = 'garage-vehicles';
|
|
20
|
+
const HERE = path.dirname(new URL(import.meta.url).pathname);
|
|
21
|
+
// Where the collections service keeps it: `make test` runs with cwd = the CMS root.
|
|
22
|
+
const DIR = path.resolve('content/collections', SLUG);
|
|
23
|
+
const KEEP = ['schema.json', 'data.json'].map(f => path.join(DIR, f));
|
|
24
|
+
const saved = new Map();
|
|
25
|
+
let dirWas = false;
|
|
26
|
+
let app;
|
|
27
|
+
let shortcode;
|
|
28
|
+
|
|
29
|
+
before(async () => {
|
|
30
|
+
dirWas = await fs.stat(DIR).then(() => true, () => false);
|
|
31
|
+
for (const f of KEEP) saved.set(f, await fs.readFile(f, 'utf8').catch(() => null));
|
|
32
|
+
await fs.mkdir(DIR, {recursive: true});
|
|
33
|
+
await fs.copyFile(path.join(HERE, '..', 'collections', SLUG, 'schema.json'), path.join(DIR, 'schema.json'));
|
|
34
|
+
await fs.writeFile(path.join(DIR, 'data.json'), JSON.stringify([
|
|
35
|
+
{id: 'v1', data: {userId: 'u-sam', registrationNumber: 'AB12CDE', make: 'FORD', isSaved: true}},
|
|
36
|
+
{id: 'v2', data: {userId: 'u-jo', registrationNumber: 'XY99ZZZ', make: 'VOLVO', isSaved: true}},
|
|
37
|
+
{id: 'v3', data: {userId: 'u-sam', registrationNumber: 'LK07ABC', make: 'SKODA', isSaved: false}}
|
|
38
|
+
]));
|
|
39
|
+
invalidate(SLUG);
|
|
40
|
+
app = Fastify();
|
|
41
|
+
const authenticate = async (req) => { req.user = {id: req.headers['x-id'] || 'u-sam', role: req.headers['x-role'] || 'user'}; };
|
|
42
|
+
// The Garage permission: held unless x-no-garage is sent.
|
|
43
|
+
const requirePermission = () => async (req, reply) => {
|
|
44
|
+
if (req.headers['x-no-garage']) return reply.code(403).send({error: 'Insufficient permissions'});
|
|
45
|
+
};
|
|
46
|
+
await app.register(garagePlugin, {auth: {authenticate, requirePermission}, settings: {},
|
|
47
|
+
hooks: {registerShortcode: (name, fn) => { if (name === 'garage-vehicles') shortcode = fn; }}});
|
|
48
|
+
await app.ready();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
after(async () => {
|
|
52
|
+
await app?.close();
|
|
53
|
+
if (!dirWas) await fs.rm(DIR, {recursive: true, force: true});
|
|
54
|
+
else for (const [f, body] of saved) {
|
|
55
|
+
if (body === null) await fs.rm(f, {force: true});
|
|
56
|
+
else await fs.writeFile(f, body);
|
|
57
|
+
}
|
|
58
|
+
invalidate(SLUG);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const ctx = {parseShortcodeAttrs: (s) => Object.fromEntries([...String(s).matchAll(/(\w+)="([^"]*)"/g)].map(m => [m[1], m[2]]))};
|
|
62
|
+
|
|
63
|
+
test('the bare shortcode shows nobody\'s vehicles; a named user, only theirs (file storage)', async () => {
|
|
64
|
+
assert.equal(await shortcode('', null, ctx), '', 'every user\'s garage on a public page - no more');
|
|
65
|
+
const sams = await shortcode(' user="u-sam"', null, ctx);
|
|
66
|
+
assert.match(sams, /FORD/);
|
|
67
|
+
assert.doesNotMatch(sams, /VOLVO/, 'another user\'s vehicle');
|
|
68
|
+
assert.doesNotMatch(sams, /SKODA/, 'looked up but not saved');
|
|
69
|
+
assert.equal(await shortcode(' user="u-nobody"', null, ctx), '');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('the shortcode reads through the storage adapter, so a non-file store renders the same', async () => {
|
|
73
|
+
// What a MongoDB-backed collection hands back: nothing in data.json, and
|
|
74
|
+
// isSaved as the schema's text field may store it.
|
|
75
|
+
const adapter = await getAdapter(SLUG);
|
|
76
|
+
const listed = [];
|
|
77
|
+
const list = mock.method(adapter, 'list', async (slug, opts) => {
|
|
78
|
+
listed.push(slug);
|
|
79
|
+
return {entries: [
|
|
80
|
+
{id: 'm1', data: {userId: 'u-sam', registrationNumber: 'MO55NGO', make: 'TESLA', isSaved: 'true', motStatus: 'Valid'}},
|
|
81
|
+
{id: 'm2', data: {userId: 'u-jo', registrationNumber: 'JO11JJJ', make: 'VOLVO', isSaved: true}},
|
|
82
|
+
{id: 'm3', data: {userId: 'u-sam', registrationNumber: 'UN54VED', make: 'SKODA', isSaved: false}}
|
|
83
|
+
], total: 3, page: 1, limit: opts?.limit ?? 50};
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
const sams = await shortcode(' user="u-sam"', null, ctx);
|
|
87
|
+
assert.deepEqual(listed, [SLUG], 'asked the adapter, not the file');
|
|
88
|
+
assert.match(sams, /MO55NGO/);
|
|
89
|
+
assert.match(sams, /TESLA/);
|
|
90
|
+
assert.match(sams, /MOT: Valid/);
|
|
91
|
+
assert.doesNotMatch(sams, /FORD/, 'the data file was not read');
|
|
92
|
+
assert.doesNotMatch(sams, /VOLVO|SKODA/, 'still only the named user\'s saved vehicles');
|
|
93
|
+
assert.equal(await shortcode('', null, ctx), '', 'and still nobody\'s without a user');
|
|
94
|
+
// The API's own read gives the same vehicles through the same adapter.
|
|
95
|
+
const api = (await app.inject({method: 'GET', url: '/vehicles', headers: {'x-id': 'u-sam'}})).json();
|
|
96
|
+
assert.deepEqual(api.map(v => v.registrationNumber), ['MO55NGO']);
|
|
97
|
+
} finally {
|
|
98
|
+
list.mock.restore();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a store that fails renders nothing rather than breaking the page', async () => {
|
|
103
|
+
const adapter = await getAdapter(SLUG);
|
|
104
|
+
const list = mock.method(adapter, 'list', async () => { throw new Error('connection lost'); });
|
|
105
|
+
try {
|
|
106
|
+
assert.equal(await shortcode(' user="u-sam"', null, ctx), '');
|
|
107
|
+
} finally {
|
|
108
|
+
list.mock.restore();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('every route needs the Garage permission', async () => {
|
|
113
|
+
for (const [method, url] of [['GET', '/vehicles'], ['GET', '/history'], ['GET', '/count'], ['POST', '/lookup'],
|
|
114
|
+
['PATCH', '/vehicles/v1/save'], ['DELETE', '/vehicles/v1']]) {
|
|
115
|
+
const res = await app.inject({method, url, headers: {'x-no-garage': '1'}, payload: method === 'GET' ? undefined : {}});
|
|
116
|
+
assert.equal(res.statusCode, 403, `${method} ${url}`);
|
|
117
|
+
}
|
|
118
|
+
assert.notEqual((await app.inject({method: 'GET', url: '/count'})).statusCode, 403, 'with it, as before');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('saving sets the state asked for; with no body it toggles as before; anything else is refused', async () => {
|
|
122
|
+
const send = (payload) => app.inject({method: 'PATCH', url: '/vehicles/v3/save', headers: {'x-id': 'u-sam'}, payload});
|
|
123
|
+
const save = async (payload) => (await send(payload)).json().isSaved;
|
|
124
|
+
assert.equal(await save({isSaved: true}), true);
|
|
125
|
+
assert.equal(await save({isSaved: true}), true, 'asked twice (a screen out of date) - still saved, not flipped back');
|
|
126
|
+
assert.equal(await save({isSaved: false}), false);
|
|
127
|
+
assert.equal(await save(undefined), true, 'no body: a toggle');
|
|
128
|
+
for (const bad of [{isSaved: 'false'}, {isSaved: 'yes'}, {isSaved: 0}, {}]) {
|
|
129
|
+
assert.equal((await send(bad)).statusCode, 400, `refused: ${JSON.stringify(bad)}`);
|
|
130
|
+
}
|
|
131
|
+
assert.equal(await save({isSaved: false}), false, 'the refused ones changed nothing: it was still saved');
|
|
132
|
+
const other = await app.inject({method: 'PATCH', url: '/vehicles/v3/save', headers: {'x-id': 'u-jo'}, payload: {isSaved: true}});
|
|
133
|
+
assert.equal(other.statusCode, 404, 'another user\'s vehicle');
|
|
134
|
+
});
|