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.
- package/admin/js/templates/block-editor.html +163 -163
- package/admin/js/templates/form-editor.html +245 -245
- package/admin/js/views/action-editor.js +1 -1
- package/admin/js/views/block-editor.js +8 -8
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/forms.js +1 -1
- package/admin/js/views/navigation.js +14 -14
- package/admin/js/views/page-editor.js +35 -35
- package/admin/js/views/pages.js +5 -5
- package/admin/js/views/plugins.js +19 -10
- package/admin/js/views/view-editor.js +1 -1
- package/config/plugins.json +35 -0
- package/package.json +1 -1
- package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +4 -4
- package/plugins/docs/data/folders.json +3 -3
- package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +5 -0
- package/plugins/garage/admin/templates/garage.html +30 -0
- package/plugins/garage/admin/views/garage.js +62 -1
- package/plugins/garage/plugin.json +1 -1
- package/plugins/notes/admin/templates/notes.html +2 -11
- package/plugins/notes/admin/views/notes.js +107 -129
- package/plugins/notes/collections/user-notes/schema.json +2 -1
- package/plugins/notes/plugin.json +1 -1
- package/plugins/site-search/admin/templates/site-search.html +174 -46
- package/plugins/site-search/admin/views/site-search.js +72 -1
- package/plugins/site-search/config.js +6 -1
- package/plugins/site-search/plugin.json +1 -1
- package/plugins/site-search/public/inject-head.html +1 -1
- package/plugins/site-search/public/search.css +1 -1
- package/plugins/site-search/public/search.js +1 -1
- package/plugins/todo/admin/templates/todo.html +2 -8
- package/plugins/todo/admin/views/todo.js +122 -106
- package/plugins/todo/collections/todos/schema.json +2 -1
- package/plugins/todo/plugin.json +1 -1
- package/server/routes/api/media.js +127 -118
- package/server/routes/api/plugins.js +15 -4
- package/server/server.js +288 -285
- package/server/services/collections.js +17 -10
- package/server/services/plugins.js +77 -67
- package/server/services/renderer.js +3 -3
- 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
|
|
4
|
-
// Uses
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
.replace(/"/g, '"')
|
|
37
|
-
.replace(/'/g, ''');
|
|
38
|
-
}
|
|
29
|
+
const esc = (str) => String(str ?? '')
|
|
30
|
+
.replace(/&/g, '&')
|
|
31
|
+
.replace(/</g, '<')
|
|
32
|
+
.replace(/>/g, '>')
|
|
33
|
+
.replace(/"/g, '"')
|
|
34
|
+
.replace(/'/g, ''');
|
|
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
|
|
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 (
|
|
84
|
-
|
|
85
|
-
listEl.textContent = '';
|
|
86
|
-
emptyEl.style.display = 'block';
|
|
77
|
+
if (todoTable) {
|
|
78
|
+
todoTable.setData(filtered);
|
|
87
79
|
return;
|
|
88
80
|
}
|
|
89
81
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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();
|
package/plugins/todo/plugin.json
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
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 {
|
|
8
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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);
|