kempo-media 0.2.3
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/.env.example +1 -0
- package/.gitattributes +5 -0
- package/.github/workflows/publish.yml +85 -0
- package/.github/workflows/test.yml +123 -0
- package/CHANGELOG.md +25 -0
- package/README.md +114 -0
- package/admin/asset-picker.global.html +63 -0
- package/admin/components/MediaLibrary.js +304 -0
- package/admin/icons/archive.svg +1 -0
- package/admin/icons/audio.svg +1 -0
- package/admin/icons/image.svg +1 -0
- package/admin/icons/model3d.svg +1 -0
- package/admin/icons/video.svg +1 -0
- package/admin/index.page.html +35 -0
- package/admin/nav.global.html +3 -0
- package/docker-compose.yml +22 -0
- package/drizzle.config.js +26 -0
- package/install.js +18 -0
- package/kempo-config.json +18 -0
- package/package.json +39 -0
- package/public/api/media/DELETE.js +21 -0
- package/public/api/media/GET.js +38 -0
- package/public/api/media/PATCH.js +18 -0
- package/public/api/media/POST.js +57 -0
- package/public/sdk.js +62 -0
- package/scripts/build.js +0 -0
- package/sdk.js +15 -0
- package/server/db/schema.js +18 -0
- package/server/utils/media/createMediaAsset.js +19 -0
- package/server/utils/media/deleteMediaAsset.js +37 -0
- package/server/utils/media/getMediaAsset.js +14 -0
- package/server/utils/media/getMediaAssets.js +39 -0
- package/server/utils/media/updateMediaAsset.js +32 -0
- package/server/utils/uploads/fileTypes.js +50 -0
- package/server/utils/uploads/generateThumbnail.js +63 -0
- package/server/utils/uploads/parseMultipart.js +99 -0
- package/server/utils/uploads/storeUpload.js +89 -0
- package/tests/browser-fixtures.node-test.js +61 -0
- package/tests/browser-modules-parse.node-test.js +62 -0
- package/tests/components/MediaLibrary.browser-test.js +412 -0
- package/tests/fixtures/kempo-media-sdk-mock/sdk.js +16 -0
- package/tests/media-api-contract.node-test.js +363 -0
- package/tests/media-data-layer.node-test.js +268 -0
- package/tests/module-graph.node-test.js +86 -0
- package/tests/package-invariants.node-test.js +247 -0
- package/tests/permission-name-consistency.node-test.js +102 -0
- package/tests/uploads.node-test.js +255 -0
- package/update.js +3 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';
|
|
2
|
+
import { html, css } from '/kempo-ui/lit-all.min.js';
|
|
3
|
+
import Toast from '/kempo-ui/components/Toast.js';
|
|
4
|
+
import Dialog from '/kempo-ui/components/Dialog.js';
|
|
5
|
+
import { getMediaAssets, uploadMedia, deleteMediaAsset } from '/kempo-media/sdk.js';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
The media library, used in two places: the admin screen at /admin/extension/kempo-media, and the
|
|
9
|
+
picker dialog the editor opens through window.kempo.openAssetPicker. Both want the same grid of
|
|
10
|
+
thumbnails with the same upload affordance, so it lives here once and the picker sets
|
|
11
|
+
`selectable`.
|
|
12
|
+
|
|
13
|
+
A grid rather than a k-table: the whole point of a media library is recognising an image by
|
|
14
|
+
looking at it, and a table row of filenames does not do that.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const BASE = '/admin/extension/kempo-media';
|
|
18
|
+
const PAGE_SIZE = 24;
|
|
19
|
+
|
|
20
|
+
const formatBytes = bytes => {
|
|
21
|
+
if(bytes < 1024) return `${bytes} B`;
|
|
22
|
+
if(bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
23
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default class MediaLibrary extends ShadowComponent {
|
|
27
|
+
static properties = {
|
|
28
|
+
kind: { type: String },
|
|
29
|
+
selectable: { type: Boolean },
|
|
30
|
+
canUpload: { type: Boolean, attribute: 'can-upload' },
|
|
31
|
+
canDelete: { type: Boolean, attribute: 'can-delete' },
|
|
32
|
+
assets: { type: Array, state: true },
|
|
33
|
+
total: { type: Number, state: true },
|
|
34
|
+
offset: { type: Number, state: true },
|
|
35
|
+
search: { type: String, state: true },
|
|
36
|
+
loading: { type: Boolean, state: true },
|
|
37
|
+
dragging: { type: Boolean, state: true },
|
|
38
|
+
uploads: { type: Array, state: true },
|
|
39
|
+
selectedId: { type: String, state: true },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
constructor() {
|
|
43
|
+
super();
|
|
44
|
+
this.kind = '';
|
|
45
|
+
this.selectable = false;
|
|
46
|
+
this.canUpload = false;
|
|
47
|
+
this.canDelete = false;
|
|
48
|
+
this.assets = [];
|
|
49
|
+
this.total = 0;
|
|
50
|
+
this.offset = 0;
|
|
51
|
+
this.search = '';
|
|
52
|
+
this.loading = true;
|
|
53
|
+
this.dragging = false;
|
|
54
|
+
this.uploads = [];
|
|
55
|
+
this.selectedId = '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
connectedCallback() {
|
|
59
|
+
super.connectedCallback();
|
|
60
|
+
this.load();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
load = async () => {
|
|
64
|
+
this.loading = true;
|
|
65
|
+
const [error, data] = await getMediaAssets({
|
|
66
|
+
kind: this.kind || undefined,
|
|
67
|
+
search: this.search || undefined,
|
|
68
|
+
limit: PAGE_SIZE,
|
|
69
|
+
offset: this.offset,
|
|
70
|
+
});
|
|
71
|
+
this.loading = false;
|
|
72
|
+
if(error){
|
|
73
|
+
Toast.error(`Could not load the media library: ${error.msg}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.assets = data.assets;
|
|
77
|
+
this.total = data.total;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
goTo = offset => {
|
|
81
|
+
this.offset = Math.max(0, offset);
|
|
82
|
+
this.load();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
handleSearch = e => {
|
|
86
|
+
// Re-running the query on every keystroke would fire a request per character
|
|
87
|
+
clearTimeout(this._searchTimer);
|
|
88
|
+
const value = e.target.value;
|
|
89
|
+
this._searchTimer = setTimeout(() => {
|
|
90
|
+
this.search = value;
|
|
91
|
+
this.offset = 0;
|
|
92
|
+
this.load();
|
|
93
|
+
}, 250);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/*
|
|
97
|
+
Uploads run one at a time rather than in parallel: bodies are buffered in memory server-side,
|
|
98
|
+
so firing off ten large files at once is the surest way to make the server run out of it.
|
|
99
|
+
*/
|
|
100
|
+
uploadFiles = async files => {
|
|
101
|
+
const queue = [...files];
|
|
102
|
+
if(!queue.length) return;
|
|
103
|
+
|
|
104
|
+
this.uploads = queue.map(file => ({ name: file.name, progress: 0, failed: false }));
|
|
105
|
+
|
|
106
|
+
for(const [index, file] of queue.entries()){
|
|
107
|
+
const [error] = await uploadMedia(file, {}, progress => {
|
|
108
|
+
this.uploads = this.uploads.map((u, i) => i === index ? { ...u, progress } : u);
|
|
109
|
+
});
|
|
110
|
+
if(error){
|
|
111
|
+
this.uploads = this.uploads.map((u, i) => i === index ? { ...u, failed: true } : u);
|
|
112
|
+
Toast.error(`${file.name}: ${error.msg}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const failed = this.uploads.filter(u => u.failed).length;
|
|
117
|
+
const succeeded = queue.length - failed;
|
|
118
|
+
if(succeeded) Toast.success(succeeded === 1 ? 'Uploaded' : `Uploaded ${succeeded} files`);
|
|
119
|
+
|
|
120
|
+
this.uploads = [];
|
|
121
|
+
this.offset = 0;
|
|
122
|
+
await this.load();
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
handleFileInput = e => {
|
|
126
|
+
this.uploadFiles(e.target.files);
|
|
127
|
+
e.target.value = ''; // so picking the same file twice in a row still fires a change event
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
handleDrop = e => {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
this.dragging = false;
|
|
133
|
+
if(this.canUpload) this.uploadFiles(e.dataTransfer.files);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
handleDelete = (asset, e) => {
|
|
137
|
+
e.stopPropagation(); // a tile click selects; the delete button must not do both
|
|
138
|
+
Dialog.confirm(
|
|
139
|
+
`Delete “${asset.originalName}”? Anything already linking to it will break.`,
|
|
140
|
+
async confirmed => {
|
|
141
|
+
if(!confirmed) return;
|
|
142
|
+
const [error] = await deleteMediaAsset(asset.id);
|
|
143
|
+
if(error) return Toast.error(`Could not delete: ${error.msg}`);
|
|
144
|
+
Toast.success('Deleted');
|
|
145
|
+
// Step back a page when the last item on this one is gone
|
|
146
|
+
if(this.assets.length === 1 && this.offset > 0) this.offset -= PAGE_SIZE;
|
|
147
|
+
this.load();
|
|
148
|
+
},
|
|
149
|
+
{ title: 'Delete asset', confirmText: 'Delete' }
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
handleSelect = asset => {
|
|
154
|
+
if(!this.selectable) return;
|
|
155
|
+
this.selectedId = asset.id;
|
|
156
|
+
this.dispatchEvent(new CustomEvent('asset-selected', { detail: { asset }, bubbles: true, composed: true }));
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
get selectedAsset() {
|
|
160
|
+
return this.assets.find(a => a.id === this.selectedId) || null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
renderTile(asset) {
|
|
164
|
+
const url = `/${asset.path}`;
|
|
165
|
+
return html`
|
|
166
|
+
<div
|
|
167
|
+
class="tile ${this.selectedId === asset.id ? 'selected' : ''} ${this.selectable ? 'selectable' : ''}"
|
|
168
|
+
@click=${() => this.handleSelect(asset)}
|
|
169
|
+
@dblclick=${() => this.selectable && this.dispatchEvent(new CustomEvent('asset-confirmed', { detail: { asset }, bubbles: true, composed: true }))}
|
|
170
|
+
>
|
|
171
|
+
<div class="preview">
|
|
172
|
+
${asset.thumbnailPath
|
|
173
|
+
? html`<img src="/${asset.thumbnailPath}" alt=${asset.altText || asset.originalName} loading="lazy" />`
|
|
174
|
+
: html`<img class="type-icon" src="${BASE}/icons/${asset.kind}.svg" alt=${asset.kind} />`}
|
|
175
|
+
</div>
|
|
176
|
+
<div class="meta">
|
|
177
|
+
<span class="name" title=${asset.originalName}>${asset.originalName}</span>
|
|
178
|
+
<span class="sub">${formatBytes(asset.sizeBytes)}${asset.width ? ` · ${asset.width}×${asset.height}` : ''}</span>
|
|
179
|
+
</div>
|
|
180
|
+
<div class="actions">
|
|
181
|
+
<a class="open" href=${url} target="_blank" rel="noopener" title="Open in a new tab" @click=${e => e.stopPropagation()}>↗</a>
|
|
182
|
+
${this.canDelete ? html`<button type="button" class="delete" title="Delete" @click=${e => this.handleDelete(asset, e)}>✕</button>` : ''}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
render() {
|
|
189
|
+
const page = Math.floor(this.offset / PAGE_SIZE) + 1;
|
|
190
|
+
const pages = Math.max(1, Math.ceil(this.total / PAGE_SIZE));
|
|
191
|
+
|
|
192
|
+
return html`
|
|
193
|
+
<div
|
|
194
|
+
class="library ${this.dragging ? 'dragging' : ''}"
|
|
195
|
+
@dragover=${e => { e.preventDefault(); if(this.canUpload) this.dragging = true; }}
|
|
196
|
+
@dragleave=${() => this.dragging = false}
|
|
197
|
+
@drop=${this.handleDrop}
|
|
198
|
+
>
|
|
199
|
+
<div class="toolbar">
|
|
200
|
+
<input class="search" type="search" placeholder="Search by name…" @input=${this.handleSearch} />
|
|
201
|
+
<!-- .value keeps the control honest when kind is set from outside, as the picker does -->
|
|
202
|
+
<select class="kind" .value=${this.kind} @change=${e => { this.kind = e.target.value; this.offset = 0; this.load(); }}>
|
|
203
|
+
<option value="">All types</option>
|
|
204
|
+
<option value="image">Images</option>
|
|
205
|
+
<option value="video">Video</option>
|
|
206
|
+
<option value="audio">Audio</option>
|
|
207
|
+
<option value="model3d">3D models</option>
|
|
208
|
+
<option value="archive">Archives</option>
|
|
209
|
+
</select>
|
|
210
|
+
${this.canUpload ? html`
|
|
211
|
+
<label class="upload-button">
|
|
212
|
+
Upload
|
|
213
|
+
<input type="file" multiple @change=${this.handleFileInput} />
|
|
214
|
+
</label>
|
|
215
|
+
` : ''}
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
${this.uploads.length ? html`
|
|
219
|
+
<div class="uploads">
|
|
220
|
+
${this.uploads.map(u => html`
|
|
221
|
+
<div class="upload ${u.failed ? 'failed' : ''}">
|
|
222
|
+
<span class="upload-name">${u.name}</span>
|
|
223
|
+
<progress value=${u.progress} max="1"></progress>
|
|
224
|
+
</div>
|
|
225
|
+
`)}
|
|
226
|
+
</div>
|
|
227
|
+
` : ''}
|
|
228
|
+
|
|
229
|
+
${this.loading
|
|
230
|
+
? html`<p class="empty">Loading…</p>`
|
|
231
|
+
: this.assets.length
|
|
232
|
+
? html`<div class="grid">${this.assets.map(a => this.renderTile(a))}</div>`
|
|
233
|
+
: html`<p class="empty">
|
|
234
|
+
${this.search || this.kind ? 'Nothing matches that filter.' : 'No media yet.'}
|
|
235
|
+
${this.canUpload ? html`<br />Drop files here or use the Upload button.` : ''}
|
|
236
|
+
</p>`}
|
|
237
|
+
|
|
238
|
+
${pages > 1 ? html`
|
|
239
|
+
<div class="pager">
|
|
240
|
+
<button type="button" ?disabled=${this.offset === 0} @click=${() => this.goTo(this.offset - PAGE_SIZE)}>Previous</button>
|
|
241
|
+
<span>Page ${page} of ${pages} · ${this.total} item${this.total === 1 ? '' : 's'}</span>
|
|
242
|
+
<button type="button" ?disabled=${page >= pages} @click=${() => this.goTo(this.offset + PAGE_SIZE)}>Next</button>
|
|
243
|
+
</div>
|
|
244
|
+
` : ''}
|
|
245
|
+
</div>
|
|
246
|
+
`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
static styles = css`
|
|
250
|
+
:host { display: block; }
|
|
251
|
+
.library { position: relative; border-radius: var(--radius, 0.25rem); }
|
|
252
|
+
.library.dragging { outline: 2px dashed var(--c_primary, #06c); outline-offset: 0.5rem; }
|
|
253
|
+
|
|
254
|
+
/*
|
|
255
|
+
kempo-css is linked into this shadow root by ShadowComponent, and its global form-control rule
|
|
256
|
+
-- input:not([type=button]):not(...), select -- carries far more specificity than a plain
|
|
257
|
+
class selector here. It sets width 100%, which pushes every control onto its own row, and it
|
|
258
|
+
overrides display none on the hidden file input. Beating it needs !important; matching that
|
|
259
|
+
selector's specificity by hand would break the moment kempo-css edits it.
|
|
260
|
+
*/
|
|
261
|
+
.toolbar { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
|
262
|
+
.search, .kind { padding: 0.5rem; border: 1px solid var(--c_border, #ccc); border-radius: var(--radius, 0.25rem); font: inherit; }
|
|
263
|
+
.toolbar .search { flex: 1 1 12rem; width: auto !important; min-width: 0; }
|
|
264
|
+
.toolbar .kind { flex: 0 0 auto; width: auto !important; }
|
|
265
|
+
|
|
266
|
+
.upload-button { display: inline-flex; align-items: center; padding: 0.5rem 1rem; border-radius: var(--radius, 0.25rem);
|
|
267
|
+
background: var(--c_primary, #06c); color: #fff; cursor: pointer; font: inherit; white-space: nowrap; }
|
|
268
|
+
.upload-button input { display: none !important; }
|
|
269
|
+
|
|
270
|
+
.uploads { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 1rem; }
|
|
271
|
+
.upload { display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; align-items: center; font-size: 0.875rem; }
|
|
272
|
+
.upload progress { width: 10rem; }
|
|
273
|
+
.upload.failed .upload-name { text-decoration: line-through; opacity: 0.6; }
|
|
274
|
+
|
|
275
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); gap: 0.75rem; }
|
|
276
|
+
|
|
277
|
+
.tile { position: relative; border: 1px solid var(--c_border, #ccc); border-radius: var(--radius, 0.25rem); overflow: hidden; background: var(--c_bg, #fff); }
|
|
278
|
+
.tile.selectable { cursor: pointer; }
|
|
279
|
+
.tile.selectable:hover { border-color: var(--c_primary, #06c); }
|
|
280
|
+
.tile.selected { border-color: var(--c_primary, #06c); box-shadow: 0 0 0 2px var(--c_primary, #06c); }
|
|
281
|
+
|
|
282
|
+
.preview { aspect-ratio: 4 / 3; display: grid; place-items: center; background: var(--c_bg_alt, #f4f4f4); overflow: hidden; }
|
|
283
|
+
.preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
|
284
|
+
.preview .type-icon { width: 3rem; height: 3rem; opacity: 0.5; }
|
|
285
|
+
|
|
286
|
+
.meta { display: flex; flex-direction: column; padding: 0.5rem; gap: 0.125rem; min-width: 0; }
|
|
287
|
+
.name { font-size: 0.8125rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
288
|
+
.sub { font-size: 0.75rem; opacity: 0.65; }
|
|
289
|
+
|
|
290
|
+
.actions { position: absolute; top: 0.25rem; right: 0.25rem; display: flex; gap: 0.25rem; opacity: 0; transition: opacity 0.1s; }
|
|
291
|
+
.tile:hover .actions, .tile:focus-within .actions { opacity: 1; }
|
|
292
|
+
.actions a, .actions button { display: grid; place-items: center; width: 1.5rem; height: 1.5rem; padding: 0;
|
|
293
|
+
border: none; border-radius: var(--radius, 0.25rem); background: rgba(0,0,0,0.6); color: #fff;
|
|
294
|
+
cursor: pointer; font: inherit; font-size: 0.75rem; text-decoration: none; }
|
|
295
|
+
.actions .delete:hover { background: var(--c_danger, #c00); }
|
|
296
|
+
|
|
297
|
+
.empty { padding: 2rem; text-align: center; opacity: 0.7; }
|
|
298
|
+
.pager { display: flex; gap: 1rem; align-items: center; justify-content: center; margin-top: 1rem; font-size: 0.875rem; }
|
|
299
|
+
.pager button { padding: 0.4rem 0.8rem; border: 1px solid var(--c_border, #ccc); border-radius: var(--radius, 0.25rem); cursor: pointer; font: inherit; }
|
|
300
|
+
.pager button[disabled] { opacity: 0.5; cursor: default; }
|
|
301
|
+
`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
customElements.define('kempo-media-library', MediaLibrary);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="5" rx="1"/><path d="M5 8v11a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8"/><path d="M11 12h2v3h-2z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l10-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="16" cy="16" r="3"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9.5" r="1.5"/><path d="m21 16-5-5L5 20"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m12 2 9 5v10l-9 5-9-5V7z"/><path d="m3 7 9 5 9-5M12 12v10"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="14" height="14" rx="2"/><path d="m16 10 6-3v10l-6-3z"/></svg>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
locked: true
|
|
3
|
+
-->
|
|
4
|
+
<page title="Media - Admin">
|
|
5
|
+
<content>
|
|
6
|
+
<script type="module" src="/kempo/components/Permission.js"></script>
|
|
7
|
+
<script type="module" src="/admin/extension/kempo-media/components/MediaLibrary.js"></script>
|
|
8
|
+
|
|
9
|
+
<div class="p">
|
|
10
|
+
<h1>Media</h1>
|
|
11
|
+
|
|
12
|
+
<k-permission has="media:browse">
|
|
13
|
+
<kempo-media-library id="library"></kempo-media-library>
|
|
14
|
+
</k-permission>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<script type="module">
|
|
18
|
+
import { getAllCurrentUserPermissions } from '/kempo/sdk.js';
|
|
19
|
+
|
|
20
|
+
/*
|
|
21
|
+
canUpload / canDelete only decide whether the buttons are drawn. Every one of these
|
|
22
|
+
permissions is enforced again server-side on the route itself, so a hidden button is a
|
|
23
|
+
courtesy rather than the boundary.
|
|
24
|
+
*/
|
|
25
|
+
const [error, data] = await getAllCurrentUserPermissions();
|
|
26
|
+
const permissions = error ? [] : (data?.permissions || []);
|
|
27
|
+
|
|
28
|
+
const library = document.getElementById('library');
|
|
29
|
+
if(library){
|
|
30
|
+
library.canUpload = permissions.includes('media:upload');
|
|
31
|
+
library.canDelete = permissions.includes('media:delete');
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
</content>
|
|
35
|
+
</page>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Database for kempo-media's own test suite. kempo's own tests use 5434 and kempo-demo uses 5433 —
|
|
2
|
+
# this deliberately uses 5435 so all three can be up at once.
|
|
3
|
+
services:
|
|
4
|
+
postgres:
|
|
5
|
+
image: postgres:16-alpine
|
|
6
|
+
container_name: kempo-media-test-db
|
|
7
|
+
restart: unless-stopped
|
|
8
|
+
environment:
|
|
9
|
+
POSTGRES_USER: kempo
|
|
10
|
+
POSTGRES_PASSWORD: kempo_media_test_password
|
|
11
|
+
POSTGRES_DB: kempo_media_test
|
|
12
|
+
ports:
|
|
13
|
+
- "5435:5432"
|
|
14
|
+
volumes:
|
|
15
|
+
- postgres_test_data:/var/lib/postgresql/data
|
|
16
|
+
healthcheck:
|
|
17
|
+
test: ["CMD-SHELL", "pg_isready -U kempo"]
|
|
18
|
+
interval: 10s
|
|
19
|
+
timeout: 5s
|
|
20
|
+
retries: 5
|
|
21
|
+
volumes:
|
|
22
|
+
postgres_test_data:
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { defineConfig } from 'drizzle-kit';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Local/CI test-database setup only — this extension has no migrations of its own in production.
|
|
5
|
+
kempoMediaAsset is created the real way, via createTablesFromSchema at install time (see
|
|
6
|
+
install.js and installExtension in kempo's sdk), which the data-layer and API-contract test
|
|
7
|
+
suites exercise directly. This file exists so `npx drizzle-kit push` can stand up a throwaway
|
|
8
|
+
database with both kempo's core tables (user/session/permission/...) and this extension's own,
|
|
9
|
+
mirroring the schema array kempo's own app-drizzle.config.js template scaffolds into a real site.
|
|
10
|
+
|
|
11
|
+
No dotenv here on purpose: unlike a real kempo site, nothing in this repo's own runtime loads
|
|
12
|
+
.env either, so pulling it in only for this file would be one more dependency to explain rather
|
|
13
|
+
than one less step. DATABASE_URL needs to be a real environment variable — export it, or prefix
|
|
14
|
+
the command — in every documented workflow (CI's `env:` block, this repo's own README).
|
|
15
|
+
*/
|
|
16
|
+
export default defineConfig({
|
|
17
|
+
schema: [
|
|
18
|
+
'./server/db/schema.js',
|
|
19
|
+
'./node_modules/kempo/server/db/schema.js',
|
|
20
|
+
],
|
|
21
|
+
out: './server/db/migrations',
|
|
22
|
+
dialect: 'postgresql',
|
|
23
|
+
dbCredentials: {
|
|
24
|
+
url: process.env.DATABASE_URL,
|
|
25
|
+
},
|
|
26
|
+
});
|
package/install.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { mkdir } from 'fs/promises';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Uploads are written into the site's own public/ directory rather than anywhere inside this
|
|
6
|
+
package, so they survive `npm install` and are served by kempo-server's static file scanner —
|
|
7
|
+
which means binary uploads get HTTP range support (video and audio seeking) for free.
|
|
8
|
+
|
|
9
|
+
The file types this extension accepts are registered in kempo-server's default allowedMimes as of
|
|
10
|
+
3.2.0, so there is nothing for the site operator to configure.
|
|
11
|
+
*/
|
|
12
|
+
export default async () => {
|
|
13
|
+
const rootDir = join(process.cwd(), 'public');
|
|
14
|
+
|
|
15
|
+
await mkdir(join(rootDir, 'media', 'uploads'), { recursive: true });
|
|
16
|
+
await mkdir(join(rootDir, 'media', 'thumbnails'), { recursive: true });
|
|
17
|
+
console.log('[kempo-media] Created public/media/uploads and public/media/thumbnails.');
|
|
18
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"docs": "https://github.com/dustinpoissant/kempo-media#readme",
|
|
3
|
+
"public-scope": "kempo-media",
|
|
4
|
+
"schema": "./server/db/schema.js",
|
|
5
|
+
"permissions": [
|
|
6
|
+
{ "name": "media:browse", "description": "List and view media library assets" },
|
|
7
|
+
{ "name": "media:upload", "description": "Upload new media assets" },
|
|
8
|
+
{ "name": "media:update", "description": "Edit alt text / rename existing assets" },
|
|
9
|
+
{ "name": "media:delete", "description": "Delete media assets" }
|
|
10
|
+
],
|
|
11
|
+
"settings": [
|
|
12
|
+
{ "name": "max_upload_size_mb", "value": "250", "type": "number", "description": "Maximum size, in MB, of a single uploaded file" },
|
|
13
|
+
{ "name": "thumbnail_max_dimension", "value": "480", "type": "number", "description": "Longest edge, in pixels, of generated image thumbnails" }
|
|
14
|
+
],
|
|
15
|
+
"groups": [
|
|
16
|
+
{ "name": "kempo-media:editor", "description": "Can browse, upload, and edit media", "permissions": ["media:browse", "media:upload", "media:update"] }
|
|
17
|
+
]
|
|
18
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kempo-media",
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "Media library extension for kempo — upload and manage images, video, audio, 3D models, and archives",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Dustin Poissant",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "sdk.js",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "node scripts/build.js",
|
|
11
|
+
"test": "kempo-test -l minimal",
|
|
12
|
+
"link:local": "npm install ../kempo ../kempo-ui --no-save"
|
|
13
|
+
},
|
|
14
|
+
"kempo": {
|
|
15
|
+
"public-scope": "kempo-media"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
"./install": "./install.js",
|
|
19
|
+
"./update": "./update.js",
|
|
20
|
+
"./sdk": "./sdk.js"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/dustinpoissant/kempo-media.git"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"drizzle-orm": "^0.45.2",
|
|
28
|
+
"sharp": "^0.35.3"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"kempo": ">=4.2.26",
|
|
32
|
+
"kempo-server": ">=3.2.0",
|
|
33
|
+
"kempo-ui": ">=0.4.43"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"drizzle-kit": "^0.31.10",
|
|
37
|
+
"kempo-testing-framework": "^1.4.11"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { currentUserHasPermission } from 'kempo/server/sdk.js';
|
|
3
|
+
import deleteMediaAsset from '../../../server/utils/media/deleteMediaAsset.js';
|
|
4
|
+
|
|
5
|
+
const ROOT_DIR = join(process.cwd(), 'public');
|
|
6
|
+
|
|
7
|
+
export default async (request, response) => {
|
|
8
|
+
const token = request.cookies.session_token;
|
|
9
|
+
|
|
10
|
+
const [permError, canDelete] = await currentUserHasPermission(token, 'media:delete');
|
|
11
|
+
if(permError) return response.status(permError.code).json({ error: permError.msg });
|
|
12
|
+
if(!canDelete) return response.status(403).json({ error: 'Insufficient permissions' });
|
|
13
|
+
|
|
14
|
+
const { id } = request.body || {};
|
|
15
|
+
if(!id) return response.status(400).json({ error: 'An asset id is required' });
|
|
16
|
+
|
|
17
|
+
const [error, data] = await deleteMediaAsset(id, { rootDir: ROOT_DIR });
|
|
18
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
19
|
+
|
|
20
|
+
response.json(data);
|
|
21
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { currentUserHasPermission } from 'kempo/server/sdk.js';
|
|
2
|
+
import getMediaAssets from '../../../server/utils/media/getMediaAssets.js';
|
|
3
|
+
import getMediaAsset from '../../../server/utils/media/getMediaAsset.js';
|
|
4
|
+
|
|
5
|
+
export default async (request, response) => {
|
|
6
|
+
const token = request.cookies.session_token;
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
Browsing is gated even though the files themselves are public once uploaded — the listing is
|
|
10
|
+
what exposes every asset on the site at once, including ones nobody has linked yet.
|
|
11
|
+
*/
|
|
12
|
+
/*
|
|
13
|
+
permError carries the distinction between "not signed in" and "signed in without the
|
|
14
|
+
permission" — a missing or expired cookie is a 401 the client can fix by signing in again,
|
|
15
|
+
where a 403 says retrying will not help. Discarding it collapses the two.
|
|
16
|
+
*/
|
|
17
|
+
const [permError, canBrowse] = await currentUserHasPermission(token, 'media:browse');
|
|
18
|
+
if(permError) return response.status(permError.code).json({ error: permError.msg });
|
|
19
|
+
if(!canBrowse) return response.status(403).json({ error: 'Insufficient permissions' });
|
|
20
|
+
|
|
21
|
+
const { id, kind, search, limit, offset } = request.query;
|
|
22
|
+
|
|
23
|
+
if(id){
|
|
24
|
+
const [error, asset] = await getMediaAsset(id);
|
|
25
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
26
|
+
return response.json({ asset });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const [error, data] = await getMediaAssets({
|
|
30
|
+
kind,
|
|
31
|
+
search,
|
|
32
|
+
limit: parseInt(limit, 10) || 24,
|
|
33
|
+
offset: parseInt(offset, 10) || 0,
|
|
34
|
+
});
|
|
35
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
36
|
+
|
|
37
|
+
response.json(data);
|
|
38
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { currentUserHasPermission } from 'kempo/server/sdk.js';
|
|
2
|
+
import updateMediaAsset from '../../../server/utils/media/updateMediaAsset.js';
|
|
3
|
+
|
|
4
|
+
export default async (request, response) => {
|
|
5
|
+
const token = request.cookies.session_token;
|
|
6
|
+
|
|
7
|
+
const [permError, canUpdate] = await currentUserHasPermission(token, 'media:update');
|
|
8
|
+
if(permError) return response.status(permError.code).json({ error: permError.msg });
|
|
9
|
+
if(!canUpdate) return response.status(403).json({ error: 'Insufficient permissions' });
|
|
10
|
+
|
|
11
|
+
const { id, altText, originalName } = request.body || {};
|
|
12
|
+
if(!id) return response.status(400).json({ error: 'An asset id is required' });
|
|
13
|
+
|
|
14
|
+
const [error, asset] = await updateMediaAsset(id, { altText, originalName });
|
|
15
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
16
|
+
|
|
17
|
+
response.json({ asset });
|
|
18
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { getSession, currentUserHasPermission, getSetting } from 'kempo/server/sdk.js';
|
|
3
|
+
import parseMultipart, { extractBoundary } from '../../../server/utils/uploads/parseMultipart.js';
|
|
4
|
+
import storeUpload from '../../../server/utils/uploads/storeUpload.js';
|
|
5
|
+
import createMediaAsset from '../../../server/utils/media/createMediaAsset.js';
|
|
6
|
+
|
|
7
|
+
const ROOT_DIR = join(process.cwd(), 'public');
|
|
8
|
+
|
|
9
|
+
export default async (request, response) => {
|
|
10
|
+
const token = request.cookies.session_token;
|
|
11
|
+
const [sessionError, session] = await getSession({ token });
|
|
12
|
+
if(sessionError) return response.status(401).json({ error: 'Authentication required' });
|
|
13
|
+
|
|
14
|
+
const [permError, canUpload] = await currentUserHasPermission(token, 'media:upload');
|
|
15
|
+
if(permError) return response.status(permError.code).json({ error: permError.msg });
|
|
16
|
+
if(!canUpload) return response.status(403).json({ error: 'Insufficient permissions' });
|
|
17
|
+
|
|
18
|
+
const boundary = extractBoundary(request.headers['content-type']);
|
|
19
|
+
if(!boundary){
|
|
20
|
+
return response.status(400).json({ error: 'Expected a multipart/form-data upload' });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
request.buffer() rather than request.body: for a multipart content type kempo-server hands back
|
|
25
|
+
the body decoded as UTF-8 text, which mangles every byte of a binary file. buffer() is the only
|
|
26
|
+
accessor that preserves them (kempo-server >= 3.2.0).
|
|
27
|
+
*/
|
|
28
|
+
const raw = await request.buffer();
|
|
29
|
+
const parts = parseMultipart(raw, boundary);
|
|
30
|
+
|
|
31
|
+
const filePart = parts.find(p => p.filename);
|
|
32
|
+
if(!filePart){
|
|
33
|
+
return response.status(400).json({ error: 'No file was included in the upload' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const altPart = parts.find(p => p.name === 'alt' && !p.filename);
|
|
37
|
+
|
|
38
|
+
const [, maxUploadMb] = await getSetting('kempo-media', 'max_upload_size_mb', 250);
|
|
39
|
+
const [, thumbnailMaxDimension] = await getSetting('kempo-media', 'thumbnail_max_dimension', 480);
|
|
40
|
+
|
|
41
|
+
const [storeError, asset] = await storeUpload({
|
|
42
|
+
rootDir: ROOT_DIR,
|
|
43
|
+
filename: filePart.filename,
|
|
44
|
+
data: filePart.data,
|
|
45
|
+
mimeType: filePart.contentType,
|
|
46
|
+
altText: altPart ? altPart.data.toString('utf8') : '',
|
|
47
|
+
uploadedBy: session.user.id,
|
|
48
|
+
maxBytes: Number(maxUploadMb) * 1024 * 1024,
|
|
49
|
+
thumbnailMaxDimension: Number(thumbnailMaxDimension),
|
|
50
|
+
});
|
|
51
|
+
if(storeError) return response.status(storeError.code).json({ error: storeError.msg });
|
|
52
|
+
|
|
53
|
+
const [recordError, row] = await createMediaAsset(asset);
|
|
54
|
+
if(recordError) return response.status(recordError.code).json({ error: recordError.msg });
|
|
55
|
+
|
|
56
|
+
response.status(201).json({ asset: row });
|
|
57
|
+
};
|