domma-cms 0.9.1 → 0.9.6

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 (43) hide show
  1. package/admin/js/templates/block-editor.html +163 -163
  2. package/admin/js/templates/form-editor.html +245 -245
  3. package/admin/js/views/action-editor.js +1 -1
  4. package/admin/js/views/block-editor.js +8 -8
  5. package/admin/js/views/collection-editor.js +4 -4
  6. package/admin/js/views/collections.js +1 -1
  7. package/admin/js/views/form-editor.js +7 -7
  8. package/admin/js/views/forms.js +1 -1
  9. package/admin/js/views/navigation.js +14 -14
  10. package/admin/js/views/page-editor.js +35 -35
  11. package/admin/js/views/pages.js +5 -5
  12. package/admin/js/views/plugins.js +13 -10
  13. package/admin/js/views/view-editor.js +1 -1
  14. package/config/plugins.json +25 -0
  15. package/package.json +1 -1
  16. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +4 -4
  17. package/plugins/docs/data/folders.json +3 -3
  18. package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +5 -0
  19. package/plugins/garage/admin/templates/garage.html +30 -0
  20. package/plugins/garage/admin/views/garage.js +62 -1
  21. package/plugins/garage/plugin.json +1 -1
  22. package/plugins/notes/admin/templates/notes.html +2 -11
  23. package/plugins/notes/admin/views/notes.js +108 -129
  24. package/plugins/notes/collections/user-notes/schema.json +2 -1
  25. package/plugins/notes/plugin.json +1 -1
  26. package/plugins/site-search/admin/templates/site-search.html +174 -46
  27. package/plugins/site-search/admin/views/site-search.js +72 -1
  28. package/plugins/site-search/config.js +6 -1
  29. package/plugins/site-search/plugin.json +1 -1
  30. package/plugins/site-search/public/inject-head.html +1 -1
  31. package/plugins/site-search/public/search.css +1 -1
  32. package/plugins/site-search/public/search.js +1 -1
  33. package/plugins/todo/admin/templates/todo.html +2 -8
  34. package/plugins/todo/admin/views/todo.js +123 -106
  35. package/plugins/todo/collections/todos/schema.json +2 -1
  36. package/plugins/todo/plugin.json +1 -1
  37. package/server/routes/api/media.js +127 -118
  38. package/server/routes/api/plugins.js +15 -4
  39. package/server/server.js +288 -285
  40. package/server/services/collections.js +17 -10
  41. package/server/services/plugins.js +77 -67
  42. package/server/services/renderer.js +3 -3
  43. package/plugins/docs/data/documents/452f49b7-9c93-4a67-874d-27f882891ad2.json +0 -11
@@ -1,8 +1,7 @@
1
1
  // ============================================================
2
2
  // Todo plugin admin view
3
- // Uses auth-aware api() helper for all API calls (Bearer token from S.get('auth_token'))
4
- // Uses $container.find() for all DOM queries
5
- // Note: innerHTML usage below escapes all user-supplied text via escapeHtml()
3
+ // Uses auth-aware api() helper for all API calls
4
+ // Uses T.create() for table rendering (Domma Table component)
6
5
  // ============================================================
7
6
 
