kempo-media 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.env.example +1 -0
  2. package/.gitattributes +5 -0
  3. package/.github/workflows/publish.yml +85 -0
  4. package/.github/workflows/test.yml +123 -0
  5. package/CHANGELOG.md +25 -0
  6. package/README.md +114 -0
  7. package/admin/asset-picker.global.html +63 -0
  8. package/admin/components/MediaLibrary.js +304 -0
  9. package/admin/icons/archive.svg +1 -0
  10. package/admin/icons/audio.svg +1 -0
  11. package/admin/icons/image.svg +1 -0
  12. package/admin/icons/model3d.svg +1 -0
  13. package/admin/icons/video.svg +1 -0
  14. package/admin/index.page.html +35 -0
  15. package/admin/nav.global.html +3 -0
  16. package/docker-compose.yml +22 -0
  17. package/drizzle.config.js +26 -0
  18. package/install.js +18 -0
  19. package/kempo-config.json +18 -0
  20. package/package.json +39 -0
  21. package/public/api/media/DELETE.js +21 -0
  22. package/public/api/media/GET.js +38 -0
  23. package/public/api/media/PATCH.js +18 -0
  24. package/public/api/media/POST.js +57 -0
  25. package/public/sdk.js +62 -0
  26. package/scripts/build.js +0 -0
  27. package/sdk.js +15 -0
  28. package/server/db/schema.js +18 -0
  29. package/server/utils/media/createMediaAsset.js +19 -0
  30. package/server/utils/media/deleteMediaAsset.js +37 -0
  31. package/server/utils/media/getMediaAsset.js +14 -0
  32. package/server/utils/media/getMediaAssets.js +39 -0
  33. package/server/utils/media/updateMediaAsset.js +32 -0
  34. package/server/utils/uploads/fileTypes.js +50 -0
  35. package/server/utils/uploads/generateThumbnail.js +63 -0
  36. package/server/utils/uploads/parseMultipart.js +99 -0
  37. package/server/utils/uploads/storeUpload.js +89 -0
  38. package/tests/browser-fixtures.node-test.js +61 -0
  39. package/tests/browser-modules-parse.node-test.js +62 -0
  40. package/tests/components/MediaLibrary.browser-test.js +412 -0
  41. package/tests/fixtures/kempo-media-sdk-mock/sdk.js +16 -0
  42. package/tests/media-api-contract.node-test.js +363 -0
  43. package/tests/media-data-layer.node-test.js +268 -0
  44. package/tests/module-graph.node-test.js +86 -0
  45. package/tests/package-invariants.node-test.js +247 -0
  46. package/tests/permission-name-consistency.node-test.js +102 -0
  47. package/tests/uploads.node-test.js +255 -0
  48. package/update.js +3 -0
