kempo-media 0.2.3 → 0.3.0

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 (37) hide show
  1. package/.github/workflows/publish.yml +11 -12
  2. package/.github/workflows/test.yml +13 -2
  3. package/CHANGELOG.md +17 -0
  4. package/README.md +69 -23
  5. package/admin/asset-picker.global.html +7 -2
  6. package/admin/components/MediaLibrary.js +33 -7
  7. package/admin/nav.global.html +1 -1
  8. package/drizzle.config.js +7 -4
  9. package/kempo-config.json +2 -2
  10. package/package.json +10 -9
  11. package/public/api/media/DELETE.js +1 -4
  12. package/public/api/media/PATCH.js +2 -2
  13. package/public/api/media/POST.js +7 -16
  14. package/public/sdk.js +2 -1
  15. package/sdk.js +3 -1
  16. package/server/db/schema.js +6 -4
  17. package/server/utils/media/createMediaAsset.js +5 -4
  18. package/server/utils/media/deleteMediaAsset.js +17 -19
  19. package/server/utils/media/describeAssets.js +49 -0
  20. package/server/utils/media/getMediaAsset.js +4 -2
  21. package/server/utils/media/getMediaAssets.js +3 -2
  22. package/server/utils/media/mediaDirectory.js +29 -0
  23. package/server/utils/media/updateMediaAsset.js +31 -14
  24. package/server/utils/media/uploadMediaAsset.js +75 -0
  25. package/server/utils/migrate/legacyAssets.js +118 -0
  26. package/server/utils/uploads/probeDimensions.js +29 -0
  27. package/tests/components/MediaLibrary.browser-test.js +62 -3
  28. package/tests/fixtures/makePng.js +45 -0
  29. package/tests/media-api-contract.node-test.js +160 -53
  30. package/tests/media-data-layer.node-test.js +168 -54
  31. package/tests/media-migration.node-test.js +233 -0
  32. package/tests/module-graph.node-test.js +1 -1
  33. package/tests/uploads.node-test.js +5 -160
  34. package/update.js +20 -2
  35. package/install.js +0 -18
  36. package/server/utils/uploads/generateThumbnail.js +0 -63
  37. package/server/utils/uploads/storeUpload.js +0 -89
@@ -33,7 +33,8 @@ jobs:
33
33
 
34
34
  - uses: actions/setup-node@v4
35
35
  with:
36
- node-version: '20.x'
36
+ node-version: '22.x'
37
+ registry-url: 'https://registry.npmjs.org'
37
38
 
38
39
  - run: npm ci
39
40
 
@@ -41,11 +42,12 @@ jobs:
41
42
  run: |
42
43
  npm run build
43
44
 
44
- # kempo/kempo-ui/kempo-server are peer dependencies and are not installed by npm ci above.
45
+ # kempo, kempo-files, kempo-thumbs, kempo-ui and kempo-server are peer dependencies and are not
46
+ # installed by npm ci above.
45
47
  # The published release is the right one to test against: it is what a site installing this
46
48
  # extension will actually pair it with.
47
49
  - name: Install the host for the test gate
48
- run: npm install --no-save kempo@latest kempo-ui@latest kempo-server@latest
50
+ run: npm install --no-save kempo@latest kempo-files@latest kempo-thumbs@latest kempo-ui@latest kempo-server@latest
49
51
 
50
52
  - name: Run Unit Tests
51
53
  run: npm test
@@ -72,14 +74,11 @@ jobs:
72
74
  env:
73
75
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
74
76
 
75
- - name: Setup .npmrc for OIDC
76
- run: |
77
- cat << EOF > ~/.npmrc
78
- //registry.npmjs.org/:_authToken=\${NPM_TOKEN}
79
- registry=https://registry.npmjs.org/
80
- always-auth=true
81
- EOF
77
+ # Trusted publishing needs npm >=11.5.1; Node 22.x still bundles 10.9.x. Without this, OIDC
78
+ # publish auth is silently unavailable and the registry returns a 404 rather than an auth error.
79
+ # Upgraded only now, not before npm ci: a newer npm skips dependency install scripts, which is
80
+ # how puppeteer downloads the Chrome the browser tests need, so the install and the tests run
81
+ # on the npm that Node ships, exactly as test.yml does.
82
+ - run: npm install -g npm@latest
82
83
 
83
84
  - run: npm publish --access public
84
- env:
85
- NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -43,9 +43,9 @@ jobs:
43
43
 
44
44
  - run: npm ci
45
45
 
46
- # kempo/kempo-ui/kempo-server are peer dependencies, which npm ci does not install.
46
+ # kempo, kempo-files, kempo-thumbs, kempo-ui and kempo-server are peer dependencies, which npm ci does not install.
47
47
  - name: Install the host for the test gate
48
- run: npm install --no-save kempo@latest kempo-ui@latest kempo-server@latest
48
+ run: npm install --no-save kempo@latest kempo-files@latest kempo-thumbs@latest kempo-ui@latest kempo-server@latest
49
49
 
50
50
  - name: Apply database schema
51
51
  run: npx drizzle-kit push --force
@@ -92,6 +92,17 @@ jobs:
92
92
  repository: dustinpoissant/kempo-server
93
93
  path: kempo-server
94
94
 
