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
package/.env.example ADDED
@@ -0,0 +1 @@
1
+ DATABASE_URL=postgresql://kempo:kempo_media_test_password@localhost:5435/kempo_media_test
package/.gitattributes ADDED
@@ -0,0 +1,5 @@
1
+ # Normalise every text file to LF in the repository, and check it out with the
2
+ # platform's native endings. Without this, a Windows build writes CRLF into files that
3
+ # checkout wrote as LF, so git reports them as modified even though the content is
4
+ # identical — `git diff` shows nothing while `git status` shows a change.
5
+ * text=auto
@@ -0,0 +1,85 @@
1
+ name: Publish to npmjs
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ workflow_dispatch:
8
+ inputs:
9
+ version-type:
10
+ description: 'Version bump type'
11
+ required: true
12
+ type: choice
13
+ options:
14
+ - patch
15
+ - minor
16
+ - major
17
+ default: patch
18
+
19
+ jobs:
20
+ publish:
21
+ # [skip publish] lands a commit on master without releasing while still running the tests,
22
+ # which a shared [skip ci] marker cannot do since that skips the test workflow too.
23
+ # A manual run always publishes.
24
+ if: "github.event_name == 'workflow_dispatch' || !(contains(github.event.head_commit.message, '[skip ci]') || contains(github.event.head_commit.message, '[skip publish]'))"
25
+ runs-on: ubuntu-latest
26
+ permissions:
27
+ contents: write
28
+ id-token: write
29
+ steps:
30
+ - uses: actions/checkout@v4
31
+ with:
32
+ token: ${{ secrets.GITHUB_TOKEN }}
33
+
34
+ - uses: actions/setup-node@v4
35
+ with:
36
+ node-version: '20.x'
37
+
38
+ - run: npm ci
39
+
40
+ - name: Build project and documentation
41
+ run: |
42
+ npm run build
43
+
44
+ # kempo/kempo-ui/kempo-server are peer dependencies and are not installed by npm ci above.
45
+ # The published release is the right one to test against: it is what a site installing this
46
+ # extension will actually pair it with.
47
+ - name: Install the host for the test gate
48
+ run: npm install --no-save kempo@latest kempo-ui@latest kempo-server@latest
49
+
50
+ - name: Run Unit Tests
51
+ run: npm test
52
+
53
+ - name: Set version type
54
+ id: version
55
+ run: |
56
+ if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
57
+ echo "type=${{ inputs.version-type }}" >> $GITHUB_OUTPUT
58
+ else
59
+ echo "type=patch" >> $GITHUB_OUTPUT
60
+ fi
61
+
62
+ - name: Bump ${{ steps.version.outputs.type }} version
63
+ run: |
64
+ git config --global user.name "github-actions"
65
+ git config --global user.email "github-actions@github.com"
66
+ npm version ${{ steps.version.outputs.type }} --no-git-tag-version
67
+ git add -A || true
68
+ git commit -m "ci: bump ${{ steps.version.outputs.type }} version [skip ci]" || echo "No changes to commit"
69
+ # Must be the branch that triggered this run — hardcoding a branch name silently drifts
70
+ # git and npm apart the day the default branch changes.
71
+ git push origin HEAD:${{ github.ref_name }}
72
+ env:
73
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
74
+
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
82
+
83
+ - run: npm publish --access public
84
+ env:
85
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -0,0 +1,123 @@
1
+ name: Run Unit Tests
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - master
8
+ pull_request:
9
+ branches:
10
+ - main
11
+ - master
12
+
13
+ jobs:
14
+ # kempo/kempo-ui/kempo-server resolve from the registry, which is what a site installing this
15
+ # extension gets. Peer dependencies are installed automatically by npm ci.
16
+ published:
17
+ if: "!contains(github.event.head_commit.message, '[skip ci]')"
18
+ runs-on: ubuntu-latest
19
+ services:
20
+ # The API-contract and data-layer suites need a real database; they skip themselves when
21
+ # none is reachable, so this is what makes them actually run rather than silently pass.
22
+ postgres:
23
+ image: postgres:16-alpine
24
+ env:
25
+ POSTGRES_USER: kempo
26
+ POSTGRES_PASSWORD: kempo
27
+ POSTGRES_DB: kempo_media_test
28
+ ports:
29
+ - 5432:5432
30
+ options: >-
31
+ --health-cmd pg_isready
32
+ --health-interval 10s
33
+ --health-timeout 5s
34
+ --health-retries 5
35
+ env:
36
+ DATABASE_URL: postgresql://kempo:kempo@localhost:5432/kempo_media_test
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+
40
+ - uses: actions/setup-node@v4
41
+ with:
42
+ node-version: '22.x'
43
+
44
+ - run: npm ci
45
+
46
+ # kempo/kempo-ui/kempo-server are peer dependencies, which npm ci does not install.
47
+ - name: Install the host for the test gate
48
+ run: npm install --no-save kempo@latest kempo-ui@latest kempo-server@latest
49
+
50
+ - name: Apply database schema
51
+ run: npx drizzle-kit push --force
52
+
53
+ - name: Run Unit Tests
54
+ run: npm test
55
+
56
+ # Same suite against the sibling checkout of the host. This extension resolves imports out of
57
+ # kempo, so it breaks whenever the host moves something it reaches into — this job is what
58
+ # surfaces that before a release, and it is advisory rather than blocking.
59
+ siblings:
60
+ if: "!contains(github.event.head_commit.message, '[skip ci]')"
61
+ runs-on: ubuntu-latest
62
+ continue-on-error: true
63
+ services:
64
+ # Without this the database-backed suites skip themselves, which reads as a pass.
65
+ postgres:
66
+ image: postgres:16-alpine
67
+ env:
68
+ POSTGRES_USER: kempo
69
+ POSTGRES_PASSWORD: kempo
70
+ POSTGRES_DB: kempo_media_test
71
+ ports:
72
+ - 5432:5432
73
+ options: >-
74
+ --health-cmd pg_isready
75
+ --health-interval 10s
76
+ --health-timeout 5s
77
+ --health-retries 5
78
+ env:
79
+ DATABASE_URL: postgresql://kempo:kempo@localhost:5432/kempo_media_test
80
+ steps:
81
+ - uses: actions/checkout@v4
82
+ with:
83
+ path: kempo-media
84
+
85
+ - uses: actions/checkout@v4
86
+ with:
87
+ repository: dustinpoissant/kempo
88
+ path: kempo
89
+
90
+ - uses: actions/checkout@v4
91
+ with:
92
+ repository: dustinpoissant/kempo-server
93
+ path: kempo-server
94
+
95
+ # kempo's own link:local script links both kempo-server and kempo-ui as siblings, so this
96
+ # checkout needs to exist even though kempo-media itself doesn't import kempo-ui server-side.
97
+ - uses: actions/checkout@v4
98
+ with:
99
+ repository: dustinpoissant/kempo-ui
100
+ path: kempo-ui
101
+
102
+ - uses: actions/setup-node@v4
103
+ with:
104
+ node-version: '22.x'
105
+
106
+ - name: Install host
107
+ run: npm ci && npm run link:local
108
+ working-directory: kempo
109
+
110
+ - run: npm ci
111
+ working-directory: kempo-media
112
+
113
+ - name: Link sibling checkouts
114
+ run: npm run link:local
115
+ working-directory: kempo-media
116
+
117
+ - name: Apply database schema
118
+ run: npx drizzle-kit push --force
119
+ working-directory: kempo-media
120
+
121
+ - name: Run Unit Tests
122
+ run: npm test
123
+ working-directory: kempo-media
package/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
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
+
7
+ ## [0.2.0] - 2026-08-13
8
+ ### Added
9
+ - **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.
10
+ - **Thumbnails for images**, via `sharp`, imported lazily so a failed native build degrades to "no preview" rather than an extension that will not load. Other kinds get a type icon.
11
+ - **Browse, rename, edit alt text and delete**, over `GET`/`PATCH`/`DELETE` on the same route, each behind its own permission. Deleting removes the files as well as the row, and tolerates a file that has already gone from disk rather than leaving an undeletable entry.
12
+ - **The media library admin screen** at *Media*: a thumbnail grid with search, type filtering, drag-and-drop upload with per-file progress, and delete. Uploads run one at a time — bodies are buffered in memory server-side, so sending ten large files at once is the surest way to exhaust it.
13
+ - **An editor picker.** `admin/asset-picker.global.html` installs `window.kempo.openAssetPicker`, so kempo-ui's image control shows a **Browse…** button that opens the same library. Nothing in kempo core needed changing: it already scans every enabled extension's `admin/` directory for `*.global.html` on each render.
14
+ - **A parse check over every browser module.** Those files import absolute `/kempo-ui/…` URLs, so nothing else in the node suite loads them, and a backtick inside a comment in an `html``/`css`` literal terminates the literal and stops the module parsing — the element then silently never upgrades. `node --check` catches it without a browser.
15
+
16
+ ### Security
17
+ - **SVG is not an accepted upload type.** kempo-server serves it as inline unsanitised markup on the site's own origin, making an uploaded SVG with a `<script>` stored XSS against the admin who opens it.
18
+ - **Supplied filenames never reach the filesystem.** Names on disk are generated; the original is kept for display only, which makes `../../../../etc/passwd.png` a harmless label rather than a traversal.
19
+
20
+ ### Changed
21
+ - Requires `kempo-server` >= 3.2.0 and, for the editor picker only, `kempo-ui` >= 0.4.43.
22
+
23
+ ## [0.1.0] - 2026-08-12
24
+ ### Added
25
+ - Initial extension scaffold: `kempo-config.json` (permissions, settings, groups), the `kempoMediaAsset` table, `install.js`/`update.js`, admin nav registration, and the baseline test suite (module graph, package invariants, permission-name consistency).
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # kempo-media
2
+
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
+
5
+ ## What it does
6
+
7
+ - **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
+ - **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
+
12
+ ## Requirements
13
+
14
+ | Package | Version | Why |
15
+ |---|---|---|
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. |
19
+
20
+ ## Install
21
+
22
+ ```
23
+ npm install kempo-media
24
+ ```
25
+
26
+ Then install it from kempo's admin under **Extensions**. On install it creates `public/media/uploads/` and `public/media/thumbnails/` in your site.
27
+
28
+ ## Permissions
29
+
30
+ | Name | Grants |
31
+ |---|---|
32
+ | `media:browse` | List and view library assets |
33
+ | `media:upload` | Upload new assets |
34
+ | `media:update` | Edit alt text and rename |
35
+ | `media:delete` | Delete assets |
36
+
37
+ A `kempo-media:editor` group is created granting browse, upload and update — but not delete.
38
+
39
+ ## Settings
40
+
41
+ | Name | Default | Description |
42
+ |---|---|---|
43
+ | `max_upload_size_mb` | `250` | Largest single file accepted |
44
+ | `thumbnail_max_dimension` | `480` | Longest edge of generated thumbnails, in pixels |
45
+
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.
47
+
48
+ ## Accepted file types
49
+
50
+ | Kind | Extensions |
51
+ |---|---|
52
+ | Image | `png` `jpg` `jpeg` `gif` `webp` `avif` |
53
+ | Video | `mp4` `m4v` `webm` `ogv` |
54
+ | Audio | `mp3` `ogg` `wav` |
55
+ | 3D model | `glb` `gltf` `obj` `fbx` |
56
+ | Archive | `zip` |
57
+
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.
59
+
60
+ ## Using assets from your own code
61
+
62
+ Uploaded files are served from your site root, so an asset's `path` is its URL:
63
+
64
+ ```js
65
+ import { getMediaAssets } from 'kempo-media/sdk';
66
+
67
+ const [error, data] = await getMediaAssets({ kind: 'image', limit: 10 });
68
+ // data.assets[0].path === 'media/uploads/a1b2c3d4e5f60718.png' -> /media/uploads/...
69
+ ```
70
+
71
+ The same functions are available in the browser from `/kempo-media/sdk.js`, plus `uploadMedia(file, { alt }, onProgress)`.
72
+
73
+ ## Providing your own picker
74
+
75
+ The editor hook is just a global. Any extension can install one, and the last one to load wins:
76
+
77
+ ```js
78
+ window.kempo.openAssetPicker = async ({ alt }) => ({ url: '/some/image.png', alt });
79
+ ```
80
+
81
+ Resolve `null` to insert nothing. See kempo-ui's markdown-editor documentation for the full contract.
82
+
83
+ ## Local development
84
+
85
+ ```
86
+ npm run link:local # links sibling ../kempo and ../kempo-ui checkouts
87
+ npm test
88
+ ```
89
+
90
+ Note that `npm install <anything>` in a consuming project drops packages linked with `--no-save`, so re-run `link:local` after one.
91
+
92
+ `npm test` runs both node and headless-browser tests (Puppeteer downloads its own Chromium on `npm install`, nothing extra to set up). The browser suites cover `admin/components/MediaLibrary.js` — a real Lit component that imports `/kempo-ui/...` and `/kempo-media/sdk.js` by absolute URL, the same way it does on a live site. `tests/browser-fixtures.node-test.js` runs first (node tests always finish before any browser test starts) and links `kempo-ui` and `kempo-media` directories at the repo root so those imports resolve against the test server; `kempo-media` there points at a fake `sdk.js` (`tests/fixtures/kempo-media-sdk-mock/`) that each test controls via `window.__mediaSdkMock`, not the real one, since these tests don't boot a server.
93
+
94
+ ### Running the database-backed tests
95
+
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:
97
+
98
+ ```
99
+ docker compose up -d # starts kempo-media-test-db on localhost:5435
100
+ export DATABASE_URL=$(grep DATABASE_URL .env.example | cut -d= -f2-)
101
+ npx drizzle-kit push --force # creates kempo's core tables + kempoMediaAsset
102
+ npm test
103
+ ```
104
+
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.
106
+
107
+ `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
+
109
+ ## Known limits
110
+
111
+ - **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.
114
+ - **No usage tracking.** Deleting an asset does not check whether a page links to it.
@@ -0,0 +1,63 @@
1
+ <content id="kempo-media-asset-picker" name="Media asset picker" owner="kempo-media" enabled="true" locked="true" location="scripts" priority="0">
2
+ <script type="module">
3
+ /*
4
+ Installs the picker kempo-ui's image control looks for. kempo core scans every enabled
5
+ extension's admin/ directory for *.global.html on each admin render, so this lands in the
6
+ scripts location of every admin page with no changes to core and no registration step.
7
+
8
+ The hook is read when the control's dropdown opens rather than at page load, so setting it here
9
+ is early enough regardless of which loads first.
10
+ */
11
+ import Dialog from '/kempo-ui/components/Dialog.js';
12
+ import '/admin/extension/kempo-media/components/MediaLibrary.js';
13
+
14
+ window.kempo = window.kempo || {};
15
+
16
+ window.kempo.openAssetPicker = ({ alt = '' } = {}) => new Promise(resolve => {
17
+ const library = document.createElement('kempo-media-library');
18
+ library.selectable = true;
19
+ library.canUpload = true; // uploading and then immediately using it is the common flow
20
+ library.kind = 'image'; // the caller is the image control; other kinds are a click away
21
+ library.style.minWidth = 'min(48rem, 80vw)';
22
+
23
+ /*
24
+ resolve() is idempotent by contract but the paths overlap — a double-click confirms and then
25
+ the dialog closes, firing closeCallback too. Guarding here keeps the first answer.
26
+ */
27
+ let settled = false;
28
+ const settle = value => {
29
+ if(settled) return;
30
+ settled = true;
31
+ resolve(value);
32
+ };
33
+
34
+ const chosen = asset => ({
35
+ url: `/${asset.path}`,
36
+ // The asset's own alt text describes the file; fall back to whatever was already typed
37
+ alt: asset.altText || alt
38
+ });
39
+
40
+ library.addEventListener('asset-confirmed', e => {
41
+ settle(chosen(e.detail.asset));
42
+ dialog?.close();
43
+ });
44
+
45
+ const dialog = Dialog.create(library, {
46
+ title: 'Select media',
47
+ confirmText: 'Insert',
48
+ cancelText: 'Cancel',
49
+ confirmAction: event => {
50
+ const asset = library.selectedAsset;
51
+ if(!asset){
52
+ // Nothing picked yet — keep the dialog up rather than silently inserting nothing
53
+ event.keepDialogOpen = true;
54
+ return;
55
+ }
56
+ settle(chosen(asset));
57
+ },
58
+ // Fires on every dismissal route: cancel, the close button, the overlay, and Escape
59
+ closeCallback: () => settle(null)
60
+ });
61
+ });
62
+ </script>
63
+ </content>