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
|
@@ -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
|
+
};
|