8
7
  const STATUS = {
@@ -27,15 +26,12 @@ function formatPriority(priority) {
27
26
  return priority.charAt(0).toUpperCase() + priority.slice(1);
28
27
  }
29
28
 
30
- /** Escape user-supplied text before inserting into HTML markup */
31
- function escapeHtml(str) {
32
- return String(str ?? '')
33
- .replace(/&/g, '&')
34
- .replace(/</g, '&lt;')
35
- .replace(/>/g, '&gt;')
36
- .replace(/"/g, '&quot;')
37
- .replace(/'/g, '&#39;');
38
- }
29
+ const esc = (str) => String(str ?? '')
30
+ .replace(/&/g, '&amp;')
31
+ .replace(/</g, '&lt;')
32
+ .replace(/>/g, '&gt;')
33
+ .replace(/"/g, '&quot;')
34
+ .replace(/'/g, '&#39;');
39
35
 
40
36
  async function api(url, method = 'GET', body) {
41
37
  const opts = {method, headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}};
@@ -59,85 +55,135 @@ export const todoView = {
59
55
  // ---- State ----
60
56
  let todos = [];
61
57
  let activeFilter = 'all';
58
+ let todoTable = null;
62
59
 
63
60
  // ---- Fetch ----
64
61
  async function loadTodos() {
65
62
  try {
66
63
  const res = await api('/api/plugins/todo/items');
67
64
  todos = Array.isArray(res) ? res : (res.data ?? []);
68
- } catch (err) {
65
+ } catch {
69
66
  E.toast('Failed to load todos', { type: 'error' });
70
67
  todos = [];
71
68
  }
72
69
  }
73
70
 
74
- // ---- Render ----
71
+ // ---- Render table ----
75
72
  function render() {
76
- const listEl = $container.find('#todo-list').get(0);
77
- const emptyEl = $container.find('#empty-state').get(0);
78
-
79
73
  const filtered = activeFilter === 'all'
80
74
  ? todos
81
75
  : todos.filter((t) => t.status === activeFilter);
82
76
 
83
- if (filtered.length === 0) {
84
- // Safe: no user content, only static markup
85
- listEl.textContent = '';
86
- emptyEl.style.display = 'block';
77
+ if (todoTable) {
78
+ todoTable.setData(filtered);
87
79
  return;
88
80
  }
89
81
 
90
- emptyEl.style.display = 'none';
91
-
92
- // Build markup — all user values run through escapeHtml()
93
- const rows = filtered.map((todo) => {
94
- const isCompleted = todo.status === STATUS.COMPLETED;
95
- const safeText = escapeHtml(todo.text);
96
- const safeId = escapeHtml(todo.id);
97
-
98
- const statusClass = `status-pill-${escapeHtml(todo.status)}`;
99
- const priorityClass = `priority-pill-${escapeHtml(todo.priority)}`;
100
-
101
- let dueBadge = '';
102
- if (todo.dueAt) {
103
- const due = new Date(todo.dueAt);
104
- const today = new Date();
105
- today.setHours(0, 0, 0, 0);
106
- const overdue = !isCompleted && due < today;
107
- const dueLabel = D(todo.dueAt).format('D MMM');
108
- dueBadge = `<span class="badge ${overdue ? 'badge-danger' : 'badge-outline'}" style="font-size:0.68rem;padding:1px 6px;flex-shrink:0;" title="Due ${escapeHtml(dueLabel)}">${overdue ? '⚠ ' : ''}${escapeHtml(dueLabel)}</span>`;
82
+ todoTable = T.create('#todo-table', {
83
+ data: filtered,
84
+ onRender: (el) => Domma.icons.scan(el),
85
+ columns: [
86
+ {
87
+ key: 'status',
88
+ title: '',
89
+ sortable: false,
90
+ render: (status, row) => {
91
+ const checked = status === STATUS.COMPLETED ? 'checked' : '';
92
+ return `<input type="checkbox" class="form-check-input todo-checkbox" data-id="${esc(row.id)}" ${checked} style="cursor:pointer;" />`;
93
+ }
94
+ },
95
+ {
96
+ key: 'text',
97
+ title: 'Task',
98
+ sortable: true,
99
+ render: (text, row) => {
100
+ const isCompleted = row.status === STATUS.COMPLETED;
101
+ const style = isCompleted ? 'text-decoration:line-through;opacity:0.45;' : '';
102
+ let dueBadge = '';
103
+ if (row.dueAt) {
104
+ const due = new Date(row.dueAt);
105
+ const today = new Date();
106
+ today.setHours(0, 0, 0, 0);
107
+ const overdue = !isCompleted && due < today;
108
+ const dueLabel = D(row.dueAt).format('D MMM');
109
+ dueBadge = ` <span class="badge ${overdue ? 'badge-danger' : 'badge-outline'}" `
110
+ + `style="font-size:0.68rem;padding:1px 6px;" title="Due ${esc(dueLabel)}">`
111
+ + `${overdue ? '\u26a0 ' : ''}${esc(dueLabel)}</span>`;
112
+ }
113
+ return `<span style="font-size:0.9rem;${style}">${esc(text)}${dueBadge}</span>`;
114
+ }
115
+ },
116
+ {
117
+ key: 'status',
118
+ title: 'Status',
119
+ sortable: true,
120
+ render: (status, row) => {
121
+ const opts = [
122
+ {v: 'not-started', l: 'Not Started'},
123
+ {v: 'in-progress', l: 'In Progress'},
124
+ {v: 'completed', l: 'Completed'}
125
+ ].map(({v, l}) =>
126
+ `<option value="${v}"${status === v ? ' selected' : ''}>${l}</option>`
127
+ ).join('');
128
+ return `<select class="todo-select-pill status-select status-pill-${esc(status)}" data-id="${esc(row.id)}">${opts}</select>`;
129
+ }
130
+ },
131
+ {
132
+ key: 'priority',
133
+ title: 'Priority',
134
+ sortable: true,
135
+ render: (priority, row) => {
136
+ const opts = [
137
+ {v: 'highest', l: 'Highest'},
138
+ {v: 'high', l: 'High'},
139
+ {v: 'medium', l: 'Medium'},
140
+ {v: 'low', l: 'Low'},
141
+ {v: 'lowest', l: 'Lowest'}
142
+ ].map(({v, l}) =>
143
+ `<option value="${v}"${priority === v ? ' selected' : ''}>${l}</option>`
144
+ ).join('');
145
+ return `<select class="todo-select-pill priority-select priority-pill-${esc(priority)}" data-id="${esc(row.id)}">${opts}</select>`;
146
+ }
147
+ },
148
+ {
149
+ key: 'id',
150
+ title: 'Actions',
151
+ sortable: false,
152
+ render: (id) =>
153
+ `<button class="btn btn-sm btn-ghost edit-todo" data-id="${esc(id)}" title="Edit" style="margin-right:3px;">` +
154
+ `<span data-icon="edit" data-icon-size="13"></span></button>` +
155
+ `<button class="btn btn-sm btn-danger delete-todo" data-id="${esc(id)}" title="Delete">` +
156
+ `<span data-icon="trash" data-icon-size="13"></span></button>`
157
+ }
158
+ ],
159
+ emptyMessage: 'No tasks yet. Add one above to get started.'
160
+ });
161
+
162
+ // Event delegation on the table container
163
+ const tableContainer = $container.find('#todo-table').get(0);
164
+
165
+ tableContainer.addEventListener('change', (e) => {
166
+ const target = e.target;
167
+ const id = target.dataset.id;
168
+ if (!id) return;
169
+
170
+ if (target.classList.contains('todo-checkbox')) {
171
+ toggleTodo(id);
172
+ } else if (target.classList.contains('status-select')) {
173
+ target.className = target.className.replace(/status-pill-\S+/g, `status-pill-${target.value}`);
174
+ updateStatus(id, target.value);
175
+ } else if (target.classList.contains('priority-select')) {
176
+ target.className = target.className.replace(/priority-pill-\S+/g, `priority-pill-${target.value}`);
177
+ updatePriority(id, target.value);
109
178
  }
179
+ });
110
180
 
111
- return `<li class="todo-item${isCompleted ? ' completed' : ''}" data-id="${safeId}">
112
- <input type="checkbox" class="form-check-input todo-checkbox" ${isCompleted ? 'checked' : ''} />
113
- <span class="todo-text">${safeText}${dueBadge ? ' ' + dueBadge : ''}</span>
114
- <div class="todo-selects">
115
- <select class="todo-select-pill status-select ${statusClass}" data-id="${safeId}" title="Status">
116
- <option value="not-started"${todo.status === 'not-started' ? ' selected' : ''}>Not Started</option>
117
- <option value="in-progress"${todo.status === 'in-progress' ? ' selected' : ''}>In Progress</option>
118
- <option value="completed"${todo.status === 'completed' ? ' selected' : ''}>Completed</option>
119
- </select>
120
- <select class="todo-select-pill priority-select ${priorityClass}" data-id="${safeId}" title="Priority">
121
- <option value="highest"${todo.priority === 'highest' ? ' selected' : ''}>Highest</option>
122
- <option value="high"${todo.priority === 'high' ? ' selected' : ''}>High</option>
123
- <option value="medium"${todo.priority === 'medium' ? ' selected' : ''}>Medium</option>
124
- <option value="low"${todo.priority === 'low' ? ' selected' : ''}>Low</option>
125
- <option value="lowest"${todo.priority === 'lowest' ? ' selected' : ''}>Lowest</option>
126
- </select>
127
- </div>
128
- <div class="todo-actions">
129
- <button class="btn btn-sm btn-ghost edit-todo" data-id="${safeId}" title="Edit">
130
- <span data-icon="edit" data-icon-size="13"></span>
131
- </button>
132
- <button class="btn btn-sm btn-danger delete-todo" data-id="${safeId}" title="Delete">
133
- <span data-icon="trash" data-icon-size="13"></span>
134
- </button>
135
- </div>
136
- </li>`;
137
- }).join('');
138
-
139
- listEl.innerHTML = rows;
140
- Domma.icons.scan(listEl);
181
+ tableContainer.addEventListener('click', (e) => {
182
+ const editBtn = e.target.closest('.edit-todo');
183
+ const deleteBtn = e.target.closest('.delete-todo');
184
+ if (editBtn) editTodo(editBtn.dataset.id);
185
+ if (deleteBtn) deleteTodo(deleteBtn.dataset.id);
186
+ });
141
187
  }
142
188
 
143
189
  // ---- Add todo ----
@@ -167,7 +213,7 @@ export const todoView = {
167
213
  if ($due.get(0)) $due.get(0).value = '';
168
214
  render();
169
215
  E.toast('Task added', { type: 'success' });
170
- } catch (err) {
216
+ } catch {
171
217
  E.toast('Failed to add task', { type: 'error' });
172
218
  }
173
219
  }
@@ -183,7 +229,7 @@ export const todoView = {
183
229
  Object.assign(todo, updated);
184
230
  render();
185
231
  E.toast(newStatus === STATUS.COMPLETED ? 'Task completed' : 'Task reopened', { type: 'success' });
186
- } catch (err) {
232
+ } catch {
187
233
  E.toast('Failed to update task', { type: 'error' });
188
234
  }
189
235
  }
@@ -201,7 +247,7 @@ export const todoView = {
201
247
  Object.assign(todo, updated);
202
248
  render();
203
249
  E.toast('Task updated', { type: 'success' });
204
- } catch (err) {
250
+ } catch {
205
251
  E.toast('Failed to update task', { type: 'error' });
206
252
  }
207
253
  }
@@ -216,7 +262,7 @@ export const todoView = {
216
262
  todos = todos.filter((t) => t.id !== id);
217
263
  render();
218
264
  E.toast('Task deleted', { type: 'success' });
219
- } catch (err) {
265
+ } catch {
220
266
  E.toast('Failed to delete task', { type: 'error' });
221
267
  }
222
268
  }
@@ -231,7 +277,7 @@ export const todoView = {
231
277
  Object.assign(todo, updated);
232
278
  render();
233
279
  E.toast(`Status: ${formatStatus(status)}`, { type: 'success' });
234
- } catch (err) {
280
+ } catch {
235
281
  E.toast('Failed to update status', { type: 'error' });
236
282
  }
237
283
  }
@@ -246,7 +292,7 @@ export const todoView = {
246
292
  Object.assign(todo, updated);
247
293
  render();
248
294
  E.toast(`Priority: ${formatPriority(priority)}`, { type: 'success' });
249
- } catch (err) {
295
+ } catch {
250
296
  E.toast('Failed to update priority', { type: 'error' });
251
297
  }
252
298
  }
@@ -267,7 +313,7 @@ export const todoView = {
267
313
  todos = todos.filter((t) => t.status !== STATUS.COMPLETED);
268
314
  render();
269
315
  E.toast('Completed tasks cleared', { type: 'success' });
270
- } catch (err) {
316
+ } catch {
271
317
  E.toast('Failed to clear completed tasks', { type: 'error' });
272
318
  }
273
319
  }
@@ -282,7 +328,6 @@ export const todoView = {
282
328
 
283
329
  $container.find('#clear-completed-btn').get(0).addEventListener('click', clearCompleted);
284
330
 
285
- // Filter buttons — direct addEventListener to avoid namespaced delegation issues
286
331
  $container.find('#filter-bar').get(0).querySelectorAll('.filter-btn').forEach((btn) => {
287
332
  btn.addEventListener('click', () => {
288
333
  activeFilter = btn.dataset.filter;
@@ -292,34 +337,6 @@ export const todoView = {
292
337
  });
293
338
  });
294
339
 
295
- // Todo list — native event delegation on the list element
296
- const listEl = $container.find('#todo-list').get(0);
297
-
298
- listEl.addEventListener('change', (e) => {
299
- const target = e.target;
300
- const id = target.dataset.id ?? target.closest('.todo-item')?.dataset.id;
301
- if (!id) return;
302
-
303
- if (target.classList.contains('todo-checkbox')) {
304
- toggleTodo(id);
305
- } else if (target.classList.contains('status-select')) {
306
- // Update pill colour immediately
307
- target.className = target.className.replace(/status-pill-\S+/, `status-pill-${target.value}`);
308
- updateStatus(id, target.value);
309
- } else if (target.classList.contains('priority-select')) {
310
- target.className = target.className.replace(/priority-pill-\S+/, `priority-pill-${target.value}`);
311
- updatePriority(id, target.value);
312
- }
313
- });
314
-
315
- listEl.addEventListener('click', (e) => {
316
- const editBtn = e.target.closest('.edit-todo');
317
- const deleteBtn = e.target.closest('.delete-todo');
318
-
319
- if (editBtn) editTodo(editBtn.dataset.id);
320
- if (deleteBtn) deleteTodo(deleteBtn.dataset.id);
321
- });
322
-
323
340
  // ---- Initial load ----
324
341
  await loadTodos();
325
342
  render();
@@ -54,6 +54,7 @@
54
54
  }
55
55
  },
56
56
  "storage": {
57
- "adapter": "file"
57
+ "adapter": "mongodb",
58
+ "connection": "default"
58
59
  }
59
60
  }
