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.
Files changed (48) hide show
  1. package/.env.example +1 -0
  2. package/.gitattributes +5 -0
  3. package/.github/workflows/publish.yml +85 -0
  4. package/.github/workflows/test.yml +123 -0
  5. package/CHANGELOG.md +25 -0
  6. package/README.md +114 -0
  7. package/admin/asset-picker.global.html +63 -0
  8. package/admin/components/MediaLibrary.js +304 -0
  9. package/admin/icons/archive.svg +1 -0
  10. package/admin/icons/audio.svg +1 -0
  11. package/admin/icons/image.svg +1 -0
  12. package/admin/icons/model3d.svg +1 -0
  13. package/admin/icons/video.svg +1 -0
  14. package/admin/index.page.html +35 -0
  15. package/admin/nav.global.html +3 -0
  16. package/docker-compose.yml +22 -0
  17. package/drizzle.config.js +26 -0
  18. package/install.js +18 -0
  19. package/kempo-config.json +18 -0
  20. package/package.json +39 -0
  21. package/public/api/media/DELETE.js +21 -0
  22. package/public/api/media/GET.js +38 -0
  23. package/public/api/media/PATCH.js +18 -0
  24. package/public/api/media/POST.js +57 -0
  25. package/public/sdk.js +62 -0
  26. package/scripts/build.js +0 -0
  27. package/sdk.js +15 -0
  28. package/server/db/schema.js +18 -0
  29. package/server/utils/media/createMediaAsset.js +19 -0
  30. package/server/utils/media/deleteMediaAsset.js +37 -0
  31. package/server/utils/media/getMediaAsset.js +14 -0
  32. package/server/utils/media/getMediaAssets.js +39 -0
  33. package/server/utils/media/updateMediaAsset.js +32 -0
  34. package/server/utils/uploads/fileTypes.js +50 -0
  35. package/server/utils/uploads/generateThumbnail.js +63 -0
  36. package/server/utils/uploads/parseMultipart.js +99 -0
  37. package/server/utils/uploads/storeUpload.js +89 -0
  38. package/tests/browser-fixtures.node-test.js +61 -0
  39. package/tests/browser-modules-parse.node-test.js +62 -0
  40. package/tests/components/MediaLibrary.browser-test.js +412 -0
  41. package/tests/fixtures/kempo-media-sdk-mock/sdk.js +16 -0
  42. package/tests/media-api-contract.node-test.js +363 -0
  43. package/tests/media-data-layer.node-test.js +268 -0
  44. package/tests/module-graph.node-test.js +86 -0
  45. package/tests/package-invariants.node-test.js +247 -0
  46. package/tests/permission-name-consistency.node-test.js +102 -0
  47. package/tests/uploads.node-test.js +255 -0
  48. 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,3 @@
1
+ <content id="kempo-media-nav" name="Media" owner="kempo-media" enabled="true" locked="true" location="admin-nav-extensions" priority="0">
2
+ <k-aside-item icon="image" href="/admin/extension/kempo-media/" no-expand>Media</k-aside-item>
3
+ </content>
@@ -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
+ };