@pikku/core 0.12.137 → 0.12.139
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/CHANGELOG.md +40 -0
- package/dist/dev/hot-reload.js +5 -2
- package/dist/env.d.ts +18 -0
- package/dist/env.js +60 -1
- package/dist/function/abort-scope.d.ts +6 -5
- package/dist/function/abort-scope.js +6 -17
- package/dist/function/function-runner.js +2 -1
- package/dist/middleware/addon-scope-cap.d.ts +19 -0
- package/dist/middleware/addon-scope-cap.js +34 -0
- package/dist/middleware/auth-apikey.js +1 -1
- package/dist/middleware/auth-bearer.js +1 -1
- package/dist/middleware/auth-cookie.js +1 -1
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.js +1 -0
- package/dist/schema.js +3 -1
- package/dist/scopes.d.ts +8 -0
- package/dist/scopes.js +23 -0
- package/dist/services/content-service.d.ts +13 -1
- package/dist/services/in-memory-queue-service.js +4 -2
- package/dist/services/in-memory-workflow-service.js +7 -6
- package/dist/services/index.d.ts +2 -2
- package/dist/services/index.js +1 -1
- package/dist/services/local-content-request-handler.js +21 -60
- package/dist/services/local-content.d.ts +45 -1
- package/dist/services/local-content.js +202 -18
- package/dist/services/local-variables.d.ts +5 -0
- package/dist/services/local-variables.js +7 -2
- package/dist/services/queue-webhook-service.js +1 -1
- package/dist/services/scoped-content-service.d.ts +35 -0
- package/dist/services/scoped-content-service.js +171 -0
- package/dist/services/webhook-service.d.ts +2 -2
- package/dist/services/webhook-service.js +6 -4
- package/dist/types/core.types.d.ts +10 -1
- package/dist/types/state.types.d.ts +8 -0
- package/dist/utils/hmac.d.ts +25 -10
- package/dist/utils/hmac.js +202 -24
- package/dist/wirings/addon/addon-reach.d.ts +3 -0
- package/dist/wirings/addon/addon-reach.js +21 -0
- package/dist/wirings/addon/addon-runner.d.ts +6 -0
- package/dist/wirings/addon/addon-runner.js +13 -0
- package/dist/wirings/addon/define-screens.d.ts +32 -0
- package/dist/wirings/addon/define-screens.js +5 -0
- package/dist/wirings/addon/index.d.ts +2 -0
- package/dist/wirings/addon/index.js +1 -0
- package/dist/wirings/addon/wire-addon.d.ts +27 -0
- package/dist/wirings/addon/wire-addon.js +4 -0
- package/dist/wirings/agent/agent-agui.js +65 -62
- package/dist/wirings/agent/agent-interrupt.d.ts +4 -1
- package/dist/wirings/agent/agent-interrupt.js +3 -3
- package/dist/wirings/agent/agent-memory.js +5 -6
- package/dist/wirings/agent/agent-model-config.js +2 -1
- package/dist/wirings/agent/agent-prepare.js +24 -13
- package/dist/wirings/agent/agent-stream.js +38 -34
- package/dist/wirings/agent/agent.types.d.ts +8 -1
- package/dist/wirings/channel/channel-handler.js +1 -1
- package/dist/wirings/channel/local/local-channel-handler.js +5 -1
- package/dist/wirings/channel/local/local-channel-runner.js +6 -4
- package/dist/wirings/channel/local/local-eventhub-service.d.ts +1 -0
- package/dist/wirings/channel/local/local-eventhub-service.js +4 -1
- package/dist/wirings/cli/channel/cli-channel-runner.js +7 -2
- package/dist/wirings/cli/channel/cli-raw-client-runner.js +8 -4
- package/dist/wirings/cli/cli-runner.js +1 -1
- package/dist/wirings/gateway/gateway-runner.js +3 -3
- package/dist/wirings/http/http-runner.js +6 -6
- package/dist/wirings/rpc/rpc-runner.d.ts +4 -0
- package/dist/wirings/rpc/rpc-runner.js +41 -2
- package/dist/wirings/rpc/rpc-types.d.ts +6 -0
- package/dist/wirings/scheduler/scheduler-runner.js +1 -1
- package/dist/wirings/scope/define-scope.d.ts +19 -0
- package/dist/wirings/scope/define-scope.js +19 -0
- package/dist/wirings/scope/index.d.ts +2 -2
- package/dist/wirings/scope/index.js +2 -2
- package/dist/wirings/trigger/trigger-runner.js +3 -1
- package/dist/wirings/trigger/webhook-source-runner.js +5 -2
- package/dist/wirings/virtual-user/virtual-user-scaffold.js +3 -2
- package/dist/wirings/workflow/workflow-invocation-id.js +85 -8
- package/package.json +3 -1
- package/src/public-surface.json +65 -18
|
@@ -1,37 +1,9 @@
|
|
|
1
1
|
import { createReadStream } from 'fs';
|
|
2
|
-
import {
|
|
3
|
-
import { normalize, resolve } from 'path';
|
|
2
|
+
import { stat } from 'fs/promises';
|
|
4
3
|
import { Readable } from 'stream';
|
|
5
|
-
import { signedContentPath } from './local-content.js';
|
|
4
|
+
import { UploadTooLargeError, parseContentSizeLimit, resolveContentRequestTarget, signedContentPath, streamUploadToFile, } from './local-content.js';
|
|
6
5
|
const matchesPrefix = (pathname, prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`);
|
|
7
6
|
const contentKey = (pathname, prefix) => pathname.slice(prefix.length).replace(/^\/+/, '');
|
|
8
|
-
/**
|
|
9
|
-
* Resolve a key against the content root, or `null` if it escapes. `normalize`
|
|
10
|
-
* first so `..` segments are collapsed before the prefix check, and the
|
|
11
|
-
* comparison carries a trailing separator so a sibling directory whose name
|
|
12
|
-
* merely starts with the root's cannot pass as being inside it.
|
|
13
|
-
*/
|
|
14
|
-
const toTargetPath = (basePath, key) => {
|
|
15
|
-
const normalizedBasePath = resolve(basePath);
|
|
16
|
-
const targetPath = resolve(normalizedBasePath, normalize(key));
|
|
17
|
-
return targetPath.startsWith(`${normalizedBasePath}/`) ? targetPath : null;
|
|
18
|
-
};
|
|
19
|
-
const parseSizeLimit = (sizeLimit) => {
|
|
20
|
-
const match = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/i.exec(sizeLimit.trim());
|
|
21
|
-
if (!match) {
|
|
22
|
-
throw new Error(`Invalid size limit: ${sizeLimit}`);
|
|
23
|
-
}
|
|
24
|
-
const value = Number(match[1]);
|
|
25
|
-
const unit = (match[2] ?? 'b').toLowerCase();
|
|
26
|
-
const multiplier = unit === 'gb'
|
|
27
|
-
? 1024 * 1024 * 1024
|
|
28
|
-
: unit === 'mb'
|
|
29
|
-
? 1024 * 1024
|
|
30
|
-
: unit === 'kb'
|
|
31
|
-
? 1024
|
|
32
|
-
: 1;
|
|
33
|
-
return value * multiplier;
|
|
34
|
-
};
|
|
35
7
|
const text = (status, body) => new Response(body, {
|
|
36
8
|
status,
|
|
37
9
|
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
@@ -112,47 +84,36 @@ export const createLocalContentRequestHandler = ({ content, logger, getJWT, }) =
|
|
|
112
84
|
return text(signed.status, signed.body);
|
|
113
85
|
}
|
|
114
86
|
const key = contentKey(pathname, content.uploadUrlPrefix);
|
|
115
|
-
const
|
|
116
|
-
if (!
|
|
87
|
+
const target = await resolveContentRequestTarget(content.localFileUploadPath, key, 'write');
|
|
88
|
+
if (!target) {
|
|
117
89
|
return text(400, 'Invalid path');
|
|
118
90
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const { done, value } = await reader.read();
|
|
128
|
-
if (done)
|
|
129
|
-
break;
|
|
130
|
-
bytesRead += value.byteLength;
|
|
131
|
-
if (bytesRead > maxBytes) {
|
|
132
|
-
await reader.cancel();
|
|
133
|
-
return text(413, 'Content too large');
|
|
134
|
-
}
|
|
135
|
-
chunks.push(Buffer.from(value));
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
finally {
|
|
139
|
-
reader.releaseLock();
|
|
91
|
+
try {
|
|
92
|
+
await streamUploadToFile(request.body
|
|
93
|
+
? Readable.fromWeb(request.body)
|
|
94
|
+
: (async function* () { })(), target.path, parseContentSizeLimit(content.sizeLimit ?? '1mb'));
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error instanceof UploadTooLargeError) {
|
|
98
|
+
return text(413, 'Content too large');
|
|
140
99
|
}
|
|
100
|
+
throw error;
|
|
141
101
|
}
|
|
142
|
-
await mkdir(resolve(targetPath, '..'), { recursive: true });
|
|
143
|
-
await writeFile(targetPath, Buffer.concat(chunks));
|
|
144
102
|
return new Response(null, { status: 200 });
|
|
145
103
|
};
|
|
146
104
|
const handleAsset = async (request, requestUrl, pathname) => {
|
|
147
105
|
const key = contentKey(pathname, content.assetUrlPrefix);
|
|
148
|
-
const
|
|
149
|
-
if (!
|
|
106
|
+
const target = await resolveContentRequestTarget(content.localFileUploadPath, key, 'read');
|
|
107
|
+
if (!target) {
|
|
150
108
|
return text(400, 'Invalid path');
|
|
151
109
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
110
|
+
if (target.visibility === 'private') {
|
|
111
|
+
const signed = await validateSignedAssetRequest(requestUrl);
|
|
112
|
+
if (!signed.ok) {
|
|
113
|
+
return text(signed.status, signed.body);
|
|
114
|
+
}
|
|
155
115
|
}
|
|
116
|
+
const targetPath = target.path;
|
|
156
117
|
try {
|
|
157
118
|
const file = await stat(targetPath);
|
|
158
119
|
if (!file.isFile()) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BucketKeyArgs, ContentService, CopyFileArgs, GetUploadURLArgs, JWTService, Logger, SignContentKeyArgs, SignURLArgs, UploadURLResult, WriteFileArgs } from '@pikku/core/services';
|
|
1
|
+
import type { BucketKeyArgs, ContentService, ContentVisibility, GetDownloadURLArgs, CopyFileArgs, GetUploadURLArgs, JWTService, Logger, SignContentKeyArgs, SignURLArgs, UploadURLResult, WriteFileArgs } from '@pikku/core/services';
|
|
2
2
|
export interface LocalContentConfig {
|
|
3
3
|
localFileUploadPath: string;
|
|
4
4
|
uploadUrlPrefix: string;
|
|
@@ -17,17 +17,59 @@ export interface LocalContentConfig {
|
|
|
17
17
|
* rather than throwing, which keeps both sides in agreement.
|
|
18
18
|
*/
|
|
19
19
|
export declare const signedContentPath: (urlOrPath: string) => string;
|
|
20
|
+
export declare const PUBLIC_URL_SEGMENT = "_public";
|
|
21
|
+
export declare const CONTENT_VISIBILITIES: readonly ContentVisibility[];
|
|
22
|
+
export type ContentUrlPath = {
|
|
23
|
+
visibility: ContentVisibility;
|
|
24
|
+
rest: string;
|
|
25
|
+
prefixed: boolean;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Splits the part of a content URL after the asset/upload prefix into the
|
|
29
|
+
* visibility it names and the `<bucket>/<key>` remainder. Only a leading
|
|
30
|
+
* `_public/` segment names the public side; anything else is private.
|
|
31
|
+
* Private buckets may therefore not be named `_public`.
|
|
32
|
+
*/
|
|
33
|
+
export declare const parseContentUrlPath: (key: string) => ContentUrlPath;
|
|
34
|
+
/**
|
|
35
|
+
* Resolves `<bucket>/<key>` against one side of the store. The side is a
|
|
36
|
+
* directory under the content root, and both the bucket and the key must stay
|
|
37
|
+
* inside it, so neither side can climb out or reach the other.
|
|
38
|
+
*/
|
|
39
|
+
export declare const resolveVisibilityPath: (basePath: string, visibility: ContentVisibility, bucket: string, key: string) => string | null;
|
|
40
|
+
export type ContentRequestTarget = {
|
|
41
|
+
path: string;
|
|
42
|
+
visibility: ContentVisibility;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Maps the part of a content URL after the asset/upload prefix to a file under
|
|
46
|
+
* the content root: `_public/...` to the public side, anything else to the
|
|
47
|
+
* private side, falling back for reads to an unprefixed file from before the
|
|
48
|
+
* split. Returns `null` for a path that escapes its side or names no file.
|
|
49
|
+
* Every server of local content resolves through here, and skips the signature
|
|
50
|
+
* check only when the result is public.
|
|
51
|
+
*/
|
|
52
|
+
export declare const resolveContentRequestTarget: (basePath: string, key: string, mode: 'read' | 'write') => Promise<ContentRequestTarget | null>;
|
|
53
|
+
export declare class UploadTooLargeError extends Error {
|
|
54
|
+
constructor();
|
|
55
|
+
}
|
|
56
|
+
export declare const parseContentSizeLimit: (sizeLimit: string) => number;
|
|
57
|
+
export declare const streamUploadToFile: (source: AsyncIterable<Uint8Array>, targetPath: string, maxBytes: number) => Promise<number>;
|
|
20
58
|
export declare class LocalContent implements ContentService {
|
|
21
59
|
private config;
|
|
22
60
|
private logger;
|
|
23
61
|
private jwt;
|
|
24
62
|
constructor(config: LocalContentConfig, logger: Logger, jwt: JWTService);
|
|
25
63
|
private safePath;
|
|
64
|
+
private existingPath;
|
|
65
|
+
private legacyPath;
|
|
66
|
+
private publicBase;
|
|
26
67
|
private joinKey;
|
|
27
68
|
init(): Promise<void>;
|
|
28
69
|
private signParams;
|
|
29
70
|
signURL(args: SignURLArgs): Promise<string>;
|
|
30
71
|
signContentKey(args: SignContentKeyArgs): Promise<string>;
|
|
72
|
+
getDownloadURL(args: GetDownloadURLArgs): Promise<string>;
|
|
31
73
|
/**
|
|
32
74
|
* Presigned upload, mirroring the read side: the URL carries a short-lived,
|
|
33
75
|
* path-bound signature that {@link createLocalContentRequestHandler} verifies
|
|
@@ -40,5 +82,7 @@ export declare class LocalContent implements ContentService {
|
|
|
40
82
|
readFile(args: BucketKeyArgs): Promise<ReadableStream | NodeJS.ReadableStream>;
|
|
41
83
|
readFileAsBuffer(args: BucketKeyArgs): Promise<Buffer>;
|
|
42
84
|
deleteFile(args: BucketKeyArgs): Promise<boolean>;
|
|
85
|
+
listFilesByPrefix(bucket: string, prefix: string, visibility?: ContentVisibility): Promise<string[]>;
|
|
86
|
+
deleteByPrefix(bucket: string, prefix: string, visibility?: ContentVisibility): Promise<number>;
|
|
43
87
|
private createDirectoryForFile;
|
|
44
88
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createReadStream, createWriteStream, promises } from 'fs';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import { mkdir, readFile, readdir, rename, rm, stat } from 'fs/promises';
|
|
4
|
+
import { resolve, normalize, sep } from 'path';
|
|
4
5
|
import { pipeline } from 'stream/promises';
|
|
6
|
+
import { Transform } from 'stream';
|
|
5
7
|
/**
|
|
6
8
|
* The representation of an asset that gets bound into a signed URL's
|
|
7
9
|
* signature, and that a server can reconstruct from an incoming request:
|
|
@@ -27,8 +29,122 @@ export const signedContentPath = (urlOrPath) => {
|
|
|
27
29
|
return pathname;
|
|
28
30
|
}
|
|
29
31
|
};
|
|
32
|
+
export const PUBLIC_URL_SEGMENT = '_public';
|
|
33
|
+
export const CONTENT_VISIBILITIES = [
|
|
34
|
+
'private',
|
|
35
|
+
'public',
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* Splits the part of a content URL after the asset/upload prefix into the
|
|
39
|
+
* visibility it names and the `<bucket>/<key>` remainder. Only a leading
|
|
40
|
+
* `_public/` segment names the public side; anything else is private.
|
|
41
|
+
* Private buckets may therefore not be named `_public`.
|
|
42
|
+
*/
|
|
43
|
+
export const parseContentUrlPath = (key) => {
|
|
44
|
+
const [first, ...rest] = key.split('/');
|
|
45
|
+
if (first === PUBLIC_URL_SEGMENT) {
|
|
46
|
+
return { visibility: 'public', rest: rest.join('/'), prefixed: true };
|
|
47
|
+
}
|
|
48
|
+
return { visibility: 'private', rest: key, prefixed: false };
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Resolves `<bucket>/<key>` against one side of the store. The side is a
|
|
52
|
+
* directory under the content root, and both the bucket and the key must stay
|
|
53
|
+
* inside it, so neither side can climb out or reach the other.
|
|
54
|
+
*/
|
|
55
|
+
export const resolveVisibilityPath = (basePath, visibility, bucket, key) => {
|
|
56
|
+
if (!CONTENT_VISIBILITIES.includes(visibility))
|
|
57
|
+
return null;
|
|
58
|
+
if (bucket.includes('\0') || key.includes('\0'))
|
|
59
|
+
return null;
|
|
60
|
+
if (visibility === 'private') {
|
|
61
|
+
const [first] = normalize(bucket).split(/[\\/]/);
|
|
62
|
+
if (first === PUBLIC_URL_SEGMENT)
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const root = resolve(resolve(basePath), visibility);
|
|
66
|
+
const scoped = resolve(root, normalize(bucket));
|
|
67
|
+
const target = resolve(scoped, normalize(key));
|
|
68
|
+
const inside = (path) => path === root || path.startsWith(root + sep);
|
|
69
|
+
if (!inside(scoped) || !inside(target))
|
|
70
|
+
return null;
|
|
71
|
+
return target;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Maps the part of a content URL after the asset/upload prefix to a file under
|
|
75
|
+
* the content root: `_public/...` to the public side, anything else to the
|
|
76
|
+
* private side, falling back for reads to an unprefixed file from before the
|
|
77
|
+
* split. Returns `null` for a path that escapes its side or names no file.
|
|
78
|
+
* Every server of local content resolves through here, and skips the signature
|
|
79
|
+
* check only when the result is public.
|
|
80
|
+
*/
|
|
81
|
+
export const resolveContentRequestTarget = async (basePath, key, mode) => {
|
|
82
|
+
const parsed = parseContentUrlPath(key);
|
|
83
|
+
const root = resolve(resolve(basePath), parsed.visibility);
|
|
84
|
+
const path = resolveVisibilityPath(basePath, parsed.visibility, parsed.rest, '');
|
|
85
|
+
if (!path || path === root)
|
|
86
|
+
return null;
|
|
87
|
+
if (mode === 'read' && !parsed.prefixed) {
|
|
88
|
+
const exists = await stat(path).then(() => true, () => false);
|
|
89
|
+
if (!exists) {
|
|
90
|
+
const [first] = normalize(parsed.rest).split(/[\\/]/);
|
|
91
|
+
const base = resolve(basePath);
|
|
92
|
+
const legacy = resolve(base, normalize(parsed.rest));
|
|
93
|
+
if (first !== PUBLIC_URL_SEGMENT &&
|
|
94
|
+
first !== 'private' &&
|
|
95
|
+
legacy.startsWith(base + sep)) {
|
|
96
|
+
return { path: legacy, visibility: 'private' };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { path, visibility: parsed.visibility };
|
|
101
|
+
};
|
|
30
102
|
/** How long a presigned upload URL stays valid. Short, like an S3 PUT URL. */
|
|
31
103
|
const UPLOAD_URL_TTL_MS = 15 * 60 * 1000;
|
|
104
|
+
export class UploadTooLargeError extends Error {
|
|
105
|
+
constructor() {
|
|
106
|
+
super('content_too_large');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export const parseContentSizeLimit = (sizeLimit) => {
|
|
110
|
+
const match = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/i.exec(sizeLimit.trim());
|
|
111
|
+
if (!match) {
|
|
112
|
+
throw new Error(`Invalid size limit: ${sizeLimit}`);
|
|
113
|
+
}
|
|
114
|
+
const unit = (match[2] ?? 'b').toLowerCase();
|
|
115
|
+
const multiplier = unit === 'gb'
|
|
116
|
+
? 1024 * 1024 * 1024
|
|
117
|
+
: unit === 'mb'
|
|
118
|
+
? 1024 * 1024
|
|
119
|
+
: unit === 'kb'
|
|
120
|
+
? 1024
|
|
121
|
+
: 1;
|
|
122
|
+
return Number(match[1]) * multiplier;
|
|
123
|
+
};
|
|
124
|
+
export const streamUploadToFile = async (source, targetPath, maxBytes) => {
|
|
125
|
+
await mkdir(resolve(targetPath, '..'), { recursive: true });
|
|
126
|
+
const partial = `${targetPath}.${process.pid}.${Date.now()}.${randomUUID()}.part`;
|
|
127
|
+
let bytes = 0;
|
|
128
|
+
const counter = new Transform({
|
|
129
|
+
transform(chunk, _encoding, callback) {
|
|
130
|
+
bytes += chunk.length;
|
|
131
|
+
if (bytes > maxBytes) {
|
|
132
|
+
callback(new UploadTooLargeError());
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
callback(null, chunk);
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
try {
|
|
139
|
+
await pipeline(source, counter, createWriteStream(partial));
|
|
140
|
+
await rename(partial, targetPath);
|
|
141
|
+
return bytes;
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
await rm(partial, { force: true });
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
32
148
|
export class LocalContent {
|
|
33
149
|
config;
|
|
34
150
|
logger;
|
|
@@ -41,15 +157,39 @@ export class LocalContent {
|
|
|
41
157
|
throw new Error('LocalContent requires a JWTService: without one it cannot sign asset URLs, and unsigned URLs cannot be verified.');
|
|
42
158
|
}
|
|
43
159
|
}
|
|
44
|
-
safePath(bucket, key) {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
const target = resolve(scoped, normalize(key));
|
|
48
|
-
if (!target.startsWith(base + '/') && target !== base) {
|
|
160
|
+
safePath(bucket, key, visibility = 'private') {
|
|
161
|
+
const target = resolveVisibilityPath(this.config.localFileUploadPath, visibility, bucket, key);
|
|
162
|
+
if (!target) {
|
|
49
163
|
throw new Error('Invalid asset key');
|
|
50
164
|
}
|
|
51
165
|
return target;
|
|
52
166
|
}
|
|
167
|
+
async existingPath(bucket, key, visibility = 'private') {
|
|
168
|
+
const path = this.safePath(bucket, key, visibility);
|
|
169
|
+
if (visibility === 'private') {
|
|
170
|
+
const exists = await stat(path).then(() => true, () => false);
|
|
171
|
+
if (!exists) {
|
|
172
|
+
const legacy = this.legacyPath(bucket, key);
|
|
173
|
+
if (legacy &&
|
|
174
|
+
(await stat(legacy).then(() => true, () => false))) {
|
|
175
|
+
return legacy;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return path;
|
|
180
|
+
}
|
|
181
|
+
legacyPath(bucket, key) {
|
|
182
|
+
const [first] = normalize(bucket).split(/[\\/]/);
|
|
183
|
+
if (first === PUBLIC_URL_SEGMENT || first === 'private')
|
|
184
|
+
return null;
|
|
185
|
+
const base = resolve(this.config.localFileUploadPath);
|
|
186
|
+
const target = resolve(base, normalize(bucket), normalize(key));
|
|
187
|
+
return target.startsWith(base + sep) ? target : null;
|
|
188
|
+
}
|
|
189
|
+
publicBase() {
|
|
190
|
+
const origin = this.config.server ?? '';
|
|
191
|
+
return `${origin}${this.config.assetUrlPrefix}/${PUBLIC_URL_SEGMENT}`;
|
|
192
|
+
}
|
|
53
193
|
joinKey(bucket, key) {
|
|
54
194
|
return `${bucket}/${key}`;
|
|
55
195
|
}
|
|
@@ -82,16 +222,27 @@ export class LocalContent {
|
|
|
82
222
|
return `${args.url}?${params}`;
|
|
83
223
|
}
|
|
84
224
|
async signContentKey(args) {
|
|
225
|
+
const visibility = args.visibility ?? 'private';
|
|
226
|
+
this.safePath(args.bucket, args.contentKey, visibility);
|
|
85
227
|
const fullKey = this.joinKey(args.bucket, args.contentKey);
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
228
|
+
const origin = this.config.server ?? '';
|
|
229
|
+
if (visibility === 'public') {
|
|
230
|
+
return `${this.publicBase()}/${fullKey}`;
|
|
231
|
+
}
|
|
89
232
|
return this.signURL({
|
|
90
|
-
url:
|
|
233
|
+
url: `${origin}${this.config.assetUrlPrefix}/${fullKey}`,
|
|
91
234
|
dateLessThan: args.dateLessThan,
|
|
92
235
|
dateGreaterThan: args.dateGreaterThan,
|
|
93
236
|
});
|
|
94
237
|
}
|
|
238
|
+
async getDownloadURL(args) {
|
|
239
|
+
return this.signContentKey({
|
|
240
|
+
bucket: args.bucket,
|
|
241
|
+
contentKey: args.key,
|
|
242
|
+
visibility: args.visibility,
|
|
243
|
+
dateLessThan: new Date(Date.now() + (args.expiresInSeconds ?? 3600) * 1000),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
95
246
|
/**
|
|
96
247
|
* Presigned upload, mirroring the read side: the URL carries a short-lived,
|
|
97
248
|
* path-bound signature that {@link createLocalContentRequestHandler} verifies
|
|
@@ -99,11 +250,12 @@ export class LocalContent {
|
|
|
99
250
|
* now refuses one — so this must sign, or every upload 403s.
|
|
100
251
|
*/
|
|
101
252
|
async getUploadURL(args) {
|
|
102
|
-
|
|
253
|
+
const visibility = args.visibility ?? 'private';
|
|
254
|
+
this.safePath(args.bucket, args.fileKey, visibility);
|
|
103
255
|
const fullKey = this.joinKey(args.bucket, args.fileKey);
|
|
104
256
|
this.logger.debug(`Going to upload with key: ${fullKey}`);
|
|
105
257
|
const uploadUrl = await this.signURL({
|
|
106
|
-
url: `${this.config.uploadUrlPrefix}/${fullKey}`,
|
|
258
|
+
url: `${this.config.uploadUrlPrefix}/${visibility === 'public' ? `${PUBLIC_URL_SEGMENT}/` : ''}${fullKey}`,
|
|
107
259
|
dateLessThan: new Date(Date.now() + UPLOAD_URL_TTL_MS),
|
|
108
260
|
});
|
|
109
261
|
return {
|
|
@@ -114,7 +266,7 @@ export class LocalContent {
|
|
|
114
266
|
}
|
|
115
267
|
async writeFile(args) {
|
|
116
268
|
this.logger.debug(`Writing file: ${args.bucket}/${args.key}`);
|
|
117
|
-
const path = this.safePath(args.bucket, args.key);
|
|
269
|
+
const path = this.safePath(args.bucket, args.key, args.visibility);
|
|
118
270
|
try {
|
|
119
271
|
await this.createDirectoryForFile(path);
|
|
120
272
|
const fileStream = createWriteStream(path);
|
|
@@ -130,7 +282,7 @@ export class LocalContent {
|
|
|
130
282
|
async copyFile(args) {
|
|
131
283
|
this.logger.debug(`Writing file: ${args.bucket}/${args.key}`);
|
|
132
284
|
try {
|
|
133
|
-
const path = this.safePath(args.bucket, args.key);
|
|
285
|
+
const path = this.safePath(args.bucket, args.key, args.visibility);
|
|
134
286
|
await this.createDirectoryForFile(path);
|
|
135
287
|
await promises.copyFile(args.fromAbsolutePath, path);
|
|
136
288
|
return true;
|
|
@@ -143,7 +295,7 @@ export class LocalContent {
|
|
|
143
295
|
}
|
|
144
296
|
async readFile(args) {
|
|
145
297
|
this.logger.debug(`Getting key: ${args.bucket}/${args.key}`);
|
|
146
|
-
const filePath = this.
|
|
298
|
+
const filePath = await this.existingPath(args.bucket, args.key, args.visibility);
|
|
147
299
|
try {
|
|
148
300
|
const stream = createReadStream(filePath);
|
|
149
301
|
stream.on('error', (err) => {
|
|
@@ -157,14 +309,14 @@ export class LocalContent {
|
|
|
157
309
|
}
|
|
158
310
|
}
|
|
159
311
|
async readFileAsBuffer(args) {
|
|
160
|
-
const filePath = this.
|
|
312
|
+
const filePath = await this.existingPath(args.bucket, args.key, args.visibility);
|
|
161
313
|
this.logger.debug(`Reading file as buffer: ${args.bucket}/${args.key}`);
|
|
162
314
|
return readFile(filePath);
|
|
163
315
|
}
|
|
164
316
|
async deleteFile(args) {
|
|
165
317
|
this.logger.debug(`deleting key: ${args.bucket}/${args.key}`);
|
|
166
318
|
try {
|
|
167
|
-
await promises.unlink(this.safePath(args.bucket, args.key));
|
|
319
|
+
await promises.unlink(this.safePath(args.bucket, args.key, args.visibility));
|
|
168
320
|
return true;
|
|
169
321
|
}
|
|
170
322
|
catch (e) {
|
|
@@ -172,6 +324,38 @@ export class LocalContent {
|
|
|
172
324
|
}
|
|
173
325
|
return false;
|
|
174
326
|
}
|
|
327
|
+
async listFilesByPrefix(bucket, prefix, visibility = 'private') {
|
|
328
|
+
const root = this.safePath(bucket, '', visibility);
|
|
329
|
+
const keys = [];
|
|
330
|
+
const walk = async (dir, rel) => {
|
|
331
|
+
let entries;
|
|
332
|
+
try {
|
|
333
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
for (const entry of entries) {
|
|
339
|
+
const next = rel ? `${rel}/${entry.name}` : entry.name;
|
|
340
|
+
if (entry.isDirectory())
|
|
341
|
+
await walk(resolve(dir, entry.name), next);
|
|
342
|
+
else if (entry.isFile() && next.startsWith(prefix))
|
|
343
|
+
keys.push(next);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
this.safePath(bucket, prefix, visibility);
|
|
347
|
+
await walk(root, '');
|
|
348
|
+
return keys.sort();
|
|
349
|
+
}
|
|
350
|
+
async deleteByPrefix(bucket, prefix, visibility = 'private') {
|
|
351
|
+
const keys = await this.listFilesByPrefix(bucket, prefix, visibility);
|
|
352
|
+
let deleted = 0;
|
|
353
|
+
for (const key of keys) {
|
|
354
|
+
if (await this.deleteFile({ bucket, key, visibility }))
|
|
355
|
+
deleted++;
|
|
356
|
+
}
|
|
357
|
+
return deleted;
|
|
358
|
+
}
|
|
175
359
|
async createDirectoryForFile(path) {
|
|
176
360
|
const dir = path.split('/').slice(0, -1).join('/');
|
|
177
361
|
await mkdir(dir, { recursive: true });
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { VariablesService } from './variables-service.js';
|
|
2
2
|
export declare class LocalVariablesService implements VariablesService {
|
|
3
3
|
private variables;
|
|
4
|
+
/**
|
|
5
|
+
* Defaults to `process.env`, resolved when the service is built rather than
|
|
6
|
+
* when this module loads, and guarded so a runtime without `process` gets an
|
|
7
|
+
* empty store instead of a ReferenceError.
|
|
8
|
+
*/
|
|
4
9
|
constructor(variables?: Record<string, string | undefined>);
|
|
5
10
|
getAll(): Record<string, string | undefined>;
|
|
6
11
|
getVariables<T extends Record<string, unknown> = Record<string, unknown>>(names: (keyof T & string)[]): Partial<T>;
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
export class LocalVariablesService {
|
|
2
2
|
variables;
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Defaults to `process.env`, resolved when the service is built rather than
|
|
5
|
+
* when this module loads, and guarded so a runtime without `process` gets an
|
|
6
|
+
* empty store instead of a ReferenceError.
|
|
7
|
+
*/
|
|
8
|
+
constructor(variables) {
|
|
9
|
+
this.variables = variables ?? globalThis.process?.env ?? {};
|
|
5
10
|
}
|
|
6
11
|
getAll() {
|
|
7
12
|
return this.variables || {};
|
|
@@ -39,7 +39,7 @@ export class QueueWebhookService extends WebhookService {
|
|
|
39
39
|
}
|
|
40
40
|
if (secret) {
|
|
41
41
|
const signatureHeader = webhookConfig?.signatureHeader ?? DEFAULT_WEBHOOK_SIGNATURE_HEADER;
|
|
42
|
-
headers[signatureHeader] = this.sign(secret, body);
|
|
42
|
+
headers[signatureHeader] = await this.sign(secret, body);
|
|
43
43
|
}
|
|
44
44
|
const jobData = {
|
|
45
45
|
url: input.url,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { BucketKeyArgs, ContentService, ContentVisibility, CopyFileArgs, GetDownloadURLArgs, GetUploadURLArgs, SignContentKeyArgs, SignURLArgs, UploadURLResult, WriteFileArgs } from './content-service.js';
|
|
2
|
+
export type ContentGrantMode = 'read' | 'write';
|
|
3
|
+
export declare const normaliseContentPath: (value: string) => string;
|
|
4
|
+
/**
|
|
5
|
+
* A `ContentService` narrowed to one folder of the store.
|
|
6
|
+
*
|
|
7
|
+
* Whatever bucket the addon names lands under `<root>/<bucket>`, and the keys
|
|
8
|
+
* and buckets it passes are refused if they climb out. Starting a bucket with
|
|
9
|
+
* `@` names a path from the root of the store instead, which only works under
|
|
10
|
+
* a prefix the host granted: `read` allows reads and signed downloads, `write`
|
|
11
|
+
* adds uploads, writes and deletes, and the longest matching prefix decides.
|
|
12
|
+
* `signURL` and `copyFile` take a finished URL or a local filesystem path, so
|
|
13
|
+
* neither can be narrowed to a folder and both are refused.
|
|
14
|
+
*/
|
|
15
|
+
export declare class ScopedContentService implements ContentService {
|
|
16
|
+
private content;
|
|
17
|
+
private root;
|
|
18
|
+
private grants;
|
|
19
|
+
constructor(content: ContentService, root: string, grants?: Record<string, ContentGrantMode>);
|
|
20
|
+
private resolve;
|
|
21
|
+
private resolveClean;
|
|
22
|
+
signContentKey(args: SignContentKeyArgs): Promise<string>;
|
|
23
|
+
signURL(_args: SignURLArgs): Promise<string>;
|
|
24
|
+
getUploadURL(args: GetUploadURLArgs): Promise<UploadURLResult>;
|
|
25
|
+
deleteFile(args: BucketKeyArgs): Promise<boolean>;
|
|
26
|
+
writeFile(args: WriteFileArgs): Promise<boolean>;
|
|
27
|
+
copyFile(_args: CopyFileArgs): Promise<boolean>;
|
|
28
|
+
readFile(args: BucketKeyArgs): Promise<ReadableStream | NodeJS.ReadableStream>;
|
|
29
|
+
readFileAsBuffer(args: BucketKeyArgs): ReturnType<ContentService['readFileAsBuffer']>;
|
|
30
|
+
getDownloadURL(args: GetDownloadURLArgs): Promise<string>;
|
|
31
|
+
deleteByPrefix(bucket: string, prefix: string, visibility?: ContentVisibility): Promise<number>;
|
|
32
|
+
listFilesByPrefix(bucket: string, prefix: string, visibility?: ContentVisibility): Promise<string[]>;
|
|
33
|
+
private keysUnder;
|
|
34
|
+
private resolvePrefix;
|
|
35
|
+
}
|