@@ -15,7 +15,7 @@
15
15
  ],
16
16
  "views": {
17
17
  "plugin-todo": {
18
- "entry": "todo/admin/views/todo.js",
18
+ "entry": "todo/admin/views/todo.js?v=2",
19
19
  "exportName": "todoView"
20
20
  }
21
21
  }
@@ -1,118 +1,127 @@
1
- /**
2
- * Media API
3
- * GET /api/media - list media files
4
- * POST /api/media - upload a file
5
- * DELETE /api/media/:name - delete a file
6
- */
7
- import path from 'path';
8
- import {deleteMedia, listMedia, renameMedia, saveMedia} from '../../services/content.js';
9
- import {getImageInfo, isEditableImage, transformImage} from '../../services/images.js';
10
- import {authenticate, requirePermission} from '../../middleware/auth.js';
11
-
12
- const ALLOWED_MIME_TYPES = new Set([
13
- // Images
14
- 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
15
- 'image/svg+xml', 'image/x-icon', 'image/bmp', 'image/tiff',
16
- // Documents
17
- 'application/pdf', 'text/plain', 'text/csv',
18
- 'application/msword',
19
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
20
- 'application/vnd.ms-excel',
21
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
22
- // Video
23
- 'video/mp4', 'video/webm', 'video/ogg',
24
- // Audio
25
- 'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav', 'audio/webm',
26
- ]);
27
-
28
- // Safe filename: strip path traversal and restrict to alphanumeric + safe chars
29
- function sanitiseFilename(name) {
30
- return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
31
- }
32
-
33
- export async function mediaRoutes(fastify) {
34
- const canRead = {preHandler: [authenticate, requirePermission('media', 'read')]};
35
- const canCreate = {preHandler: [authenticate, requirePermission('media', 'create')]};
36
- const canUpdate = {preHandler: [authenticate, requirePermission('media', 'update')]};
37
- const canDelete = {preHandler: [authenticate, requirePermission('media', 'delete')]};
38
-
39
- fastify.get('/media', canRead, async () => {
40
- return listMedia();
41
- });
42
-
43
- fastify.post('/media', canCreate, async (request, reply) => {
44
- const results = [];
45
- for await (const data of request.files()) {
46
- const filename = sanitiseFilename(data.filename);
47
- if (!ALLOWED_MIME_TYPES.has(data.mimetype)) {
48
- // Drain the stream to avoid leaving it open
49
- for await (const _ of data.file) { /* drain */ }
50
- return reply.code(400).send({
51
- error: `File type '${data.mimetype}' is not allowed. Allowed types: images, documents, audio, video.`,
52
- });
53
- }
54
- const chunks = [];
55
- for await (const chunk of data.file) {
56
- chunks.push(chunk);
57
- }
58
- results.push(await saveMedia(filename, Buffer.concat(chunks)));
59
- }
60
- if (!results.length) return reply.status(400).send({error: 'No file uploaded'});
61
- return reply.status(201).send(results.length === 1 ? results[0] : results);
62
- });
63
-
64
- fastify.patch('/media/:name', canUpdate, async (request, reply) => {
65
- const oldName = sanitiseFilename(request.params.name);
66
- const newName = sanitiseFilename(request.body?.newName ?? '');
67
- if (!newName) return reply.status(400).send({error: 'newName is required.'});
68
- if (oldName === newName) return reply.status(400).send({error: 'New name is the same as the current name.'});
69
- try {
70
- return await renameMedia(oldName, newName);
71
- } catch (err) {
72
- return reply.status(409).send({error: err.message});
73
- }
74
- });
75
-
76
- fastify.delete('/media/:name', canDelete, async (request, reply) => {
77
- const name = sanitiseFilename(request.params.name);
78
- await deleteMedia(name);
79
- return { success: true };
80
- });
81
-
82
- fastify.get('/media/:name/info', canRead, async (request, reply) => {
83
- const name = sanitiseFilename(request.params.name);
84
- if (!isEditableImage(name)) {
85
- return reply.status(400).send({error: 'Not an editable image format'});
86
- }
87
- try {
88
- return await getImageInfo(name);
89
- } catch {
90
- return reply.status(404).send({error: 'File not found'});
91
- }
92
- });
93
-
94
- fastify.post('/media/:name/transform', canUpdate, async (request, reply) => {
95
- const name = sanitiseFilename(request.params.name);
96
- if (!isEditableImage(name)) {
97
- return reply.status(400).send({error: 'Not an editable image format'});
98
- }
99
-
100
- const {operations = {}, saveAs} = request.body ?? {};
101
-
102
- // Sanitise fields that reference filesystem paths
103
- if (operations.watermark?.image) {
104
- operations.watermark.image = sanitiseFilename(operations.watermark.image);
105
- }
106
- if (operations._deleteOriginal) {
107
- operations._deleteOriginal = sanitiseFilename(operations._deleteOriginal);
108
- }
109
-
110
- const outputFilename = saveAs ? sanitiseFilename(saveAs) : null;
111
-
112
- try {
113
- return await transformImage(name, operations, outputFilename);
114
- } catch (err) {
115
- return reply.status(500).send({error: err.message});
116
- }
117
- });
118
- }
1
+ /**
2
+ * Media API
3
+ * GET /api/media - list media files
4
+ * POST /api/media - upload a file
5
+ * DELETE /api/media/:name - delete a file
6
+ */
7
+ import path from 'path';
8
+ import {deleteMedia, listMedia, renameMedia, saveMedia} from '../../services/content.js';
9
+ import {getImageInfo, isEditableImage, transformImage} from '../../services/images.js';
10
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
11
+
12
+ const ALLOWED_MIME_TYPES = new Set([
13
+ // Images
14
+ 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
15
+ 'image/svg+xml', 'image/x-icon', 'image/bmp', 'image/tiff',
16
+ // Documents
17
+ 'application/pdf', 'text/plain', 'text/csv',
18
+ 'application/msword',
19
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
20
+ 'application/vnd.ms-excel',
21
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
22
+ // Video
23
+ 'video/mp4', 'video/webm', 'video/ogg',
24
+ // Audio
25
+ 'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav', 'audio/webm',
26
+ ]);
27
+
28
+ // Safe filename: strip path traversal and restrict to alphanumeric + safe chars
29
+ function sanitiseFilename(name) {
30
+ return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
31
+ }
32
+
33
+ export async function mediaRoutes(fastify) {
34
+ const canRead = {preHandler: [authenticate, requirePermission('media', 'read')]};
35
+ const canCreate = {preHandler: [authenticate, requirePermission('media', 'create')]};
36
+ const canUpdate = {preHandler: [authenticate, requirePermission('media', 'update')]};
37
+ const canDelete = {preHandler: [authenticate, requirePermission('media', 'delete')]};
38
+
39
+ fastify.get('/media', canRead, async () => {
40
+ return listMedia();
41
+ });
42
+
43
+ fastify.post('/media', canCreate, async (request, reply) => {
44
+ const results = [];
45
+ for await (const data of request.files()) {
46
+ const filename = sanitiseFilename(data.filename);
47
+ if (filename.length > 200) {
48
+ for await (const _ of data.file) { /* drain */
49
+ }
50
+ return reply.code(400).send({error: 'Filename is too long (max 200 characters).'});
51
+ }
52
+ if (!ALLOWED_MIME_TYPES.has(data.mimetype)) {
53
+ // Drain the stream to avoid leaving it open
54
+ for await (const _ of data.file) { /* drain */ }
55
+ return reply.code(400).send({
56
+ error: `File type '${data.mimetype}' is not allowed. Allowed types: images, documents, audio, video.`,
57
+ });
58
+ }
59
+ const chunks = [];
60
+ for await (const chunk of data.file) {
61
+ chunks.push(chunk);
62
+ }
63
+ try {
64
+ results.push(await saveMedia(filename, Buffer.concat(chunks)));
65
+ } catch {
66
+ return reply.status(500).send({error: 'Upload failed.'});
67
+ }
68
+ }
69
+ if (!results.length) return reply.status(400).send({error: 'No file uploaded'});
70
+ return reply.status(201).send(results.length === 1 ? results[0] : results);
71
+ });
72
+
73
+ fastify.patch('/media/:name', canUpdate, async (request, reply) => {
74
+ const oldName = sanitiseFilename(request.params.name);
75
+ const newName = sanitiseFilename(request.body?.newName ?? '');
76
+ if (!newName) return reply.status(400).send({error: 'newName is required.'});
77
+ if (oldName === newName) return reply.status(400).send({error: 'New name is the same as the current name.'});
78
+ try {
79
+ return await renameMedia(oldName, newName);
80
+ } catch (err) {
81
+ return reply.status(409).send({error: err.message});
82
+ }
83
+ });
84
+
85
+ fastify.delete('/media/:name', canDelete, async (request, reply) => {
86
+ const name = sanitiseFilename(request.params.name);
87
+ await deleteMedia(name);
88
+ return { success: true };
89
+ });
90
+
91
+ fastify.get('/media/:name/info', canRead, async (request, reply) => {
92
+ const name = sanitiseFilename(request.params.name);
93
+ if (!isEditableImage(name)) {
94
+ return reply.status(400).send({error: 'Not an editable image format'});
95
+ }
96
+ try {
97
+ return await getImageInfo(name);
98
+ } catch {
99
+ return reply.status(404).send({error: 'File not found'});
100
+ }
101
+ });
102
+
103
+ fastify.post('/media/:name/transform', canUpdate, async (request, reply) => {
104
+ const name = sanitiseFilename(request.params.name);
105
+ if (!isEditableImage(name)) {
106
+ return reply.status(400).send({error: 'Not an editable image format'});
107
+ }
108
+
109
+ const {operations = {}, saveAs} = request.body ?? {};
110
+
111
+ // Sanitise fields that reference filesystem paths
112
+ if (operations.watermark?.image) {
113
+ operations.watermark.image = sanitiseFilename(operations.watermark.image);
114
+ }
115
+ if (operations._deleteOriginal) {
116
+ operations._deleteOriginal = sanitiseFilename(operations._deleteOriginal);
117
+ }
118
+
119
+ const outputFilename = saveAs ? sanitiseFilename(saveAs) : null;
120
+
121
+ try {
122
+ return await transformImage(name, operations, outputFilename);
123
+ } catch (err) {
124
+ return reply.status(500).send({error: err.message});
125
+ }
126
+ });
127
+ }
@@ -4,8 +4,14 @@
4
4
  * PUT /api/plugins/:name - enable/disable, update settings (admin only)
