kempo-media 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +1 -0
- package/.gitattributes +5 -0
- package/.github/workflows/publish.yml +85 -0
- package/.github/workflows/test.yml +123 -0
- package/CHANGELOG.md +25 -0
- package/README.md +114 -0
- package/admin/asset-picker.global.html +63 -0
- package/admin/components/MediaLibrary.js +304 -0
- package/admin/icons/archive.svg +1 -0
- package/admin/icons/audio.svg +1 -0
- package/admin/icons/image.svg +1 -0
- package/admin/icons/model3d.svg +1 -0
- package/admin/icons/video.svg +1 -0
- package/admin/index.page.html +35 -0
- package/admin/nav.global.html +3 -0
- package/docker-compose.yml +22 -0
- package/drizzle.config.js +26 -0
- package/install.js +18 -0
- package/kempo-config.json +18 -0
- package/package.json +39 -0
- package/public/api/media/DELETE.js +21 -0
- package/public/api/media/GET.js +38 -0
- package/public/api/media/PATCH.js +18 -0
- package/public/api/media/POST.js +57 -0
- package/public/sdk.js +62 -0
- package/scripts/build.js +0 -0
- package/sdk.js +15 -0
- package/server/db/schema.js +18 -0
- package/server/utils/media/createMediaAsset.js +19 -0
- package/server/utils/media/deleteMediaAsset.js +37 -0
- package/server/utils/media/getMediaAsset.js +14 -0
- package/server/utils/media/getMediaAssets.js +39 -0
- package/server/utils/media/updateMediaAsset.js +32 -0
- package/server/utils/uploads/fileTypes.js +50 -0
- package/server/utils/uploads/generateThumbnail.js +63 -0
- package/server/utils/uploads/parseMultipart.js +99 -0
- package/server/utils/uploads/storeUpload.js +89 -0
- package/tests/browser-fixtures.node-test.js +61 -0
- package/tests/browser-modules-parse.node-test.js +62 -0
- package/tests/components/MediaLibrary.browser-test.js +412 -0
- package/tests/fixtures/kempo-media-sdk-mock/sdk.js +16 -0
- package/tests/media-api-contract.node-test.js +363 -0
- package/tests/media-data-layer.node-test.js +268 -0
- package/tests/module-graph.node-test.js +86 -0
- package/tests/package-invariants.node-test.js +247 -0
- package/tests/permission-name-consistency.node-test.js +102 -0
- package/tests/uploads.node-test.js +255 -0
- package/update.js +3 -0
package/public/sdk.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const BASE = '/kempo-media/api';
|
|
2
|
+
|
|
3
|
+
const req = async (method, path, data) => {
|
|
4
|
+
const opts = { method, headers: {} };
|
|
5
|
+
if(data && method !== 'GET'){
|
|
6
|
+
opts.headers['Content-Type'] = 'application/json';
|
|
7
|
+
opts.body = JSON.stringify(data);
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(path, opts);
|
|
11
|
+
const json = await res.json().catch(() => ({}));
|
|
12
|
+
if(!res.ok) return [{ code: res.status, msg: json.error || 'An error occurred' }, null];
|
|
13
|
+
return [null, json];
|
|
14
|
+
} catch {
|
|
15
|
+
return [{ code: 503, msg: 'Network error' }, null];
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const buildQuery = params => {
|
|
20
|
+
const pairs = Object.entries(params)
|
|
21
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
22
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
|
|
23
|
+
return pairs.length ? `?${pairs.join('&')}` : '';
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const getMediaAssets = (params = {}) => req('GET', `${BASE}/media${buildQuery(params)}`);
|
|
27
|
+
export const getMediaAsset = id => req('GET', `${BASE}/media${buildQuery({ id })}`);
|
|
28
|
+
export const updateMediaAsset = data => req('PATCH', `${BASE}/media`, data);
|
|
29
|
+
export const deleteMediaAsset = id => req('DELETE', `${BASE}/media`, { id });
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
Uploads go through XMLHttpRequest rather than fetch purely for `upload.onprogress` — fetch still
|
|
33
|
+
has no way to report how far a request body has been sent, and a progress bar is the difference
|
|
34
|
+
between a usable upload of a large video and one that looks frozen.
|
|
35
|
+
*/
|
|
36
|
+
export const uploadMedia = (file, { alt = '' } = {}, onProgress) => new Promise(resolve => {
|
|
37
|
+
const form = new FormData();
|
|
38
|
+
form.append('file', file);
|
|
39
|
+
if(alt) form.append('alt', alt);
|
|
40
|
+
|
|
41
|
+
const xhr = new XMLHttpRequest();
|
|
42
|
+
xhr.open('POST', `${BASE}/media`);
|
|
43
|
+
|
|
44
|
+
xhr.upload.addEventListener('progress', e => {
|
|
45
|
+
if(e.lengthComputable) onProgress?.(e.loaded / e.total);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
xhr.addEventListener('load', () => {
|
|
49
|
+
let json = {};
|
|
50
|
+
try { json = JSON.parse(xhr.responseText); } catch { /* a non-JSON error page */ }
|
|
51
|
+
if(xhr.status >= 200 && xhr.status < 300){
|
|
52
|
+
onProgress?.(1);
|
|
53
|
+
return resolve([null, json]);
|
|
54
|
+
}
|
|
55
|
+
resolve([{ code: xhr.status, msg: json.error || `Upload failed (${xhr.status})` }, null]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
xhr.addEventListener('error', () => resolve([{ code: 503, msg: 'Network error' }, null]));
|
|
59
|
+
xhr.addEventListener('abort', () => resolve([{ code: 499, msg: 'Upload cancelled' }, null]));
|
|
60
|
+
|
|
61
|
+
xhr.send(form);
|
|
62
|
+
});
|
package/scripts/build.js
ADDED
|
File without changes
|
package/sdk.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Server-side entry point, for hooks and other extensions that want to reach media assets in
|
|
3
|
+
process rather than over HTTP. The browser-facing client is public/sdk.js, served at
|
|
4
|
+
/kempo-media/sdk.js — same names, different transport.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { default as createMediaAsset } from './server/utils/media/createMediaAsset.js';
|
|
8
|
+
export { default as getMediaAsset } from './server/utils/media/getMediaAsset.js';
|
|
9
|
+
export { default as getMediaAssets } from './server/utils/media/getMediaAssets.js';
|
|
10
|
+
export { default as updateMediaAsset } from './server/utils/media/updateMediaAsset.js';
|
|
11
|
+
export { default as deleteMediaAsset } from './server/utils/media/deleteMediaAsset.js';
|
|
12
|
+
|
|
13
|
+
export { default as storeUpload } from './server/utils/uploads/storeUpload.js';
|
|
14
|
+
export { default as parseMultipart, extractBoundary } from './server/utils/uploads/parseMultipart.js';
|
|
15
|
+
export { ALLOWED_TYPES, KINDS, typeForFilename } from './server/utils/uploads/fileTypes.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { pgTable, text, integer, timestamp } from 'drizzle-orm/pg-core';
|
|
2
|
+
|
|
3
|
+
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
|
|
6
|
+
originalName: text('originalName').notNull(),
|
|
7
|
+
mimeType: text('mimeType').notNull(),
|
|
8
|
+
kind: text('kind').notNull(), // 'image' | 'video' | 'audio' | 'model3d' | 'archive'
|
|
9
|
+
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
|
+
altText: text('altText').notNull().default(''),
|
|
13
|
+
width: integer('width'),
|
|
14
|
+
height: integer('height'),
|
|
15
|
+
uploadedBy: text('uploadedBy').notNull(),
|
|
16
|
+
createdAt: timestamp('createdAt').notNull(),
|
|
17
|
+
updatedAt: timestamp('updatedAt').notNull(),
|
|
18
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import db from 'kempo/server/db/index.js';
|
|
2
|
+
import { kempoMediaAsset } from '../../db/schema.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Records an asset that storeUpload has already written to disk. Kept separate from the write so a
|
|
6
|
+
failure to record does not leave the caller unsure whether the bytes landed.
|
|
7
|
+
*/
|
|
8
|
+
export default async (asset) => {
|
|
9
|
+
try {
|
|
10
|
+
const now = new Date();
|
|
11
|
+
const [row] = await db
|
|
12
|
+
.insert(kempoMediaAsset)
|
|
13
|
+
.values({ ...asset, createdAt: now, updatedAt: now })
|
|
14
|
+
.returning();
|
|
15
|
+
return [null, row];
|
|
16
|
+
} catch(e) {
|
|
17
|
+
return [{ code: 500, msg: e.message }, null];
|
|
18
|
+
}
|
|
19
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { unlink } from 'fs/promises';
|
|
3
|
+
import db from 'kempo/server/db/index.js';
|
|
4
|
+
import { kempoMediaAsset } from '../../db/schema.js';
|
|
5
|
+
import { eq } from 'drizzle-orm';
|
|
6
|
+
import getMediaAsset from './getMediaAsset.js';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
Removes the row and the files behind it.
|
|
10
|
+
|
|
11
|
+
A file already missing from disk is not treated as a failure: the row is the thing that makes the
|
|
12
|
+
asset visible, so refusing to delete it because its file was removed by hand would leave a
|
|
13
|
+
permanently undeletable entry pointing at nothing.
|
|
14
|
+
*/
|
|
15
|
+
export default async (id, { rootDir }) => {
|
|
16
|
+
const [findError, asset] = await getMediaAsset(id);
|
|
17
|
+
if(findError) return [findError, null];
|
|
18
|
+
|
|
19
|
+
for(const relativePath of [asset.path, asset.thumbnailPath].filter(Boolean)){
|
|
20
|
+
try {
|
|
21
|
+
await unlink(join(rootDir, relativePath));
|
|
22
|
+
} catch(e) {
|
|
23
|
+
if(e.code !== 'ENOENT'){
|
|
24
|
+
return [{ code: 500, msg: `Could not delete ${relativePath}: ${e.message}` }, null];
|
|
25
|
+
}
|
|
26
|
+
console.warn(`[kempo-media] ${relativePath} was already gone from disk`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await db.delete(kempoMediaAsset).where(eq(kempoMediaAsset.id, id));
|
|
32
|
+
} catch(e) {
|
|
33
|
+
return [{ code: 500, msg: e.message }, null];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return [null, { id }];
|
|
37
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import db from 'kempo/server/db/index.js';
|
|
2
|
+
import { kempoMediaAsset } from '../../db/schema.js';
|
|
3
|
+
import { eq } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
export default async (id) => {
|
|
6
|
+
if(!id) return [{ code: 400, msg: 'An asset id is required' }, null];
|
|
7
|
+
try {
|
|
8
|
+
const [row] = await db.select().from(kempoMediaAsset).where(eq(kempoMediaAsset.id, id));
|
|
9
|
+
if(!row) return [{ code: 404, msg: 'Asset not found' }, null];
|
|
10
|
+
return [null, row];
|
|
11
|
+
} catch(e) {
|
|
12
|
+
return [{ code: 500, msg: e.message }, null];
|
|
13
|
+
}
|
|
14
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import db from 'kempo/server/db/index.js';
|
|
2
|
+
import { kempoMediaAsset } from '../../db/schema.js';
|
|
3
|
+
import { eq, and, desc, count, ilike, inArray } from 'drizzle-orm';
|
|
4
|
+
import { KINDS } from '../uploads/fileTypes.js';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Newest first, which is what a library browser wants — the asset you just uploaded is the one you
|
|
8
|
+
are about to use.
|
|
9
|
+
*/
|
|
10
|
+
export default async ({ limit = 24, offset = 0, kind, search } = {}) => {
|
|
11
|
+
try {
|
|
12
|
+
const conditions = [];
|
|
13
|
+
|
|
14
|
+
if(kind){
|
|
15
|
+
// Accepts a single kind or a comma-separated list; unknown values are ignored rather than
|
|
16
|
+
// silently matching nothing, so a typo in a query string does not look like an empty library
|
|
17
|
+
const kinds = String(kind).split(',').map(k => k.trim()).filter(k => KINDS.includes(k));
|
|
18
|
+
if(kinds.length) conditions.push(kinds.length === 1 ? eq(kempoMediaAsset.kind, kinds[0]) : inArray(kempoMediaAsset.kind, kinds));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if(search) conditions.push(ilike(kempoMediaAsset.originalName, `%${search}%`));
|
|
22
|
+
|
|
23
|
+
const where = conditions.length ? and(...conditions) : undefined;
|
|
24
|
+
|
|
25
|
+
const [{ total }] = await db.select({ total: count() }).from(kempoMediaAsset).where(where);
|
|
26
|
+
|
|
27
|
+
const assets = await db
|
|
28
|
+
.select()
|
|
29
|
+
.from(kempoMediaAsset)
|
|
30
|
+
.where(where)
|
|
31
|
+
.orderBy(desc(kempoMediaAsset.createdAt))
|
|
32
|
+
.limit(limit)
|
|
33
|
+
.offset(offset);
|
|
34
|
+
|
|
35
|
+
return [null, { assets, total, limit, offset }];
|
|
36
|
+
} catch(e) {
|
|
37
|
+
return [{ code: 500, msg: e.message }, null];
|
|
38
|
+
}
|
|
39
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import db from 'kempo/server/db/index.js';
|
|
2
|
+
import { kempoMediaAsset } from '../../db/schema.js';
|
|
3
|
+
import { eq } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Only the display fields are editable. Everything else — the stored filename, path, kind, size —
|
|
7
|
+
describes bytes already on disk, so letting it be edited would just make the row lie about them.
|
|
8
|
+
*/
|
|
9
|
+
export default async (id, { altText, originalName } = {}) => {
|
|
10
|
+
if(!id) return [{ code: 400, msg: 'An asset id is required' }, null];
|
|
11
|
+
|
|
12
|
+
const updates = {};
|
|
13
|
+
if(altText !== undefined) updates.altText = String(altText);
|
|
14
|
+
if(originalName !== undefined){
|
|
15
|
+
const trimmed = String(originalName).trim();
|
|
16
|
+
if(!trimmed) return [{ code: 400, msg: 'A name cannot be empty' }, null];
|
|
17
|
+
updates.originalName = trimmed;
|
|
18
|
+
}
|
|
19
|
+
if(!Object.keys(updates).length) return [{ code: 400, msg: 'Nothing to update' }, null];
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const [row] = await db
|
|
23
|
+
.update(kempoMediaAsset)
|
|
24
|
+
.set({ ...updates, updatedAt: new Date() })
|
|
25
|
+
.where(eq(kempoMediaAsset.id, id))
|
|
26
|
+
.returning();
|
|
27
|
+
if(!row) return [{ code: 404, msg: 'Asset not found' }, null];
|
|
28
|
+
return [null, row];
|
|
29
|
+
} catch(e) {
|
|
30
|
+
return [{ code: 500, msg: e.message }, null];
|
|
31
|
+
}
|
|
32
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/*
|
|
2
|
+
What this extension accepts, and how each kind is presented in the library.
|
|
3
|
+
|
|
4
|
+
Deliberately excluded: SVG. kempo-server serves it as inline, unsanitised markup on the site's own
|
|
5
|
+
origin, so an uploaded SVG carrying a <script> is stored XSS against the very admin who uploaded
|
|
6
|
+
it. Raster images cover what the editor actually needs; adding SVG means adding a sanitiser first.
|
|
7
|
+
|
|
8
|
+
These extensions are all registered in kempo-server's default allowedMimes as of 3.2.0, so an
|
|
9
|
+
uploaded file is served with a correct Content-Type — and, being binary, with range support, so
|
|
10
|
+
video and audio seek properly.
|
|
11
|
+
*/
|
|
12
|
+
export const ALLOWED_TYPES = {
|
|
13
|
+
png: { kind: 'image', mime: 'image/png' },
|
|
14
|
+
jpg: { kind: 'image', mime: 'image/jpeg' },
|
|
15
|
+
jpeg: { kind: 'image', mime: 'image/jpeg' },
|
|
16
|
+
gif: { kind: 'image', mime: 'image/gif' },
|
|
17
|
+
webp: { kind: 'image', mime: 'image/webp' },
|
|
18
|
+
avif: { kind: 'image', mime: 'image/avif' },
|
|
19
|
+
|
|
20
|
+
mp4: { kind: 'video', mime: 'video/mp4' },
|
|
21
|
+
m4v: { kind: 'video', mime: 'video/mp4' },
|
|
22
|
+
webm: { kind: 'video', mime: 'video/webm' },
|
|
23
|
+
ogv: { kind: 'video', mime: 'video/ogg' },
|
|
24
|
+
|
|
25
|
+
mp3: { kind: 'audio', mime: 'audio/mpeg' },
|
|
26
|
+
ogg: { kind: 'audio', mime: 'audio/ogg' },
|
|
27
|
+
wav: { kind: 'audio', mime: 'audio/wav' },
|
|
28
|
+
|
|
29
|
+
glb: { kind: 'model3d', mime: 'model/gltf-binary' },
|
|
30
|
+
gltf: { kind: 'model3d', mime: 'model/gltf+json' },
|
|
31
|
+
obj: { kind: 'model3d', mime: 'model/obj' },
|
|
32
|
+
fbx: { kind: 'model3d', mime: 'application/octet-stream' },
|
|
33
|
+
|
|
34
|
+
zip: { kind: 'archive', mime: 'application/zip' },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const KINDS = ['image', 'video', 'audio', 'model3d', 'archive'];
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
Takes only the final extension of the original name, lowercased. The name itself is never used to
|
|
41
|
+
build a path — see storeUpload — so this is a type decision, not a filesystem one.
|
|
42
|
+
*/
|
|
43
|
+
export const typeForFilename = filename => {
|
|
44
|
+
if(typeof filename !== 'string') return null;
|
|
45
|
+
const dot = filename.lastIndexOf('.');
|
|
46
|
+
if(dot === -1 || dot === filename.length - 1) return null;
|
|
47
|
+
const ext = filename.slice(dot + 1).toLowerCase();
|
|
48
|
+
const type = ALLOWED_TYPES[ext];
|
|
49
|
+
return type ? { ext, ...type } : null;
|
|
50
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { writeFile, mkdir } from 'fs/promises';
|
|
3
|
+
import { THUMBNAIL_DIR } from './storeUpload.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
sharp is a native module, so it is the one dependency here that can fail to install on an unusual
|
|
7
|
+
host (musl without the right build flags, an unsupported architecture). Importing it lazily means
|
|
8
|
+
that failure surfaces as "this image has no thumbnail" rather than as a route that will not load
|
|
9
|
+
at all — the upload itself never depends on it.
|
|
10
|
+
*/
|
|
11
|
+
let sharpModule;
|
|
12
|
+
let sharpUnavailable = false;
|
|
13
|
+
|
|
14
|
+
const loadSharp = async () => {
|
|
15
|
+
if(sharpUnavailable) return null;
|
|
16
|
+
if(sharpModule) return sharpModule;
|
|
17
|
+
try {
|
|
18
|
+
sharpModule = (await import('sharp')).default;
|
|
19
|
+
return sharpModule;
|
|
20
|
+
} catch(e) {
|
|
21
|
+
sharpUnavailable = true;
|
|
22
|
+
console.warn(`[kempo-media] sharp is unavailable, so no thumbnails will be generated: ${e.message}`);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
Produces a WebP thumbnail for a raster image and reports the source's dimensions.
|
|
29
|
+
|
|
30
|
+
`fit: 'inside'` scales the longest edge down to maxDimension and leaves the aspect ratio alone;
|
|
31
|
+
`withoutEnlargement` keeps an image that is already smaller than the box at its original size
|
|
32
|
+
rather than upscaling it into a blurrier, larger file.
|
|
33
|
+
|
|
34
|
+
Returns the usual [error, data] tuple. Callers treat an error as "no preview", not as a failed
|
|
35
|
+
upload — see storeUpload.
|
|
36
|
+
*/
|
|
37
|
+
export default async ({ rootDir, data, id, maxDimension = 480 }) => {
|
|
38
|
+
const sharp = await loadSharp();
|
|
39
|
+
if(!sharp) return [{ code: 501, msg: 'sharp is not available' }, null];
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const image = sharp(data, { failOn: 'none' });
|
|
43
|
+
const metadata = await image.metadata();
|
|
44
|
+
|
|
45
|
+
const thumbnail = await image
|
|
46
|
+
.resize({ width: maxDimension, height: maxDimension, fit: 'inside', withoutEnlargement: true })
|
|
47
|
+
.webp()
|
|
48
|
+
.toBuffer();
|
|
49
|
+
|
|
50
|
+
const relativePath = join(THUMBNAIL_DIR, `${id}.webp`).replace(/\\/g, '/');
|
|
51
|
+
await mkdir(join(rootDir, THUMBNAIL_DIR), { recursive: true });
|
|
52
|
+
await writeFile(join(rootDir, relativePath), thumbnail);
|
|
53
|
+
|
|
54
|
+
return [null, {
|
|
55
|
+
thumbnailPath: relativePath,
|
|
56
|
+
width: metadata.width ?? null,
|
|
57
|
+
height: metadata.height ?? null,
|
|
58
|
+
}];
|
|
59
|
+
} catch(e) {
|
|
60
|
+
// Anything that is not really a decodable image lands here, including a mislabelled extension
|
|
61
|
+
return [{ code: 422, msg: e.message }, null];
|
|
62
|
+
}
|
|
63
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/*
|
|
2
|
+
A minimal multipart/form-data parser, working entirely on Buffers.
|
|
3
|
+
|
|
4
|
+
Nothing in kempo or kempo-server parses multipart, and the usual dependency for it (busboy and
|
|
5
|
+
friends) is built around streaming a body that is still arriving. kempo-server buffers the whole
|
|
6
|
+
body before a route ever runs, so there is no stream left to consume by then and none of that
|
|
7
|
+
machinery earns its keep. This handles the shape an upload form actually sends — file parts plus
|
|
8
|
+
a few text fields — and nothing else.
|
|
9
|
+
|
|
10
|
+
Every offset is found with Buffer.indexOf and every slice stays a Buffer, so file content is
|
|
11
|
+
never decoded. Decoding it as UTF-8 is precisely the corruption kempo-server 3.2.0 fixed, and
|
|
12
|
+
doing it here would reintroduce it one layer up.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const CRLF = Buffer.from('\r\n');
|
|
16
|
+
const CRLF_CRLF = Buffer.from('\r\n\r\n');
|
|
17
|
+
const DASH_DASH = Buffer.from('--');
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
`multipart/form-data; boundary=----WebKitFormBoundaryAbC123`, optionally quoted. Returns null when
|
|
21
|
+
the header is absent or is not multipart, which the caller turns into a 400.
|
|
22
|
+
*/
|
|
23
|
+
export const extractBoundary = contentType => {
|
|
24
|
+
if(!contentType || !contentType.toLowerCase().includes('multipart/form-data')) return null;
|
|
25
|
+
const match = contentType.match(/boundary=(?:"([^"]+)"|([^;\s]+))/i);
|
|
26
|
+
return match ? (match[1] || match[2]) : null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const parseHeaders = raw => {
|
|
30
|
+
const headers = {};
|
|
31
|
+
for(const line of raw.split('\r\n')){
|
|
32
|
+
const colon = line.indexOf(':');
|
|
33
|
+
if(colon === -1) continue;
|
|
34
|
+
headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
|
|
35
|
+
}
|
|
36
|
+
return headers;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// name="field"; filename="photo.png" — filename is absent on plain text fields
|
|
40
|
+
const quoted = (source, key) => {
|
|
41
|
+
const match = source.match(new RegExp(`${key}="([^"]*)"`, 'i'));
|
|
42
|
+
return match ? match[1] : null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
Returns [{ name, filename, contentType, data }], where `data` is always a Buffer. Parts without a
|
|
47
|
+
Content-Disposition name are skipped rather than treated as an error, since a stray part should
|
|
48
|
+
not fail an otherwise valid upload.
|
|
49
|
+
*/
|
|
50
|
+
export default (buffer, boundary) => {
|
|
51
|
+
if(!Buffer.isBuffer(buffer) || !boundary) return [];
|
|
52
|
+
|
|
53
|
+
const delimiter = Buffer.from(`\r\n--${boundary}`);
|
|
54
|
+
/*
|
|
55
|
+
The opening boundary is at the very start of the body with no CRLF before it, unlike every
|
|
56
|
+
later one. Prefixing a CRLF makes all of them identical so a single search handles the lot.
|
|
57
|
+
*/
|
|
58
|
+
const body = Buffer.concat([CRLF, buffer]);
|
|
59
|
+
|
|
60
|
+
const parts = [];
|
|
61
|
+
let index = body.indexOf(delimiter);
|
|
62
|
+
|
|
63
|
+
while(index !== -1){
|
|
64
|
+
const afterBoundary = index + delimiter.length;
|
|
65
|
+
|
|
66
|
+
// A boundary followed by "--" is the closing delimiter; anything after it is epilogue
|
|
67
|
+
if(body.subarray(afterBoundary, afterBoundary + 2).equals(DASH_DASH)) break;
|
|
68
|
+
|
|
69
|
+
const next = body.indexOf(delimiter, afterBoundary);
|
|
70
|
+
if(next === -1) break; // truncated body — drop the trailing fragment rather than guess at it
|
|
71
|
+
|
|
72
|
+
// The boundary line may carry trailing whitespace before its CRLF
|
|
73
|
+
const lineEnd = body.indexOf(CRLF, afterBoundary);
|
|
74
|
+
if(lineEnd === -1 || lineEnd > next) break;
|
|
75
|
+
|
|
76
|
+
const headerEnd = body.indexOf(CRLF_CRLF, lineEnd);
|
|
77
|
+
if(headerEnd === -1 || headerEnd > next){
|
|
78
|
+
index = next;
|
|
79
|
+
continue; // no header block — not a part worth keeping
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const headers = parseHeaders(body.subarray(lineEnd + CRLF.length, headerEnd).toString('utf8'));
|
|
83
|
+
const disposition = headers['content-disposition'] || '';
|
|
84
|
+
const name = quoted(disposition, 'name');
|
|
85
|
+
|
|
86
|
+
if(name !== null){
|
|
87
|
+
parts.push({
|
|
88
|
+
name,
|
|
89
|
+
filename: quoted(disposition, 'filename'),
|
|
90
|
+
contentType: headers['content-type'] || null,
|
|
91
|
+
data: body.subarray(headerEnd + CRLF_CRLF.length, next)
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
index = next;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return parts;
|
|
99
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { writeFile, mkdir } from 'fs/promises';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { typeForFilename } from './fileTypes.js';
|
|
5
|
+
import generateThumbnail from './generateThumbnail.js';
|
|
6
|
+
|
|
7
|
+
export const UPLOAD_DIR = join('media', 'uploads');
|
|
8
|
+
export const THUMBNAIL_DIR = join('media', 'thumbnails');
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
Writes an uploaded file into the site's own public/ directory and returns the row to record.
|
|
12
|
+
|
|
13
|
+
Files go to the *site's* public/, never anywhere inside this package: node_modules is replaced
|
|
14
|
+
wholesale on every install, so anything written there is one `npm install` away from deletion.
|
|
15
|
+
It also means kempo-server's own static scanner serves them, which is where range support for
|
|
16
|
+
video and audio seeking comes from — this extension serves no files itself.
|
|
17
|
+
|
|
18
|
+
Returns the usual [error, data] tuple.
|
|
19
|
+
*/
|
|
20
|
+
export default async ({ rootDir, filename, data, mimeType, altText = '', uploadedBy, maxBytes, thumbnailMaxDimension }) => {
|
|
21
|
+
if(!Buffer.isBuffer(data) || data.length === 0){
|
|
22
|
+
return [{ code: 400, msg: 'No file content was uploaded' }, null];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const type = typeForFilename(filename);
|
|
26
|
+
if(!type){
|
|
27
|
+
return [{ code: 415, msg: `Unsupported file type: ${filename}` }, null];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if(maxBytes && data.length > maxBytes){
|
|
31
|
+
const limitMb = Math.round(maxBytes / (1024 * 1024));
|
|
32
|
+
return [{ code: 413, msg: `File is larger than the ${limitMb}MB limit` }, null];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
The name on disk is generated, never derived from what the client sent. A filename is fully
|
|
37
|
+
attacker-controlled and is the classic path-traversal vector ("../../.env"), quite apart from
|
|
38
|
+
collisions between two people uploading "photo.jpg". The original is kept for display only.
|
|
39
|
+
*/
|
|
40
|
+
const id = crypto.randomBytes(8).toString('hex');
|
|
41
|
+
const storedName = `${id}.${type.ext}`;
|
|
42
|
+
const relativePath = join(UPLOAD_DIR, storedName).replace(/\\/g, '/');
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await mkdir(join(rootDir, UPLOAD_DIR), { recursive: true });
|
|
46
|
+
await writeFile(join(rootDir, relativePath), data);
|
|
47
|
+
} catch(e) {
|
|
48
|
+
return [{ code: 500, msg: `Could not write the uploaded file: ${e.message}` }, null];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/*
|
|
52
|
+
Thumbnails are images only; every other kind shows a type icon in the library instead. A
|
|
53
|
+
failure here is deliberately not fatal — the asset is already on disk and perfectly usable, so
|
|
54
|
+
it is recorded without a preview rather than rejected.
|
|
55
|
+
*/
|
|
56
|
+
let thumbnailPath = null;
|
|
57
|
+
let width = null;
|
|
58
|
+
let height = null;
|
|
59
|
+
if(type.kind === 'image'){
|
|
60
|
+
const [thumbError, thumb] = await generateThumbnail({
|
|
61
|
+
rootDir,
|
|
62
|
+
data,
|
|
63
|
+
id,
|
|
64
|
+
maxDimension: thumbnailMaxDimension
|
|
65
|
+
});
|
|
66
|
+
if(thumbError){
|
|
67
|
+
console.warn(`[kempo-media] No thumbnail for ${filename}: ${thumbError.msg}`);
|
|
68
|
+
} else {
|
|
69
|
+
thumbnailPath = thumb.thumbnailPath;
|
|
70
|
+
width = thumb.width;
|
|
71
|
+
height = thumb.height;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return [null, {
|
|
76
|
+
id,
|
|
77
|
+
filename: storedName,
|
|
78
|
+
originalName: filename,
|
|
79
|
+
mimeType: mimeType || type.mime,
|
|
80
|
+
kind: type.kind,
|
|
81
|
+
sizeBytes: data.length,
|
|
82
|
+
path: relativePath,
|
|
83
|
+
thumbnailPath,
|
|
84
|
+
altText,
|
|
85
|
+
width,
|
|
86
|
+
height,
|
|
87
|
+
uploadedBy,
|
|
88
|
+
}];
|
|
89
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { stat, rm, symlink } from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
MediaLibrary.js imports absolute /kempo-ui/... and /kempo-media/sdk.js URLs — the same paths a
|
|
7
|
+
real kempo-server site resolves via customRoutes and this extension's own public-scope routing.
|
|
8
|
+
The lightweight static server kempo-testing-framework's browser tests run under has neither: it
|
|
9
|
+
only serves "." + the request path relative to this repo's own root (see its own
|
|
10
|
+
browserTestServer.js — there is no config for a different resolution root). So
|
|
11
|
+
MediaLibrary.browser-test.js needs literal <root>/kempo-ui and <root>/kempo-media directories to
|
|
12
|
+
exist for those imports to resolve at all.
|
|
13
|
+
|
|
14
|
+
Both are junctions created here rather than committed: kempo-ui because it's someone else's large
|
|
15
|
+
built package (already present under node_modules), and kempo-media because the real target must
|
|
16
|
+
be a *fake* sdk.js (tests/fixtures/kempo-media-sdk-mock/sdk.js) — the real public/sdk.js talks to
|
|
17
|
+
a real server this suite never boots.
|
|
18
|
+
|
|
19
|
+
This lives in a *.node-test.js file specifically so it always finishes before any browser test
|
|
20
|
+
starts: kempo-test runs every node test to completion before touching a single browser test (see
|
|
21
|
+
runTestFiles.js), which is the only ordering guarantee available without a dedicated setup hook.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
|
|
26
|
+
const ensureJunction = async (linkPath, targetPath) => {
|
|
27
|
+
try {
|
|
28
|
+
const stats = await stat(linkPath);
|
|
29
|
+
if(stats.isDirectory()) return; // already present and resolving
|
|
30
|
+
} catch { /* fall through and create it */ }
|
|
31
|
+
await rm(linkPath, { recursive: true, force: true }).catch(() => {});
|
|
32
|
+
await symlink(targetPath, linkPath, 'junction');
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const tests = {
|
|
36
|
+
'links /kempo-ui to the installed kempo-ui package for browser tests': async ({ pass, fail }) => {
|
|
37
|
+
const link = path.join(root, 'kempo-ui');
|
|
38
|
+
const target = path.join(root, 'node_modules', 'kempo-ui', 'dist');
|
|
39
|
+
await ensureJunction(link, target);
|
|
40
|
+
try {
|
|
41
|
+
await stat(path.join(link, 'lit-all.min.js'));
|
|
42
|
+
} catch {
|
|
43
|
+
return fail('kempo-ui junction created but lit-all.min.js is not reachable through it');
|
|
44
|
+
}
|
|
45
|
+
pass();
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
'links /kempo-media to a fake sdk.js for browser tests': async ({ pass, fail }) => {
|
|
49
|
+
const link = path.join(root, 'kempo-media');
|
|
50
|
+
const target = path.join(root, 'tests', 'fixtures', 'kempo-media-sdk-mock');
|
|
51
|
+
await ensureJunction(link, target);
|
|
52
|
+
try {
|
|
53
|
+
await stat(path.join(link, 'sdk.js'));
|
|
54
|
+
} catch {
|
|
55
|
+
return fail('kempo-media junction created but sdk.js is not reachable through it');
|
|
56
|
+
}
|
|
57
|
+
pass();
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export default tests;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readdir } from 'fs/promises';
|
|
2
|
+
import { execFile } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
const run = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
The browser-side modules under admin/ and public/ are never imported by the node test suite —
|
|
11
|
+
they pull in absolute /kempo-ui/... URLs that only resolve in a browser — so nothing else here
|
|
12
|
+
would notice if one stopped parsing.
|
|
13
|
+
|
|
14
|
+
That is not hypothetical. A backtick inside a comment within an html`` or css`` template literal
|
|
15
|
+
terminates the literal, and the rest of the file is then parsed as JavaScript. The module fails
|
|
16
|
+
to load, the custom element silently never upgrades, and the page renders bare markup with no
|
|
17
|
+
error beyond one line in the browser console. It happened twice while building this.
|
|
18
|
+
|
|
19
|
+
`node --check` parses without executing, so it catches exactly this without needing a browser.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
|
|
24
|
+
const walk = async dir => {
|
|
25
|
+
let entries;
|
|
26
|
+
try {
|
|
27
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const files = [];
|
|
32
|
+
for(const entry of entries){
|
|
33
|
+
const full = path.join(dir, entry.name);
|
|
34
|
+
if(entry.isDirectory()) files.push(...await walk(full));
|
|
35
|
+
else if(entry.name.endsWith('.js')) files.push(full);
|
|
36
|
+
}
|
|
37
|
+
return files;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const files = (await Promise.all(['admin', 'public'].map(d => walk(path.join(root, d))))).flat();
|
|
41
|
+
|
|
42
|
+
const tests = {
|
|
43
|
+
'browser modules are discoverable': async ({ pass, fail }) => {
|
|
44
|
+
if(!files.length) return fail('found no browser modules — this suite would pass without checking anything');
|
|
45
|
+
pass(`${files.length} module(s)`);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
for(const file of files){
|
|
50
|
+
const rel = path.relative(root, file).replace(/\\/g, '/');
|
|
51
|
+
tests[`parses ${rel}`] = async ({ pass, fail }) => {
|
|
52
|
+
try {
|
|
53
|
+
await run(process.execPath, ['--check', file]);
|
|
54
|
+
} catch(e) {
|
|
55
|
+
const detail = (e.stderr || e.message).split('\n').find(l => l.includes('Error')) || e.message;
|
|
56
|
+
return fail(detail.trim());
|
|
57
|
+
}
|
|
58
|
+
pass();
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default tests;
|