@@ -0,0 +1,86 @@
1
+ import { readdir } from 'fs/promises';
2
+ import path from 'path';
3
+ import { fileURLToPath, pathToFileURL } from 'url';
4
+
5
+ /*
6
+ Every server-side module must at least load.
7
+
8
+ This extension resolves imports out of the host `kempo` package, so it breaks in two ways
9
+ nothing else notices until a request comes in: a relative import that climbs the wrong number of
10
+ directories, or the host moving something this package reaches into. Importing the whole surface
11
+ turns both into a build failure.
12
+
13
+ admin/* and public/components/* are excluded — those are browser modules importing absolute
14
+ /kempo-ui/... URLs, not something Node can import directly.
15
+ */
16
+
17
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
+
19
+ const ROUTE_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
20
+
21
+ const walk = async dir => {
22
+ let entries;
23
+ try {
24
+ entries = await readdir(dir, { withFileTypes: true });
25
+ } catch {
26
+ return [];
27
+ }
28
+ const files = [];
29
+ for(const entry of entries){
30
+ const full = path.join(dir, entry.name);
31
+ if(entry.isDirectory()){
32
+ files.push(...await walk(full));
33
+ } else if(entry.name.endsWith('.js')){
34
+ files.push(full);
35
+ }
36
+ }
37
+ return files;
38
+ };
39
+
40
+ const dirs = ['server', 'hooks', 'public/api'];
41
+ const singles = ['install.js', 'update.js', 'sdk.js'];
42
+
43
+ const files = [
44
+ ...(await Promise.all(dirs.map(d => walk(path.join(root, d))))).flat(),
45
+ ...singles.map(f => path.join(root, f)),
46
+ ];
47
+
48
+ const isRouteHandler = file => {
49
+ const name = path.basename(file, '.js');
50
+ return file.replace(/\\/g, '/').includes('/api/') && ROUTE_METHODS.includes(name);
51
+ };
52
+
53
+ const tests = {
54
+ /*
55
+ Starts low because this extension is still being scaffolded (see the implementation plan) —
56
+ raise this as server/utils and public/api fill in, so it stays a meaningful check that the
57
+ surface list itself isn't broken, rather than a number nobody revisits.
58
+ */
59
+ 'module surface is discoverable': async ({ pass, fail }) => {
60
+ if(files.length < 4){
61
+ return fail(`only found ${files.length} modules — the surface list is probably wrong, so this suite would pass without checking anything`);
62
+ }
63
+ pass();
64
+ }
65
+ };
66
+
67
+ for(const file of files){
68
+ const rel = path.relative(root, file).replace(/\\/g, '/');
69
+
70
+ tests[`imports ${rel}`] = async ({ pass, fail }) => {
71
+ let module;
72
+ try {
73
+ module = await import(pathToFileURL(file).href);
74
+ } catch(e) {
75
+ return fail(`${e.code || e.constructor.name}: ${e.message.split('\n')[0]}`);
76
+ }
77
+
78
+ if(isRouteHandler(file) && typeof module.default !== 'function'){
79
+ return fail(`route handler has no default export function (got ${typeof module.default})`);
80
+ }
81
+
82
+ pass();
83
+ };
84
+ }
85
+
86
+ export default tests;
@@ -0,0 +1,247 @@
1
+ import { readFile, readdir } from 'fs/promises';
2
+ import { existsSync, readdirSync } from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { builtinModules } from 'module';
6
+
7
+ /*
8
+ Static checks for the packaging mistakes that do not surface until something else breaks.
9
+
10
+ Each of these corresponds to a bug that actually shipped in kempo-blog, the extension this
11
+ package is modeled on: a package depending on itself, a dependency imported everywhere but never
12
+ declared, a file: specifier committed into a manifest, and relative imports climbing the wrong
13
+ number of directories.
14
+
15
+ The module-graph suite catches an unresolvable import only for modules it can load, which
16
+ excludes every browser module and the standalone scripts. These run over the source text
17
+ instead, so public/components and admin/ are covered too.
18
+ */
19
+
20
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+
22
+ /*
23
+ public/ and admin/ ship inside this package and are served in place by the host, so their imports
24
+ are checked here as well.
25
+ */
26
+ const SOURCE_DIRS = ['server', 'hooks', 'public', 'admin', 'scripts', 'tests'];
27
+
28
+ const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
29
+
30
+ const DEPENDENCY_FIELDS = [
31
+ 'dependencies',
32
+ 'devDependencies',
33
+ 'peerDependencies',
34
+ 'optionalDependencies',
35
+ ];
36
+
37
+ const declared = new Set(
38
+ DEPENDENCY_FIELDS.flatMap(field => Object.keys(pkg[field] || {}))
39
+ );
40
+
41
+ const builtins = new Set(builtinModules);
42
+
43
+ /*
44
+ How the absolute URLs the browser requests map back onto files. These mirror the host's
45
+ customRoutes plus this package's own public-scope mount. Longest prefix first, so
46
+ /kempo-ui/icons/ wins over /kempo-ui/. /admin/** is intentionally absent: those are links into
47
+ the host's admin portal, which does not exist in this checkout.
48
+ */
49
+ const BROWSER_ROUTES = [
50
+ ['/kempo-ui/icons/', ['node_modules/kempo-ui/icons']],
51
+ ['/kempo-ui/', ['node_modules/kempo-ui/dist']],
52
+ ['/kempo-css/', ['node_modules/kempo-css/dist']],
53
+ ['/kempo/', ['node_modules/kempo/dist/kempo']],
54
+ // This package is mounted at its public-scope, so /kempo-media/** is its own public/ directory
55
+ ['/kempo-media/', ['public']],
56
+ ];
57
+
58
+ const walk = async (dir, extensions = ['.js']) => {
59
+ let entries;
60
+ try {
61
+ entries = await readdir(dir, { withFileTypes: true });
62
+ } catch {
63
+ return [];
64
+ }
65
+ const files = [];
66
+ for(const entry of entries){
67
+ const full = path.join(dir, entry.name);
68
+ if(entry.isDirectory()){
69
+ files.push(...await walk(full, extensions));
70
+ } else if(extensions.some(ext => entry.name.endsWith(ext))){
71
+ files.push(full);
72
+ }
73
+ }
74
+ return files;
75
+ };
76
+
77
+ const files = (await Promise.all(SOURCE_DIRS.map(d => walk(path.join(root, d))))).flat();
78
+
79
+ // Commented-out imports are still imports as far as a regex is concerned
80
+ const stripComments = source => source
81
+ .replace(/\/\*[\s\S]*?\*\//g, '')
82
+ .replace(/^[ \t]*\/\/.*$/gm, '');
83
+
84
+ /*
85
+ Matches `from '…'`, `import '…'` and `import('…')`. The lookbehind keeps method calls that happen
86
+ to end in those words — Buffer.from('…') above all — from being read as imports.
87
+ */
88
+ const SPECIFIER = /(?<![.\w])(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g;
89
+
90
+ const specifiersFor = source => [...stripComments(source).matchAll(SPECIFIER)].map(m => m[1]);
91
+
92
+ const packageNameOf = specifier => {
93
+ const parts = specifier.split('/');
94
+ return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
95
+ };
96
+
97
+ const sources = new Map(
98
+ await Promise.all(files.map(async f => [f, await readFile(f, 'utf8')]))
99
+ );
100
+
101
+ const rel = file => path.relative(root, file).replace(/\\/g, '/');
102
+
103
+ /*
104
+ Walks a relative specifier one segment at a time, checking each against the real directory
105
+ listing. existsSync cannot do this on Windows or macOS: their filesystems are case-insensitive,
106
+ so an import of TcExportCSV.js happily resolves to TcExportCsv.js locally and then 404s on Linux.
107
+ Returns null when the specifier is fine, or a description of what is wrong.
108
+ */
109
+ const resolveCaseExact = (fromDir, specifier) => {
110
+ let dir = fromDir;
111
+ const segments = specifier.split('/').filter(s => s !== '' && s !== '.');
112
+ for(const [i, segment] of segments.entries()){
113
+ if(segment === '..'){
114
+ dir = path.dirname(dir);
115
+ continue;
116
+ }
117
+ const last = i === segments.length - 1;
118
+ let listing;
119
+ try {
120
+ listing = readdirSync(dir);
121
+ } catch {
122
+ return `${dir} is not a directory`;
123
+ }
124
+ // Extensionless imports may resolve to name.js or name/index.js
125
+ const candidates = last ? [segment, `${segment}.js`] : [segment];
126
+ const match = candidates.find(c => listing.includes(c));
127
+ if(!match){
128
+ const insensitive = listing.filter(n => candidates.some(c => n.toLowerCase() === c.toLowerCase()));
129
+ return insensitive.length
130
+ ? `case mismatch — on disk it is ${insensitive.join(', ')}`
131
+ : `${segment} does not exist`;
132
+ }
133
+ dir = path.join(dir, match);
134
+ }
135
+ return null;
136
+ };
137
+
138
+ export default {
139
+ 'no dependency is declared with a file: or link: specifier': async ({ pass, fail }) => {
140
+ const offenders = [];
141
+ for(const field of DEPENDENCY_FIELDS){
142
+ for(const [name, spec] of Object.entries(pkg[field] || {})){
143
+ if(typeof spec === 'string' && /^(file|link):/.test(spec)){
144
+ offenders.push(`${field}.${name} = "${spec}"`);
145
+ }
146
+ }
147
+ }
148
+ if(offenders.length){
149
+ return fail(`a local path was committed into package.json, which encodes one machine's directory layout and leaves every other checkout with a dangling symlink — use \`npm run link:local\` instead:\n ${offenders.join('\n ')}`);
150
+ }
151
+ pass();
152
+ },
153
+
154
+ 'the package does not depend on itself': async ({ pass, fail }) => {
155
+ const offenders = DEPENDENCY_FIELDS
156
+ .filter(field => pkg[field]?.[pkg.name])
157
+ .map(field => `${field}.${pkg.name} = "${pkg[field][pkg.name]}"`);
158
+ if(offenders.length){
159
+ return fail(`${pkg.name} lists itself as a dependency:\n ${offenders.join('\n ')}`);
160
+ }
161
+ pass();
162
+ },
163
+
164
+ 'every imported package is declared in package.json': async ({ pass, fail }) => {
165
+ const missing = new Map();
166
+ for(const [file, source] of sources){
167
+ for(const specifier of specifiersFor(source)){
168
+ // Relative paths, absolute browser URLs and remote modules are not package imports
169
+ if(/^[./]/.test(specifier) || /^[a-z]+:/i.test(specifier)) continue;
170
+ const name = packageNameOf(specifier);
171
+ if(builtins.has(name) || declared.has(name)) continue;
172
+ if(!missing.has(name)) missing.set(name, new Set());
173
+ missing.get(name).add(rel(file));
174
+ }
175
+ }
176
+ if(missing.size){
177
+ const detail = [...missing].map(([name, users]) =>
178
+ `${name} — imported by ${[...users].slice(0, 3).join(', ')}${users.size > 3 ? ` (+${users.size - 3} more)` : ''}`
179
+ );
180
+ return fail(`imported but not declared, so it only resolves by luck of hoisting:\n ${detail.join('\n ')}`);
181
+ }
182
+ pass();
183
+ },
184
+
185
+ 'every relative import resolves, matching case exactly': async ({ pass, fail }) => {
186
+ const broken = [];
187
+ for(const [file, source] of sources){
188
+ for(const specifier of specifiersFor(source)){
189
+ if(!specifier.startsWith('.')) continue;
190
+ const problem = resolveCaseExact(path.dirname(file), specifier);
191
+ if(problem) broken.push(`${rel(file)}\n -> ${specifier} (${problem})`);
192
+ }
193
+ }
194
+ if(broken.length){
195
+ return fail(`relative imports that do not resolve (wrong number of ../ segments, or a case mismatch that only fails on a case-sensitive filesystem):\n ${broken.join('\n ')}`);
196
+ }
197
+ pass();
198
+ },
199
+
200
+ 'every absolute browser URL resolves to a file that ships': async ({ pass, fail }) => {
201
+ if(!existsSync(path.join(root, 'node_modules', 'kempo-ui', 'dist'))){
202
+ return fail('kempo-ui is not installed, so /kempo-ui/** cannot be checked — run npm install');
203
+ }
204
+
205
+ const htmlFiles = (await Promise.all(
206
+ SOURCE_DIRS.map(d => walk(path.join(root, d), ['.html']))
207
+ )).flat();
208
+
209
+ const references = [];
210
+
211
+ for(const [file, source] of sources){
212
+ for(const specifier of specifiersFor(source)){
213
+ if(specifier.startsWith('/')) references.push([file, specifier]);
214
+ }
215
+ }
216
+
217
+ // The outage this guards was almost entirely <script src>, not JS imports
218
+ for(const file of htmlFiles){
219
+ const source = await readFile(file, 'utf8');
220
+ for(const m of source.matchAll(/(?:src|href)="(\/[^"]+)"/g)){
221
+ references.push([file, m[1]]);
222
+ }
223
+ }
224
+
225
+ const broken = [];
226
+ for(const [file, specifier] of references){
227
+ const url = specifier.split(/[?#]/)[0];
228
+ /*
229
+ Only static assets are checked. Links like /admin/extension/<name>/ are navigation to a
230
+ dynamic route, not a file on disk, and are recognised by having no extension.
231
+ */
232
+ if(!path.basename(url).includes('.')) continue;
233
+ const route = BROWSER_ROUTES.find(([prefix]) => url.startsWith(prefix));
234
+ // Unmapped prefixes are the consumer's own public/ files, which do not exist in this repo
235
+ if(!route) continue;
236
+ const [prefix, roots] = route;
237
+ const rest = url.slice(prefix.length);
238
+ if(roots.some(r => existsSync(path.join(root, r, rest)))) continue;
239
+ broken.push(`${rel(file)}\n -> ${url} (looked in ${roots.join(', ')})`);
240
+ }
241
+
242
+ if(broken.length){
243
+ return fail(`absolute browser URLs that 404 at runtime. These fail silently — a module that does not load never defines its custom element, so the component simply renders nothing:\n ${[...new Set(broken)].join('\n ')}`);
244
+ }
245
+ pass();
246
+ },
247
+ };
@@ -0,0 +1,102 @@
1
+ import { readFile, readdir } from 'fs/promises';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ /*
6
+ kempo-blog ships this exact bug today: kempo-config.json declares an unprefixed permission
7
+ ("posts:create"), the server-side check uses the same unprefixed name, but both
8
+ public/index.page.html and admin/index.page.html gate their "New Post" button with
9
+ <k-permission has="kempo-blog:posts:create"> — a string that was never registered, so the gate
10
+ silently evaluates false for every user, including admins.
11
+
12
+ This statically cross-checks every permission literal referenced anywhere in this package against
13
+ the set actually declared in kempo-config.json, so the same class of bug fails CI instead of
14
+ shipping silently.
15
+ */
16
+
17
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
+
19
+ const kempoConfig = JSON.parse(await readFile(path.join(root, 'kempo-config.json'), 'utf8'));
20
+
21
+ const declared = new Set((kempoConfig.permissions || []).map(p => p.name));
22
+
23
+ const walk = async (dir, extensions) => {
24
+ let entries;
25
+ try {
26
+ entries = await readdir(dir, { withFileTypes: true });
27
+ } catch {
28
+ return [];
29
+ }
30
+ const files = [];
31
+ for(const entry of entries){
32
+ const full = path.join(dir, entry.name);
33
+ if(entry.isDirectory()){
34
+ files.push(...await walk(full, extensions));
35
+ } else if(extensions.some(ext => entry.name.endsWith(ext))){
36
+ files.push(full);
37
+ }
38
+ }
39
+ return files;
40
+ };
41
+
42
+ const rel = file => path.relative(root, file).replace(/\\/g, '/');
43
+
44
+ // currentUserHasPermission(token, 'name') — the server-side check every route uses
45
+ const SERVER_CHECK = /currentUserHasPermission\(\s*[^,]+,\s*['"]([^'"]+)['"]\s*\)/g;
46
+
47
+ // <k-permission has="name"> / all="a,b" / some="a,b"> — the admin/public UI gate
48
+ const UI_GATE = /<k-permission\s+(?:has|all|some)="([^"]+)"/g;
49
+
50
+ export default {
51
+ 'every permission referenced in code or markup is declared in kempo-config.json': async ({ pass, fail }) => {
52
+ const jsFiles = await walk(path.join(root, 'public'), ['.js']);
53
+ jsFiles.push(...await walk(path.join(root, 'admin'), ['.js']));
54
+ jsFiles.push(...await walk(path.join(root, 'server'), ['.js']));
55
+
56
+ const htmlFiles = await walk(path.join(root, 'public'), ['.html']);
57
+ htmlFiles.push(...await walk(path.join(root, 'admin'), ['.html']));
58
+
59
+ const referenced = new Map(); // permission name -> Set of files referencing it
60
+
61
+ const record = (name, file) => {
62
+ if(!referenced.has(name)) referenced.set(name, new Set());
63
+ referenced.get(name).add(rel(file));
64
+ };
65
+
66
+ for(const file of jsFiles){
67
+ const source = await readFile(file, 'utf8');
68
+ for(const m of source.matchAll(SERVER_CHECK)) record(m[1], file);
69
+ }
70
+
71
+ for(const file of htmlFiles){
72
+ const source = await readFile(file, 'utf8');
73
+ for(const m of source.matchAll(UI_GATE)){
74
+ for(const name of m[1].split(',').map(s => s.trim())) record(name, file);
75
+ }
76
+ }
77
+
78
+ const undeclared = [...referenced].filter(([name]) => !declared.has(name));
79
+
80
+ if(undeclared.length){
81
+ const detail = undeclared.map(([name, files]) =>
82
+ `"${name}" — referenced in ${[...files].join(', ')} but not declared in kempo-config.json's permissions[]`
83
+ );
84
+ return fail(`permission name mismatches, which silently deny everyone regardless of role:\n ${detail.join('\n ')}`);
85
+ }
86
+
87
+ pass(`${referenced.size} referenced permission name(s), all declared`);
88
+ },
89
+
90
+ 'every group permission is declared in kempo-config.json': async ({ pass, fail }) => {
91
+ const undeclared = [];
92
+ for(const group of kempoConfig.groups || []){
93
+ for(const name of group.permissions || []){
94
+ if(!declared.has(name)) undeclared.push(`group "${group.name}" grants "${name}", which is not declared in permissions[]`);
95
+ }
96
+ }
97
+ if(undeclared.length){
98
+ return fail(undeclared.join('\n '));
99
+ }
100
+ pass();
101
+ },
102
+ };
@@ -0,0 +1,255 @@
1
+ import { mkdtemp, rm, readFile, stat } from 'fs/promises';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import parseMultipart, { extractBoundary } from '../server/utils/uploads/parseMultipart.js';
5
+ import storeUpload from '../server/utils/uploads/storeUpload.js';
6
+ import generateThumbnail from '../server/utils/uploads/generateThumbnail.js';
7
+ import { typeForFilename } from '../server/utils/uploads/fileTypes.js';
8
+
9
+ /*
10
+ The upload path is the one place this extension handles attacker-supplied bytes and
11
+ attacker-supplied filenames, so these lean on the two things that actually go wrong: content
12
+ being corrupted on the way in, and a filename being trusted to build a path.
13
+ */
14
+
15
+ const BOUNDARY = '----kempoMediaTestBoundary';
16
+
17
+ // Bytes that are not valid UTF-8 — a PNG signature, a JPEG marker, and a lone 0xFF
18
+ const BINARY = Buffer.from([
19
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
20
+ 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0xff, 0xfe, 0xfd, 0x80
21
+ ]);
22
+
23
+ const buildBody = parts => {
24
+ const chunks = [];
25
+ for(const part of parts){
26
+ const disposition = part.filename
27
+ ? `form-data; name="${part.name}"; filename="${part.filename}"`
28
+ : `form-data; name="${part.name}"`;
29
+ const headers = [`Content-Disposition: ${disposition}`];
30
+ if(part.contentType) headers.push(`Content-Type: ${part.contentType}`);
31
+ chunks.push(Buffer.from(`--${BOUNDARY}\r\n${headers.join('\r\n')}\r\n\r\n`));
32
+ chunks.push(Buffer.isBuffer(part.data) ? part.data : Buffer.from(part.data));
33
+ chunks.push(Buffer.from('\r\n'));
34
+ }
35
+ chunks.push(Buffer.from(`--${BOUNDARY}--\r\n`));
36
+ return Buffer.concat(chunks);
37
+ };
38
+
39
+ const withTempRoot = async fn => {
40
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'kempo-media-'));
41
+ try {
42
+ return await fn(dir);
43
+ } finally {
44
+ await rm(dir, { recursive: true, force: true });
45
+ }
46
+ };
47
+
48
+ const makePng = async () => {
49
+ const { default: sharp } = await import('sharp');
50
+ return sharp({ create: { width: 200, height: 120, channels: 3, background: { r: 200, g: 40, b: 40 } } })
51
+ .png()
52
+ .toBuffer();
53
+ };
54
+
55
+ export default {
56
+ 'extractBoundary reads the boundary, quoted or not': async ({ pass, fail }) => {
57
+ if(extractBoundary(`multipart/form-data; boundary=${BOUNDARY}`) !== BOUNDARY) return fail('plain boundary');
58
+ if(extractBoundary(`multipart/form-data; boundary="${BOUNDARY}"`) !== BOUNDARY) return fail('quoted boundary');
59
+ if(extractBoundary('application/json') !== null) return fail('should reject a non-multipart content type');
60
+ if(extractBoundary(undefined) !== null) return fail('should tolerate a missing content type');
61
+ pass();
62
+ },
63
+
64
+ 'a file part and a text field are both parsed': async ({ pass, fail }) => {
65
+ const body = buildBody([
66
+ { name: 'file', filename: 'photo.png', contentType: 'image/png', data: BINARY },
67
+ { name: 'alt', data: 'A red square' }
68
+ ]);
69
+ const parts = parseMultipart(body, BOUNDARY);
70
+ if(parts.length !== 2) return fail(`expected 2 parts, got ${parts.length}`);
71
+
72
+ const file = parts.find(p => p.name === 'file');
73
+ if(file?.filename !== 'photo.png') return fail(`filename wrong: ${file?.filename}`);
74
+ if(file?.contentType !== 'image/png') return fail(`content type wrong: ${file?.contentType}`);
75
+
76
+ const alt = parts.find(p => p.name === 'alt');
77
+ if(alt?.data.toString('utf8') !== 'A red square') return fail(`text field wrong: ${alt?.data}`);
78
+ if(alt?.filename !== null) return fail('a text field should have no filename');
79
+ pass();
80
+ },
81
+
82
+ 'binary file content survives parsing byte for byte': async ({ pass, fail }) => {
83
+ const body = buildBody([{ name: 'file', filename: 'x.png', contentType: 'image/png', data: BINARY }]);
84
+ const [file] = parseMultipart(body, BOUNDARY);
85
+ if(!Buffer.isBuffer(file.data)) return fail(`part data is ${typeof file.data}, expected a Buffer`);
86
+ if(!file.data.equals(BINARY)){
87
+ return fail(`bytes changed\n sent: ${BINARY.toString('hex')}\n received: ${file.data.toString('hex')}`);
88
+ }
89
+ pass();
90
+ },
91
+
92
+ 'content containing CRLF is not truncated': async ({ pass, fail }) => {
93
+ // A naive line-based parser cuts the part short at the first CRLF inside the content
94
+ const data = Buffer.from('line one\r\nline two\r\n\r\nline three');
95
+ const body = buildBody([{ name: 'file', filename: 'a.zip', contentType: 'application/zip', data }]);
96
+ const [file] = parseMultipart(body, BOUNDARY);
97
+ if(!file.data.equals(data)) return fail(`got ${JSON.stringify(file.data.toString())}`);
98
+ pass();
99
+ },
100
+
101
+ 'a body with no boundary yields nothing rather than throwing': async ({ pass, fail }) => {
102
+ if(parseMultipart(Buffer.from('not multipart at all'), BOUNDARY).length !== 0) return fail('expected no parts');
103
+ if(parseMultipart(Buffer.from(''), BOUNDARY).length !== 0) return fail('expected no parts for an empty body');
104
+ if(parseMultipart(Buffer.from('x'), null).length !== 0) return fail('expected no parts without a boundary');
105
+ pass();
106
+ },
107
+
108
+ 'only allowed file types are accepted': async ({ pass, fail }) => {
109
+ for(const name of ['a.png', 'b.JPG', 'c.mp4', 'd.glb', 'e.zip', 'f.mp3']){
110
+ if(!typeForFilename(name)) return fail(`${name} should be allowed`);
111
+ }
112
+ for(const name of ['evil.svg', 'run.exe', 'shell.sh', 'page.html', 'noextension', 'trailing.']){
113
+ if(typeForFilename(name)) return fail(`${name} should be rejected`);
114
+ }
115
+ // SVG is excluded on purpose: it is served as inline unsanitised markup, so it is stored XSS
116
+ if(typeForFilename('logo.svg')) return fail('svg must stay rejected');
117
+ pass();
118
+ },
119
+
120
+ 'an upload is written under a generated name, never the one supplied': async ({ pass, fail }) => {
121
+ await withTempRoot(async (rootDir) => {
122
+ const [error, asset] = await storeUpload({
123
+ rootDir,
124
+ // A filename doing its best to escape the upload directory
125
+ filename: '../../../../etc/passwd.png',
126
+ data: BINARY,
127
+ uploadedBy: 'user-1',
128
+ altText: 'traversal attempt'
129
+ });
130
+ if(error) return fail(`unexpected error: ${error.msg}`);
131
+
132
+ if(!/^media\/uploads\/[0-9a-f]{16}\.png$/.test(asset.path)){
133
+ return fail(`stored path is not a generated name: ${asset.path}`);
134
+ }
135
+ if(asset.originalName !== '../../../../etc/passwd.png'){
136
+ return fail('the original name should still be recorded for display');
137
+ }
138
+
139
+ const onDisk = await readFile(path.join(rootDir, asset.path));
140
+ if(!onDisk.equals(BINARY)) return fail('file on disk does not match the uploaded bytes');
141
+
142
+ // Nothing should exist outside the upload directory
143
+ const escaped = path.join(rootDir, '..', 'etc');
144
+ const leaked = await stat(escaped).then(() => true).catch(() => false);
145
+ if(leaked) return fail('a file was written outside the upload directory');
146
+ pass();
147
+ });
148
+ },
149
+
150
+ 'an unsupported type is rejected before anything is written': async ({ pass, fail }) => {
151
+ await withTempRoot(async (rootDir) => {
152
+ const [error, asset] = await storeUpload({ rootDir, filename: 'payload.svg', data: BINARY, uploadedBy: 'u' });
153
+ if(!error) return fail('svg should have been rejected');
154
+ if(error.code !== 415) return fail(`expected 415, got ${error.code}`);
155
+ if(asset) return fail('no asset should be returned');
156
+
157
+ const uploads = path.join(rootDir, 'media', 'uploads');
158
+ const written = await stat(uploads).then(() => true).catch(() => false);
159
+ if(written) return fail('the upload directory should not have been created for a rejected type');
160
+ pass();
161
+ });
162
+ },
163
+
164
+ 'the size limit is enforced': async ({ pass, fail }) => {
165
+ await withTempRoot(async (rootDir) => {
166
+ const [error] = await storeUpload({
167
+ rootDir,
168
+ filename: 'big.png',
169
+ data: Buffer.alloc(2048, 0x41),
170
+ uploadedBy: 'u',
171
+ maxBytes: 1024
172
+ });
173
+ if(!error) return fail('an oversized upload should be rejected');
174
+ if(error.code !== 413) return fail(`expected 413, got ${error.code}`);
175
+
176
+ const [okError] = await storeUpload({
177
+ rootDir,
178
+ filename: 'small.png',
179
+ data: Buffer.alloc(512, 0x41),
180
+ uploadedBy: 'u',
181
+ maxBytes: 1024
182
+ });
183
+ if(okError) return fail(`an upload within the limit was rejected: ${okError.msg}`);
184
+ pass();
185
+ });
186
+ },
187
+
188
+ 'an empty upload is rejected': async ({ pass, fail }) => {
189
+ await withTempRoot(async (rootDir) => {
190
+ const [error] = await storeUpload({ rootDir, filename: 'x.png', data: Buffer.alloc(0), uploadedBy: 'u' });
191
+ if(!error) return fail('an empty file should be rejected');
192
+ if(error.code !== 400) return fail(`expected 400, got ${error.code}`);
193
+ pass();
194
+ });
195
+ },
196
+
197
+ 'an image gets a thumbnail and its dimensions recorded': async ({ pass, fail }) => {
198
+ await withTempRoot(async (rootDir) => {
199
+ const png = await makePng();
200
+ const [error, asset] = await storeUpload({
201
+ rootDir,
202
+ filename: 'square.png',
203
+ data: png,
204
+ uploadedBy: 'u',
205
+ thumbnailMaxDimension: 64
206
+ });
207
+ if(error) return fail(`unexpected error: ${error.msg}`);
208
+
209
+ if(asset.width !== 200 || asset.height !== 120){
210
+ return fail(`expected the source dimensions 200x120, got ${asset.width}x${asset.height}`);
211
+ }
212
+ if(!asset.thumbnailPath) return fail('no thumbnail was recorded');
213
+
214
+ const thumb = await readFile(path.join(rootDir, asset.thumbnailPath));
215
+ const { default: sharp } = await import('sharp');
216
+ const meta = await sharp(thumb).metadata();
217
+ if(meta.format !== 'webp') return fail(`thumbnail is ${meta.format}, expected webp`);
218
+ if(meta.width > 64 || meta.height > 64) return fail(`thumbnail was not resized: ${meta.width}x${meta.height}`);
219
+ pass();
220
+ });
221
+ },
222
+
223
+ 'a non-image kind gets no thumbnail and no sharp call': async ({ pass, fail }) => {
224
+ await withTempRoot(async (rootDir) => {
225
+ const [error, asset] = await storeUpload({ rootDir, filename: 'clip.mp4', data: BINARY, uploadedBy: 'u' });
226
+ if(error) return fail(`unexpected error: ${error.msg}`);
227
+ if(asset.kind !== 'video') return fail(`expected kind video, got ${asset.kind}`);
228
+ if(asset.thumbnailPath !== null) return fail('a video should not get a generated thumbnail');
229
+ if(asset.width !== null || asset.height !== null) return fail('a video should not report image dimensions');
230
+ pass();
231
+ });
232
+ },
233
+
234
+ 'bytes that are not a real image still upload, just without a preview': async ({ pass, fail }) => {
235
+ await withTempRoot(async (rootDir) => {
236
+ // Named .png but not remotely a PNG — the upload must survive, the thumbnail need not
237
+ const [error, asset] = await storeUpload({ rootDir, filename: 'fake.png', data: BINARY, uploadedBy: 'u' });
238
+ if(error) return fail(`the upload should not fail because a thumbnail could not be made: ${error.msg}`);
239
+ if(asset.thumbnailPath !== null) return fail('undecodable bytes should not produce a thumbnail');
240
+
241
+ const onDisk = await readFile(path.join(rootDir, asset.path));
242
+ if(!onDisk.equals(BINARY)) return fail('the original file should still be stored intact');
243
+ pass();
244
+ });
245
+ },
246
+
247
+ 'generateThumbnail reports a failure rather than throwing': async ({ pass, fail }) => {
248
+ await withTempRoot(async (rootDir) => {
249
+ const [error, data] = await generateThumbnail({ rootDir, data: BINARY, id: 'deadbeefdeadbeef' });
250
+ if(!error) return fail('expected an error tuple for undecodable input');
251
+ if(data) return fail('no data should accompany an error');
252
+ pass();
253
+ });
254
+ }
255
+ };