5
5
  * GET /api/plugins/admin-config - sidebar/routes/views for enabled plugins (authenticated)
6
6
  */
7
- import { authenticate, requireAdmin, requirePermission } from '../../middleware/auth.js';
8
- import { discoverPlugins, getPluginStates, savePluginState, getAdminPluginConfig, runLifecycleHook } from '../../services/plugins.js';
7
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
8
+ import {
9
+ discoverPlugins,
10
+ getAdminPluginConfig,
11
+ getPluginStates,
12
+ runLifecycleHook,
13
+ savePluginState
14
+ } from '../../services/plugins.js';
9
15
 
10
16
  export async function pluginsRoutes(fastify) {
11
17
  const canRead = { preHandler: [authenticate, requirePermission('plugins', 'read')] };
@@ -25,6 +31,7 @@ export async function pluginsRoutes(fastify) {
25
31
  date: manifest.date || '',
26
32
  icon: manifest.icon || 'package',
27
33
  enabled: !!(states[manifest.name]?.enabled),
34
+ bundled: !!(states[manifest.name]?.bundled),
28
35
  settings: states[manifest.name]?.settings || {}
29
36
  }));
30
37
  });
@@ -32,14 +39,18 @@ export async function pluginsRoutes(fastify) {
32
39
  // Enable/disable or update settings for a plugin
33
40
  fastify.put('/plugins/:name', canUpdate, async (request, reply) => {
34
41
  const { name } = request.params;
35
- const { enabled, settings } = request.body || {};
42
+ const {enabled, settings, bundled} = request.body || {};
36
43
 
37
44
  const manifests = await discoverPlugins();
38
45
  const manifest = manifests.find(m => m.name === name);
39
46
  if (!manifest) return reply.status(404).send({ error: 'Plugin not found' });
40
47
 
41
48
  const prevState = getPluginStates()[name] || {};
42
- savePluginState(name, { enabled: !!enabled, settings: settings || {} });
49
+ const update = {};
50
+ if (enabled !== undefined) update.enabled = !!enabled;
51
+ if (settings !== undefined) update.settings = settings;
52
+ if (bundled !== undefined) update.bundled = !!bundled;
53
+ savePluginState(name, update);
43
54
 
44
55
  if (!prevState.enabled && !!enabled) {
45
56
  await runLifecycleHook(name, 'onEnable', fastify);