domma-cms 0.9.1 → 0.9.5

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 +19 -10
  13. package/admin/js/views/view-editor.js +1 -1
  14. package/config/plugins.json +35 -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 +107 -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 +122 -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,134 @@ 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
+ columns: [
85
+ {
86
+ key: 'status',
87
+ title: '',
88
+ sortable: false,
89
+ render: (status, row) => {
90
+ const checked = status === STATUS.COMPLETED ? 'checked' : '';
91
+ return `<input type="checkbox" class="form-check-input todo-checkbox" data-id="${esc(row.id)}" ${checked} style="cursor:pointer;" />`;
92
+ }
93
+ },
94
+ {
95
+ key: 'text',
96
+ title: 'Task',
97
+ sortable: true,
98
+ render: (text, row) => {
99
+ const isCompleted = row.status === STATUS.COMPLETED;
100
+ const style = isCompleted ? 'text-decoration:line-through;opacity:0.45;' : '';
101
+ let dueBadge = '';
102
+ if (row.dueAt) {
103
+ const due = new Date(row.dueAt);
104
+ const today = new Date();
105
+ today.setHours(0, 0, 0, 0);
106
+ const overdue = !isCompleted && due < today;
107
+ const dueLabel = D(row.dueAt).format('D MMM');
108
+ dueBadge = ` <span class="badge ${overdue ? 'badge-danger' : 'badge-outline'}" `
109
+ + `style="font-size:0.68rem;padding:1px 6px;" title="Due ${esc(dueLabel)}">`
110
+ + `${overdue ? '\u26a0 ' : ''}${esc(dueLabel)}</span>`;
111
+ }
112
+ return `<span style="font-size:0.9rem;${style}">${esc(text)}${dueBadge}</span>`;
113
+ }
114
+ },
115
+ {
116
+ key: 'status',
117
+ title: 'Status',
118
+ sortable: true,
119
+ render: (status, row) => {
120
+ const opts = [
121
+ {v: 'not-started', l: 'Not Started'},
122
+ {v: 'in-progress', l: 'In Progress'},
123
+ {v: 'completed', l: 'Completed'}
124
+ ].map(({v, l}) =>
125
+ `<option value="${v}"${status === v ? ' selected' : ''}>${l}</option>`
126
+ ).join('');
127
+ return `<select class="todo-select-pill status-select status-pill-${esc(status)}" data-id="${esc(row.id)}">${opts}</select>`;
128
+ }
129
+ },
130
+ {
131
+ key: 'priority',
132
+ title: 'Priority',
133
+ sortable: true,
134
+ render: (priority, row) => {
135
+ const opts = [
136
+ {v: 'highest', l: 'Highest'},
137
+ {v: 'high', l: 'High'},
138
+ {v: 'medium', l: 'Medium'},
139
+ {v: 'low', l: 'Low'},
140
+ {v: 'lowest', l: 'Lowest'}
141
+ ].map(({v, l}) =>
142
+ `<option value="${v}"${priority === v ? ' selected' : ''}>${l}</option>`
143
+ ).join('');
144
+ return `<select class="todo-select-pill priority-select priority-pill-${esc(priority)}" data-id="${esc(row.id)}">${opts}</select>`;
145
+ }
146
+ },
147
+ {
148
+ key: 'id',
149
+ title: 'Actions',
150
+ sortable: false,
151
+ render: (id) =>
152
+ `<button class="btn btn-sm btn-ghost edit-todo" data-id="${esc(id)}" title="Edit" style="margin-right:3px;">` +
153
+ `<span data-icon="edit" data-icon-size="13"></span></button>` +
154
+ `<button class="btn btn-sm btn-danger delete-todo" data-id="${esc(id)}" title="Delete">` +
155
+ `<span data-icon="trash" data-icon-size="13"></span></button>`
156
+ }
157
+ ],
158
+ emptyMessage: 'No tasks yet. Add one above to get started.'
159
+ });
160
+
161
+ // Event delegation on the table container
162
+ const tableContainer = $container.find('#todo-table').get(0);
163
+
164
+ tableContainer.addEventListener('change', (e) => {
165
+ const target = e.target;
166
+ const id = target.dataset.id;
167
+ if (!id) return;
168
+
169
+ if (target.classList.contains('todo-checkbox')) {
170
+ toggleTodo(id);
171
+ } else if (target.classList.contains('status-select')) {
172
+ target.className = target.className.replace(/status-pill-\S+/g, `status-pill-${target.value}`);
173
+ updateStatus(id, target.value);
174
+ } else if (target.classList.contains('priority-select')) {
175
+ target.className = target.className.replace(/priority-pill-\S+/g, `priority-pill-${target.value}`);
176
+ updatePriority(id, target.value);
109
177
  }
178
+ });
110
179
 
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);
180
+ tableContainer.addEventListener('click', (e) => {
181
+ const editBtn = e.target.closest('.edit-todo');
182
+ const deleteBtn = e.target.closest('.delete-todo');
183
+ if (editBtn) editTodo(editBtn.dataset.id);
184
+ if (deleteBtn) deleteTodo(deleteBtn.dataset.id);
185
+ });
141
186
  }