95
+ # This extension sits on kempo-files and kempo-thumbs, and link:local links both.
96
+ - uses: actions/checkout@v4
97
+ with:
98
+ repository: dustinpoissant/kempo-files
99
+ path: kempo-files
100
+
101
+ - uses: actions/checkout@v4
102
+ with:
103
+ repository: dustinpoissant/kempo-thumbs
104
+ path: kempo-thumbs
105
+
95
106
  # kempo's own link:local script links both kempo-server and kempo-ui as siblings, so this
96
107
  # checkout needs to exist even though kempo-media itself doesn't import kempo-ui server-side.
97
108
  - uses: actions/checkout@v4
package/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [Unreleased]
8
+ ### Changed
9
+ - **Media now sits on kempo-files, with thumbnails from kempo-thumbs.** Before, this extension kept its own copy of everything: uploads in the site's `public/media/uploads/` (static serving, no permissions), and `sharp` thumbnails in `public/media/thumbnails/`, parallel to kempo-files' permission-checked library and kempo-thumbs' ffmpeg thumbnails. Anything consuming images had to handle both stacks. Now an asset is a kempo-files file plus a `kempoMediaAsset` row (kind, dimensions, alt text, display name), the **asset id is the file id** (no mapping table), the bytes live in kempo-files' `files/media/` folder, and a thumbnail is whatever kempo-thumbs made. One storage layer, one set of file permissions, one thumbnail path.
10
+ - `kempo-config.json` declares `"dependencies": ["kempo-files", "kempo-thumbs"]`; kempo will not install this extension until both are enabled. kempo-thumbs is required rather than optional so every consumer can rely on `thumbnail` meaning the same thing. Requires `kempo-files` >= 0.1.5, which adds `uploadFile`, caller-chosen ids and download caching.
11
+ - **Asset shape.** Same ids, same fields (`path`, `thumbnailPath`, `altText`, `kind`, `width`, `height`, ...), but `path` is now the kempo-files URL without its leading slash (`kempo-files/api/files/<id>`) rather than a path under `public/`, and `thumbnailPath` is the small kempo-thumbs thumbnail (its `sm` size, else its smallest ready one), `null` until it exists. New: `url`, `thumbnail`, `thumbnails` (every size, for a `srcset`), `public`, `fileId`. Removed: `filename`.
12
+ - **Visibility.** Assets are public by default, as before; the flag is now kempo-files' `public` flag, settable through `updateMediaAsset(id, { public })` / `PATCH`. kempo-thumbs already refuses a private file's thumbnails to anyone who could not fetch it.
13
+ - `uploadMediaAsset` replaces `storeUpload` as the way to store a Buffer. It goes through kempo-files' `uploadFile`, so `file:before_upload` can refuse an upload and `file:uploaded` reaches kempo-thumbs. `deleteMediaAsset(id)` no longer takes a `rootDir`; deleting the file is what makes kempo-thumbs remove its thumbnails. **Breaking for callers of the old `storeUpload(...)` + `createMediaAsset(...)` pair**; this extension had not been published.
14
+ - Dimensions come from ffprobe (kempo-thumbs' binary) for images and video, and are `null` when the file cannot be decoded.
15
+ - The admin library shows the original image while a thumbnail is pending and refreshes itself a few times (only for images and video uploaded in the last two minutes) until it arrives. The editor picker has priority `-10`, so it loads after kempo-files' own picker and wins the shared `window.kempo.openAssetPicker`.
16
+
17
+ ### Migration
18
+ - **`update.js` moves 0.2.x installs into kempo-files** under the same ids, keeping bytes, alt text, kind, dimensions, upload date and uploader; registers each old `/media/uploads/<id>.<ext>` URL as an alias so embedded links keep working (needs `public/CATCH.js`); removes the old files, the old `sharp` thumbnails and the `thumbnail_max_dimension` setting; has kempo-thumbs make new thumbnails; and finally drops the `filename`, `path` and `thumbnailPath` columns. Idempotent, resumable after an interruption, and it refuses to start (changing nothing) unless kempo-files and kempo-thumbs are enabled. See the README.
19
+
20
+ ### Removed
21
+ - `sharp` and the `thumbnail_max_dimension` setting: thumbnail sizes, format and quality are kempo-thumbs' settings now.
22
+ - `install.js`: nothing needs creating up front. The `media` folder in kempo-files is created by the first upload, and nothing is written under `public/` any more.
23
+
7
24
  ## [0.2.0] - 2026-08-13
8
25
  ### Added
9
26
  - **Uploads.** `POST /kempo-media/api/media` accepts a `multipart/form-data` upload, validates its type, stores it under a generated name in the site's own `public/media/uploads/`, and records it. The multipart parser is written here rather than pulled in: the usual libraries stream a body that is still arriving, and kempo-server has already buffered the whole thing by the time a route runs. It works entirely on Buffers, so file content is never decoded — decoding it as UTF-8 is exactly the corruption kempo-server 3.2.0 fixed.
package/README.md CHANGED
@@ -2,28 +2,59 @@
2
2
 
3
3
  Media library extension for [kempo](https://github.com/dustinpoissant/kempo) — upload and manage images, video, audio, 3D models and archives, with a picker wired into the editor's image control.
4
4
 
5
+ A media asset is a [kempo-files](https://github.com/dustinpoissant/kempo-files) file plus the metadata a media library needs (kind, dimensions, alt text), and its thumbnails come from [kempo-thumbs](https://github.com/dustinpoissant/kempo-thumbs). There is one storage layer, one set of file permissions and one thumbnail path for the whole site.
6
+
5
7
  ## What it does
6
8
 
7
9
  - **A media library in the admin**, at *Media* in the sidebar: a grid of thumbnails with search, filtering by type, drag-and-drop upload with per-file progress, and delete.
8
10
  - **A picker in the editor.** kempo-ui's image control grows a **Browse…** button that opens the library, so an author inserts an image by looking at it instead of remembering its URL.
9
- - **Thumbnails for images**, generated on upload. Other kinds show a type icon.
10
- - **Range-request support for free.** Uploads are stored in your site's own `public/`, so kempo-server serves them directly and video and audio seek properly.
11
+ - **Thumbnails for images and video**, made by kempo-thumbs shortly after upload. A fresh image shows its original until the thumbnail is ready, and the grid refreshes itself. Audio and the other kinds show a type icon.
12
+ - **Public by default, with range requests.** Files are served by kempo-files: no login for a public asset, `Range` support so video and audio seek, correct content types, and cache headers with `304` revalidation.
13
+
14
+ ## How it fits together
15
+
16
+ ```
17
+ upload --> kempo-media --> kempo-files (bytes, name, public flag, permissions, serving)
18
+ | |
19
+ | +-- file:uploaded --> kempo-thumbs --> ffmpeg --> thumbnail files (also kempo-files)
20
+ +-- kempoMediaAsset row: kind, dimensions, alt text, display name
21
+ ```
22
+
23
+ - **The asset id is the file id.** There is no mapping table, and other extensions that store a media id keep working. An asset's URL is `/kempo-files/api/files/<id>` and is stable across renames and moves.
24
+ - **Files live in kempo-files' `files/media/` folder**, named `<id>.<ext>`. The name you uploaded is kept as the asset's display name only; it never reaches the filesystem. Nothing is written under the site's `public/` any more.
25
+ - **Two permission layers, deliberately.** `media:*` (below) decides who may use the media API and library. What is allowed on the underlying file is kempo-files' business: its own/others permissions apply if someone opens the file in the files library, and its `public` flag decides whether the world can fetch it.
26
+ - **Visibility is the file's `public` flag**, nothing separate. Assets are public by default, as they always were. `updateMediaAsset(id, { public: false })` (or a `PATCH` with `public: false`) closes the original, and kempo-thumbs refuses its thumbnails to anyone who could not fetch the original.
27
+ - **kempo-thumbs is required, not optional.** One thumbnail path (ffmpeg, which also covers video frames) beats a library that sometimes has `sharp` thumbnails and sometimes none, and every consumer can count on `thumbnail` meaning the same thing. kempo-media's own `sharp` thumbnail code and the `thumbnail_max_dimension` setting are gone; thumbnail sizes, format and quality are kempo-thumbs' settings. The cost is that a thumbnail appears a moment after the upload instead of with it.
11
28
 
12
29
  ## Requirements
13
30
 
14
31
  | Package | Version | Why |
15
32
  |---|---|---|
16
- | `kempo` | >= 4.2.26 | Extension host |
17
- | `kempo-server` | >= 3.2.0 | First release with binary-safe request bodies. On anything earlier, uploaded files arrive corrupted. |
18
- | `kempo-ui` | >= 0.4.43 | First release with the `window.kempo.openAssetPicker` hook the editor picker uses. The library itself works without it. |
33
+ | `kempo` | >= 4.2.39 | Extension host, including extension `dependencies` |
34
+ | `kempo-files` | >= 0.1.5 | Storage and serving. 0.1.5 adds `uploadFile` (upload hooks), caller-chosen ids and download caching. |
35
+ | `kempo-thumbs` | >= 0.1.3 | Thumbnails. Its ffprobe also supplies image and video dimensions. |
36
+ | `kempo-server` | >= 3.3.0 | Binary-safe request bodies, and the file server kempo-files streams through |
37
+ | `kempo-ui` | >= 0.4.43 | The `window.kempo.openAssetPicker` hook the editor picker uses. The library itself works without it. |
19
38
 
20
39
  ## Install
21
40
 
22
41
  ```
23
- npm install kempo-media
42
+ npm install kempo-media kempo-files kempo-thumbs
24
43
  ```
25
44
 
26
- Then install it from kempo's admin under **Extensions**. On install it creates `public/media/uploads/` and `public/media/thumbnails/` in your site.
45
+ Then, from kempo's admin under **Extensions**, install and enable **kempo-files**, then **kempo-thumbs**, then **kempo-media**. kempo refuses to install this one until the other two are enabled, and refuses to disable either while this is.
46
+
47
+ ### Upgrading from 0.2.x
48
+
49
+ 0.2.x kept uploads in your site's `public/media/uploads/` and made its own thumbnails. Install and enable kempo-files and kempo-thumbs, then **update kempo-media** from the Extensions screen. The update:
50
+
51
+ - moves every existing asset into kempo-files **under the same id**, so products, inventory items and anything else that stored a media id are untouched; alt text, kind, dimensions, upload date and uploader are kept, and the bytes are copied exactly;
52
+ - registers each old URL (`/media/uploads/<id>.png`) as the file's **alias**, so a page that embedded one keeps working. Aliases are resolved through kempo-files' `route:unmatched` hook, which needs the site's `public/CATCH.js` (see kempo-files' README);
53
+ - deletes the old files and the old `sharp` thumbnails from `public/media/`, and the retired `thumbnail_max_dimension` setting;
54
+ - asks kempo-thumbs to make new thumbnails, which appear over the next few moments;
55
+ - then drops the old `filename`, `path` and `thumbnailPath` columns.
56
+
57
+ It is safe to run again. If kempo-files or kempo-thumbs is missing, it stops before touching anything and says which. An asset whose file had already vanished from disk keeps its row and id (and is listed in the log), as it did before. The columns are only dropped once every row is accounted for, so a half-finished run resumes cleanly. Until the update has run, uploads fail, because the old table still demands the old columns.
27
58
 
28
59
  ## Permissions
29
60
 
@@ -31,8 +62,8 @@ Then install it from kempo's admin under **Extensions**. On install it creates `
31
62
  |---|---|
32
63
  | `media:browse` | List and view library assets |
33
64
  | `media:upload` | Upload new assets |
34
- | `media:update` | Edit alt text and rename |
35
- | `media:delete` | Delete assets |
65
+ | `media:update` | Edit alt text, rename, and change visibility |
66
+ | `media:delete` | Delete assets (and their files and thumbnails) |
36
67
 
37
68
  A `kempo-media:editor` group is created granting browse, upload and update — but not delete.
38
69
 
@@ -41,9 +72,8 @@ A `kempo-media:editor` group is created granting browse, upload and update — b
41
72
  | Name | Default | Description |
42
73
  |---|---|---|
43
74
  | `max_upload_size_mb` | `250` | Largest single file accepted |
44
- | `thumbnail_max_dimension` | `480` | Longest edge of generated thumbnails, in pixels |
45
75
 
46
- `max_upload_size_mb` sits **below** kempo-server's own `maxBodySize` (500 MB by default), which is the real ceiling. Raising this setting above that has no effect; the request is rejected before this extension sees it.
76
+ `max_upload_size_mb` sits **below** kempo-server's own `maxBodySize` (500 MB by default), which is the real ceiling. Raising this setting above that has no effect; the request is rejected before this extension sees it. Thumbnail sizes, output format and quality are kempo-thumbs' settings.
47
77
 
48
78
  ## Accepted file types
49
79
 
@@ -55,20 +85,33 @@ A `kempo-media:editor` group is created granting browse, upload and update — b
55
85
  | 3D model | `glb` `gltf` `obj` `fbx` |
56
86
  | Archive | `zip` |
57
87
 
58
- **SVG is deliberately not accepted.** kempo-server serves SVG as inline, unsanitised markup on your site's own origin, so an uploaded SVG carrying a `<script>` would be stored XSS against the admin who opens it. Supporting it needs a sanitiser first, not just an entry in the list.
88
+ **SVG is deliberately not accepted.** A web server serving SVG inline and unsanitised on your site's own origin turns an uploaded SVG carrying a `<script>` into stored XSS against the admin who opens it. Supporting it needs a sanitiser first, not just an entry in the list.
59
89
 
60
90
  ## Using assets from your own code
61
91
 
62
- Uploaded files are served from your site root, so an asset's `path` is its URL:
63
-
64
92
  ```js
65
- import { getMediaAssets } from 'kempo-media/sdk';
93
+ import { getMediaAssets, getMediaAsset, uploadMediaAsset } from 'kempo-media/sdk';
66
94
 
67
95
  const [error, data] = await getMediaAssets({ kind: 'image', limit: 10 });
68
- // data.assets[0].path === 'media/uploads/a1b2c3d4e5f60718.png' -> /media/uploads/...
96
+ const asset = data.assets[0];
69
97
  ```
70
98
 
71
- The same functions are available in the browser from `/kempo-media/sdk.js`, plus `uploadMedia(file, { alt }, onProgress)`.
99
+ An asset carries its row plus what kempo-files and kempo-thumbs add:
100
+
101
+ | Field | |
102
+ |---|---|
103
+ | `id`, `fileId` | The asset id, which is also the kempo-files file id |
104
+ | `originalName`, `kind`, `mimeType`, `sizeBytes`, `altText`, `width`, `height`, `uploadedBy`, `createdAt`, `updatedAt` | The media row |
105
+ | `url` / `path` | The original: `/kempo-files/api/files/<id>`, and the same without the leading slash |
106
+ | `thumbnail` / `thumbnailPath` | The small thumbnail (kempo-thumbs' `sm` size, else its smallest ready one), the same two ways, or `null` until kempo-thumbs has made one |
107
+ | `thumbnails` | Every size kempo-thumbs knows about, whatever its status, for a `srcset` |
108
+ | `public` | Whether anyone can fetch it |
109
+
110
+ Show `thumbnail ?? url` wherever a small image is wanted. `thumbnail` is `null` for a moment after upload, for audio without cover art, and for anything ffmpeg cannot read.
111
+
112
+ `uploadMediaAsset({ filename, data, altText, uploadedBy, maxBytes, public })` stores a Buffer: it fires kempo-files' `file:before_upload` and `file:uploaded` hooks (so a site's upload policy applies and kempo-thumbs hears about the file), probes the dimensions, and resolves to the asset. `pickThumbnail(thumbnails)` is the selection rule above, exported. `createMediaAsset` registers metadata for a kempo-files file you already have.
113
+
114
+ The same read functions are available in the browser from `/kempo-media/sdk.js`, plus `uploadMedia(file, { alt, public }, onProgress)` (`public` defaults to true).
72
115
 
73
116
  ## Providing your own picker
74
117
 
@@ -80,10 +123,12 @@ window.kempo.openAssetPicker = async ({ alt }) => ({ url: '/some/image.png', alt
80
123
 
81
124
  Resolve `null` to insert nothing. See kempo-ui's markdown-editor documentation for the full contract.
82
125
 
126
+ kempo-files installs the same hook for its own library, so with both present the question is which loads last. kempo-media's picker is registered with priority `-10` (later than kempo-files' `0`), so the editor opens the media library. An extension that wants to replace both needs a priority below that.
127
+
83
128
  ## Local development
84
129
 
85
130
  ```
86
- npm run link:local # links sibling ../kempo and ../kempo-ui checkouts
131
+ npm run link:local # links sibling ../kempo, ../kempo-files, ../kempo-thumbs, ../kempo-server and ../kempo-ui checkouts
87
132
  npm test
88
133
  ```
89
134
 
@@ -93,22 +138,23 @@ Note that `npm install <anything>` in a consuming project drops packages linked
93
138
 
94
139
  ### Running the database-backed tests
95
140
 
96
- Two suites (`tests/media-api-contract.node-test.js`, `tests/media-data-layer.node-test.js`) drive this extension against a real Postgres — one installs it and hits its actual HTTP routes, the other exercises the CRUD layer directly. Both skip themselves with a clear message when no database is reachable, so `npm test` is safe to run without one. To actually run them:
141
+ Three suites drive this extension against a real Postgres: `media-api-contract` installs kempo-files, kempo-thumbs and kempo-media and hits the real HTTP routes of a spawned server (upload, serving, ranges, caching, the thumbnail appearing through the real hooks, visibility, delete), `media-data-layer` exercises the library functions directly, and `media-migration` runs the 0.2.x move against a recreated legacy table. They skip themselves with a clear message when no database is reachable, so `npm test` is safe to run without one — but a green run that skipped them proves nothing, so check for `(SKIPPED)`. To actually run them:
97
142
 
98
143
  ```
99
144
  docker compose up -d # starts kempo-media-test-db on localhost:5435
100
145
  export DATABASE_URL=$(grep DATABASE_URL .env.example | cut -d= -f2-)
101
- npx drizzle-kit push --force # creates kempo's core tables + kempoMediaAsset
146
+ npx drizzle-kit push --force # creates kempo's core tables, kempo-files', kempo-thumbs' and kempoMediaAsset
102
147
  npm test
103
148
  ```
104
149
 
105
- `DATABASE_URL` needs to be a real environment variable for both `drizzle-kit` and `npm test` — this repo has no dotenv step of its own to load a `.env` file, so `.env.example` is a copy-paste reference, not something read automatically.
150
+ `DATABASE_URL` needs to be a real environment variable for both `drizzle-kit` and `npm test` — this repo has no dotenv step of its own to load a `.env` file, so `.env.example` is a copy-paste reference, not something read automatically. The suites write into `files/` in this checkout (git-ignored), and need `kempo-files` and `kempo-thumbs` linked (`npm run link:local`).
106
151
 
107
152
  `drizzle.config.js` here exists only for this — the table itself is created for real via `createTablesFromSchema` at install time, the same as it would be on a live site.
108
153
 
109
154
  ## Known limits
110
155
 
111
156
  - **Uploads are buffered in memory, not streamed to disk.** kempo-server reads the whole request body before a route runs, so a very large upload is bounded by available RAM rather than disk. `maxBodySize` is the ceiling.
112
- - **Thumbnails are images only.** No video frame extraction or waveforms — that needs ffmpeg and a job queue, neither of which exists here.
113
- - **`sharp` is a native module.** It is the one dependency that can fail to install on an unusual host. It is imported lazily, so if it is missing you get uploads without thumbnails rather than a broken extension.
157
+ - **Thumbnails arrive after the upload**, not with it, and are generated in kempo-thumbs' in-process queue. A restart can lose queued work; its admin screen's *Generate missing* picks it back up.
158
+ - **Only assets uploaded through this library are in it.** Other files in kempo-files do not appear in the media grid unless registered with `createMediaAsset`.
159
+ - **A replaced public file can be cached for up to an hour** by browsers (kempo-files' cache lifetime).
114
160
  - **No usage tracking.** Deleting an asset does not check whether a page links to it.
@@ -1,4 +1,4 @@
1
- <content id="kempo-media-asset-picker" name="Media asset picker" owner="kempo-media" enabled="true" locked="true" location="scripts" priority="0">
1
+ <content id="kempo-media-asset-picker" name="Media asset picker" owner="kempo-media" enabled="true" locked="true" location="scripts" priority="-10">
2
2
  <script type="module">
3
3
  /*
4
4
  Installs the picker kempo-ui's image control looks for. kempo core scans every enabled
@@ -7,6 +7,11 @@
7
7
 
8
8
  The hook is read when the control's dropdown opens rather than at page load, so setting it here
9
9
  is early enough regardless of which loads first.
10
+
11
+ kempo-files installs the same hook for its own library, and this extension requires it, so both
12
+ are always present. Content with a lower priority renders later and the last assignment wins;
13
+ -10 puts this one after kempo-files' (0), so the editor opens the media library, not the raw
14
+ file library.
10
15
  */
11
16
  import Dialog from '/kempo-ui/components/Dialog.js';
12
17
  import '/admin/extension/kempo-media/components/MediaLibrary.js';
@@ -32,7 +37,7 @@
32
37
  };
33
38
 
34
39
  const chosen = asset => ({
35
- url: `/${asset.path}`,
40
+ url: `/${asset.path}`, // the kempo-files URL: public, cacheable and range-capable
36
41
  // The asset's own alt text describes the file; fall back to whatever was already typed
37
42
  alt: asset.altText || alt
38
43
  });
@@ -17,6 +17,19 @@ import { getMediaAssets, uploadMedia, deleteMediaAsset } from '/kempo-media/sdk.
17
17
  const BASE = '/admin/extension/kempo-media';
18
18
  const PAGE_SIZE = 24;
19
19
 
20
+ /*
21
+ kempo-thumbs makes thumbnails after the upload, in the background, so a fresh image or video has
22
+ none for a moment. The grid asks again every few seconds, but only for assets young enough that a
23
+ thumbnail is still expected and only a bounded number of times, so a file ffmpeg cannot read does
24
+ not keep the library polling forever.
25
+ */
26
+ const THUMBNAIL_POLL_MS = 2500;
27
+ const THUMBNAIL_POLL_LIMIT = 8;
28
+ const THUMBNAIL_EXPECTED_MS = 2 * 60 * 1000;
29
+ const awaitingThumbnail = asset => ['image', 'video'].includes(asset.kind)
30
+ && !asset.thumbnailPath
31
+ && Date.now() - new Date(asset.createdAt).getTime() < THUMBNAIL_EXPECTED_MS;
32
+
20
33
  const formatBytes = bytes => {
21
34
  if(bytes < 1024) return `${bytes} B`;
22
35
  if(bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
@@ -53,6 +66,8 @@ export default class MediaLibrary extends ShadowComponent {
53
66
  this.dragging = false;
54
67
  this.uploads = [];
55
68
  this.selectedId = '';
69
+ this.pollAttempts = 0;
70
+ this.pollTimer = null;
56
71
  }
57
72
 
58
73
  connectedCallback() {
@@ -60,8 +75,14 @@ export default class MediaLibrary extends ShadowComponent {
60
75
  this.load();
61
76
  }
62
77
 
63
- load = async () => {
64
- this.loading = true;
78
+ disconnectedCallback() {
79
+ super.disconnectedCallback();
80
+ clearTimeout(this.pollTimer);
81
+ }
82
+
83
+ load = async ({ quiet = false } = {}) => {
84
+ clearTimeout(this.pollTimer);
85
+ if(!quiet) this.loading = true;
65
86
  const [error, data] = await getMediaAssets({
66
87
  kind: this.kind || undefined,
67
88
  search: this.search || undefined,
@@ -70,11 +91,16 @@ export default class MediaLibrary extends ShadowComponent {
70
91
  });
71
92
  this.loading = false;
72
93
  if(error){
73
- Toast.error(`Could not load the media library: ${error.msg}`);
94
+ if(!quiet) Toast.error(`Could not load the media library: ${error.msg}`);
74
95
  return;
75
96
  }
76
97
  this.assets = data.assets;
77
98
  this.total = data.total;
99
+
100
+ this.pollAttempts = quiet ? this.pollAttempts + 1 : 0;
101
+ if(this.pollAttempts < THUMBNAIL_POLL_LIMIT && this.assets.some(awaitingThumbnail)){
102
+ this.pollTimer = setTimeout(() => this.load({ quiet: true }), THUMBNAIL_POLL_MS);
103
+ }
78
104
  };
79
105
 
80
106
  goTo = offset => {
@@ -84,9 +110,9 @@ export default class MediaLibrary extends ShadowComponent {
84
110
 
85
111
  handleSearch = e => {
86
112
  // Re-running the query on every keystroke would fire a request per character
87
- clearTimeout(this._searchTimer);
113
+ clearTimeout(this.searchTimer);
88
114
  const value = e.target.value;
89
- this._searchTimer = setTimeout(() => {
115
+ this.searchTimer = setTimeout(() => {
90
116
  this.search = value;
91
117
  this.offset = 0;
92
118
  this.load();
@@ -169,8 +195,8 @@ export default class MediaLibrary extends ShadowComponent {
169
195
  @dblclick=${() => this.selectable && this.dispatchEvent(new CustomEvent('asset-confirmed', { detail: { asset }, bubbles: true, composed: true }))}
170
196
  >
171
197
  <div class="preview">
172
- ${asset.thumbnailPath
173
- ? html`<img src="/${asset.thumbnailPath}" alt=${asset.altText || asset.originalName} loading="lazy" />`
198
+ ${asset.thumbnailPath || asset.kind === 'image'
199
+ ? html`<img src="/${asset.thumbnailPath || asset.path}" alt=${asset.altText || asset.originalName} loading="lazy" />`
174
200
  : html`<img class="type-icon" src="${BASE}/icons/${asset.kind}.svg" alt=${asset.kind} />`}
175
201
  </div>
176
202
  <div class="meta">
@@ -1,3 +1,3 @@
1
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>
2
+ <k-aside-item icon="image" group="Media" group-icon="image" href="/admin/extension/kempo-media/" no-expand>Media</k-aside-item>
3
3
  </content>
package/drizzle.config.js CHANGED
@@ -3,10 +3,11 @@ import { defineConfig } from 'drizzle-kit';
3
3
  /*
4
4
  Local/CI test-database setup only — this extension has no migrations of its own in production.
5
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.
6
+ installExtension in kempo's sdk), which the data-layer and API-contract test suites exercise
7
+ directly. This file exists so `npx drizzle-kit push` can stand up a throwaway database with
8
+ kempo's core tables (user/session/permission/...), the two extensions this one sits on
9
+ (kempo-files and kempo-thumbs) and its own, mirroring the schema array kempo's own
10
+ app-drizzle.config.js template scaffolds into a real site.
10
11
 
11
12
  No dotenv here on purpose: unlike a real kempo site, nothing in this repo's own runtime loads
12
13
  .env either, so pulling it in only for this file would be one more dependency to explain rather
@@ -17,6 +18,8 @@ export default defineConfig({
17
18
  schema: [
18
19
  './server/db/schema.js',
19
20
  './node_modules/kempo/server/db/schema.js',
21
+ './node_modules/kempo-files/server/db/schema.js',
22
+ './node_modules/kempo-thumbs/server/db/schema.js',
20
23
  ],
21
24
  out: './server/db/migrations',
22
25
  dialect: 'postgresql',
package/kempo-config.json CHANGED
@@ -2,6 +2,7 @@
2
2
  "docs": "https://github.com/dustinpoissant/kempo-media#readme",
3
3
  "public-scope": "kempo-media",
4
4
  "schema": "./server/db/schema.js",
5
+ "dependencies": ["kempo-files", "kempo-thumbs"],
5
6
  "permissions": [
6
7
  { "name": "media:browse", "description": "List and view media library assets" },
7
8
  { "name": "media:upload", "description": "Upload new media assets" },
@@ -9,8 +10,7 @@
9
10
  { "name": "media:delete", "description": "Delete media assets" }
10
11
  ],
11
12
  "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" }
13
+ { "name": "max_upload_size_mb", "value": "250", "type": "number", "description": "Maximum size, in MB, of a single uploaded file" }
14
14
  ],
15
15
  "groups": [
16
16
  { "name": "kempo-media:editor", "description": "Can browse, upload, and edit media", "permissions": ["media:browse", "media:upload", "media:update"] }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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",
3
+ "version": "0.3.0",
4
+ "description": "Media library extension for kempo — upload and manage images, video, audio, 3D models, and archives, stored in kempo-files with thumbnails from kempo-thumbs",
5
5
  "license": "MIT",
6
6
  "author": "Dustin Poissant",
7
7
  "type": "module",
@@ -9,13 +9,12 @@
9
9
  "scripts": {
10
10
  "build": "node scripts/build.js",
11
11
  "test": "kempo-test -l minimal",
12
- "link:local": "npm install ../kempo ../kempo-ui --no-save"
12
+ "link:local": "npm install ../kempo ../kempo-files ../kempo-thumbs ../kempo-server ../kempo-ui --no-save --no-legacy-peer-deps --force"
13
13
  },
14
14
  "kempo": {
15
15
  "public-scope": "kempo-media"
16
16
  },
17
17
  "exports": {
18
- "./install": "./install.js",
19
18
  "./update": "./update.js",
20
19
  "./sdk": "./sdk.js"
21
20
  },
@@ -24,16 +23,18 @@
24
23
  "url": "git+https://github.com/dustinpoissant/kempo-media.git"
25
24
  },
26
25
  "dependencies": {
27
- "drizzle-orm": "^0.45.2",
28
- "sharp": "^0.35.3"
26
+ "drizzle-orm": "^0.45.2"
29
27
  },
30
28
  "peerDependencies": {
31
- "kempo": ">=4.2.26",
32
- "kempo-server": ">=3.2.0",
29
+ "kempo": ">=4.2.39",
30
+ "kempo-files": ">=0.1.5",
31
+ "kempo-server": ">=3.3.0",
32
+ "kempo-thumbs": ">=0.1.3",
33
33
  "kempo-ui": ">=0.4.43"
34
34
  },
35
35
  "devDependencies": {
36
36
  "drizzle-kit": "^0.31.10",
37
- "kempo-testing-framework": "^1.4.11"
37
+ "kempo-testing-framework": "^1.4.11",
38
+ "postgres": "^3.4.9"
38
39
  }
39
40
  }
@@ -1,9 +1,6 @@
1
- import { join } from 'path';
2
1
  import { currentUserHasPermission } from 'kempo/server/sdk.js';
3
2
  import deleteMediaAsset from '../../../server/utils/media/deleteMediaAsset.js';
4
3
 
5
- const ROOT_DIR = join(process.cwd(), 'public');
6
-
7
4
  export default async (request, response) => {
8
5
  const token = request.cookies.session_token;
9
6
 
@@ -14,7 +11,7 @@ export default async (request, response) => {
14
11
  const { id } = request.body || {};
15
12
  if(!id) return response.status(400).json({ error: 'An asset id is required' });
16
13
 
17
- const [error, data] = await deleteMediaAsset(id, { rootDir: ROOT_DIR });
14
+ const [error, data] = await deleteMediaAsset(id);
18
15
  if(error) return response.status(error.code).json({ error: error.msg });
19
16
 
20
17
  response.json(data);
@@ -8,10 +8,10 @@ export default async (request, response) => {
8
8
  if(permError) return response.status(permError.code).json({ error: permError.msg });
9
9
  if(!canUpdate) return response.status(403).json({ error: 'Insufficient permissions' });
10
10
 
11
- const { id, altText, originalName } = request.body || {};
11
+ const { id, altText, originalName, public: isPublic } = request.body || {};
12
12
  if(!id) return response.status(400).json({ error: 'An asset id is required' });
13
13
 
14
- const [error, asset] = await updateMediaAsset(id, { altText, originalName });
14
+ const [error, asset] = await updateMediaAsset(id, { altText, originalName, public: isPublic });
15
15
  if(error) return response.status(error.code).json({ error: error.msg });
16
16
 
17
17
  response.json({ asset });
@@ -1,10 +1,6 @@
1
- import { join } from 'path';
2
1
  import { getSession, currentUserHasPermission, getSetting } from 'kempo/server/sdk.js';
3
2
  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');
3
+ import uploadMediaAsset from '../../../server/utils/media/uploadMediaAsset.js';
8
4
 
9
5
  export default async (request, response) => {
10
6
  const token = request.cookies.session_token;
@@ -34,24 +30,19 @@ export default async (request, response) => {
34
30
  }
35
31
 
36
32
  const altPart = parts.find(p => p.name === 'alt' && !p.filename);
33
+ const publicPart = parts.find(p => p.name === 'public' && !p.filename);
37
34
 
38
35
  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,
36
+ const [error, asset] = await uploadMediaAsset({
43
37
  filename: filePart.filename,
44
38
  data: filePart.data,
45
- mimeType: filePart.contentType,
46
39
  altText: altPart ? altPart.data.toString('utf8') : '',
47
40
  uploadedBy: session.user.id,
48
41
  maxBytes: Number(maxUploadMb) * 1024 * 1024,
49
- thumbnailMaxDimension: Number(thumbnailMaxDimension),
42
+ public: publicPart?.data.toString('utf8') !== 'false',
43
+ request,
50
44
  });
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 });
45
+ if(error) return response.status(error.code).json({ error: error.msg });
55
46
 
56
- response.status(201).json({ asset: row });
47
+ response.status(201).json({ asset });
57
48
  };
package/public/sdk.js CHANGED
@@ -33,10 +33,11 @@ export const deleteMediaAsset = id => req('DELETE', `${BASE}/media`, { id });
33
33
  has no way to report how far a request body has been sent, and a progress bar is the difference
34
34
  between a usable upload of a large video and one that looks frozen.
35
35
  */
36
- export const uploadMedia = (file, { alt = '' } = {}, onProgress) => new Promise(resolve => {
36
+ export const uploadMedia = (file, { alt = '', public: isPublic = true } = {}, onProgress) => new Promise(resolve => {
37
37
  const form = new FormData();
38
38
  form.append('file', file);
39
39
  if(alt) form.append('alt', alt);
40
+ if(!isPublic) form.append('public', 'false');
40
41
 
41
42
  const xhr = new XMLHttpRequest();
42
43
  xhr.open('POST', `${BASE}/media`);
package/sdk.js CHANGED
@@ -4,12 +4,14 @@
4
4
  /kempo-media/sdk.js — same names, different transport.
5
5
  */
6
6
 
7
+ export { default as uploadMediaAsset } from './server/utils/media/uploadMediaAsset.js';
7
8
  export { default as createMediaAsset } from './server/utils/media/createMediaAsset.js';
8
9
  export { default as getMediaAsset } from './server/utils/media/getMediaAsset.js';
9
10
  export { default as getMediaAssets } from './server/utils/media/getMediaAssets.js';
10
11
  export { default as updateMediaAsset } from './server/utils/media/updateMediaAsset.js';
11
12
  export { default as deleteMediaAsset } from './server/utils/media/deleteMediaAsset.js';
12
13
 
13
- export { default as storeUpload } from './server/utils/uploads/storeUpload.js';
14
+ export { pickThumbnail } from './server/utils/media/describeAssets.js';
15
+
14
16
  export { default as parseMultipart, extractBoundary } from './server/utils/uploads/parseMultipart.js';
15
17
  export { ALLOWED_TYPES, KINDS, typeForFilename } from './server/utils/uploads/fileTypes.js';
@@ -1,14 +1,16 @@
1
1
  import { pgTable, text, integer, timestamp } from 'drizzle-orm/pg-core';
2
2
 
3
+ /*
4
+ What a media asset adds to a kempo-files file. The bytes, the name on disk, who may fetch them and
5
+ the thumbnails all belong to kempo-files and kempo-thumbs; this row holds only what a media
6
+ library needs on top: the kind, the dimensions, the display name and the alt text.
7
+ */
3
8
  export const kempoMediaAsset = pgTable('kempoMediaAsset', {
4
- id: text('id').primaryKey(), // crypto.randomBytes(8).toString('hex')
5
- filename: text('filename').notNull(), // server-generated name on disk — never derived from user input
9
+ id: text('id').primaryKey(), // also the id of the kempo-files file behind it, so no mapping table
6
10
  originalName: text('originalName').notNull(),
7
11
  mimeType: text('mimeType').notNull(),
8
12
  kind: text('kind').notNull(), // 'image' | 'video' | 'audio' | 'model3d' | 'archive'
9
13
  sizeBytes: integer('sizeBytes').notNull(),
10
- path: text('path').notNull(), // relative to the site's public/, e.g. 'media/uploads/<id>.png'
11
- thumbnailPath: text('thumbnailPath'), // images only; null for every other kind
12
14
  altText: text('altText').notNull().default(''),
13
15
  width: integer('width'),
14
16
  height: integer('height'),