142
187
 
143
188
  // ---- Add todo ----
@@ -167,7 +212,7 @@ export const todoView = {
167
212
  if ($due.get(0)) $due.get(0).value = '';
168
213
  render();
169
214
  E.toast('Task added', { type: 'success' });
170
- } catch (err) {
215
+ } catch {
171
216
  E.toast('Failed to add task', { type: 'error' });
172
217
  }
173
218
  }
@@ -183,7 +228,7 @@ export const todoView = {
183
228
  Object.assign(todo, updated);
184
229
  render();
185
230
  E.toast(newStatus === STATUS.COMPLETED ? 'Task completed' : 'Task reopened', { type: 'success' });
186
- } catch (err) {
231
+ } catch {
187
232
  E.toast('Failed to update task', { type: 'error' });
188
233
  }
189
234
  }
@@ -201,7 +246,7 @@ export const todoView = {
201
246
  Object.assign(todo, updated);
202
247
  render();
203
248
  E.toast('Task updated', { type: 'success' });
204
- } catch (err) {
249
+ } catch {
205
250
  E.toast('Failed to update task', { type: 'error' });
206
251
  }
207
252
  }
@@ -216,7 +261,7 @@ export const todoView = {
216
261
  todos = todos.filter((t) => t.id !== id);
217
262
  render();
218
263
  E.toast('Task deleted', { type: 'success' });
219
- } catch (err) {
264
+ } catch {
220
265
  E.toast('Failed to delete task', { type: 'error' });
221
266
  }
222
267
  }
@@ -231,7 +276,7 @@ export const todoView = {
231
276
  Object.assign(todo, updated);
232
277
  render();
233
278
  E.toast(`Status: ${formatStatus(status)}`, { type: 'success' });
234
- } catch (err) {
279
+ } catch {
235
280
  E.toast('Failed to update status', { type: 'error' });
236
281
  }
237
282
  }
@@ -246,7 +291,7 @@ export const todoView = {
246
291
  Object.assign(todo, updated);
247
292
  render();
248
293
  E.toast(`Priority: ${formatPriority(priority)}`, { type: 'success' });
249
- } catch (err) {
294
+ } catch {
250
295
  E.toast('Failed to update priority', { type: 'error' });
251
296
  }
252
297
  }
@@ -267,7 +312,7 @@ export const todoView = {
267
312
  todos = todos.filter((t) => t.status !== STATUS.COMPLETED);
268
313
  render();
269
314
  E.toast('Completed tasks cleared', { type: 'success' });
270
- } catch (err) {
315
+ } catch {
271
316
  E.toast('Failed to clear completed tasks', { type: 'error' });
272
317
  }
273
318
  }
@@ -282,7 +327,6 @@ export const todoView = {
282
327
 
283
328
  $container.find('#clear-completed-btn').get(0).addEventListener('click', clearCompleted);
284
329
 
285
- // Filter buttons — direct addEventListener to avoid namespaced delegation issues
286
330
  $container.find('#filter-bar').get(0).querySelectorAll('.filter-btn').forEach((btn) => {
287
331
  btn.addEventListener('click', () => {
288
332
  activeFilter = btn.dataset.filter;
@@ -292,34 +336,6 @@ export const todoView = {
292
336
  });
293
337
  });
294
338
 
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
339
  // ---- Initial load ----
324
340
  await loadTodos();
325
341
  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);