@teikyo/blob 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +204 -2
- package/lib/auth/grants.d.ts +16 -0
- package/lib/auth/grants.js +26 -0
- package/lib/auth/oauth.d.ts +5 -0
- package/lib/auth/oauth.js +6 -0
- package/lib/auth/token.d.ts +23 -0
- package/lib/auth/token.js +88 -0
- package/lib/capability.d.ts +19 -0
- package/lib/capability.js +25 -0
- package/lib/context.d.ts +8 -0
- package/lib/context.js +16 -0
- package/lib/did.d.ts +2 -0
- package/lib/did.js +8 -0
- package/lib/errors.d.ts +12 -0
- package/lib/errors.js +53 -0
- package/lib/handlers/body.d.ts +2 -0
- package/lib/handlers/body.js +3 -0
- package/lib/handlers/guard.d.ts +17 -0
- package/lib/handlers/guard.js +27 -0
- package/lib/handlers/index.d.ts +6 -0
- package/lib/handlers/index.js +153 -0
- package/lib/handlers/manage.d.ts +5 -0
- package/lib/handlers/manage.js +81 -0
- package/lib/handlers/read.d.ts +4 -0
- package/lib/handlers/read.js +118 -0
- package/lib/handlers/router.d.ts +31 -0
- package/lib/handlers/router.js +55 -0
- package/lib/handlers/types.d.ts +36 -0
- package/lib/handlers/types.js +1 -0
- package/lib/handlers/write.d.ts +3 -0
- package/lib/handlers/write.js +78 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.js +11 -0
- package/lib/limits.d.ts +6 -0
- package/lib/limits.js +11 -0
- package/lib/plugin.d.ts +51 -0
- package/lib/plugin.js +190 -0
- package/lib/range.d.ts +11 -0
- package/lib/range.js +30 -0
- package/lib/readiness.d.ts +7 -0
- package/lib/readiness.js +17 -0
- package/lib/resources.d.ts +8 -0
- package/lib/resources.js +9 -0
- package/lib/transport.d.ts +4 -0
- package/lib/transport.js +15 -0
- package/lib/types.d.ts +34 -0
- package/lib/types.js +1 -0
- package/package.json +60 -3
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { getLogger } from '@sozai/log';
|
|
2
|
+
import { errorResponse, toErrorResponse } from '../errors.js';
|
|
3
|
+
import { assertUploadLimits } from '../limits.js';
|
|
4
|
+
import { cancelBody } from './body.js';
|
|
5
|
+
import { deleteBlob, listBlobs, pinBlob, unpinBlob } from './manage.js';
|
|
6
|
+
import { getBlob, getBlobManifest, getBlobMeta } from './read.js';
|
|
7
|
+
import { decodeSegment, matchRoute, methodNotAllowed, relativeSegments, resolveBasePath } from './router.js';
|
|
8
|
+
import { uploadBlob } from './write.js';
|
|
9
|
+
export const DEFAULT_BASE_PATH = '/blobs';
|
|
10
|
+
export const DEFAULT_MAX_UPLOAD_SIZE = 104857600;
|
|
11
|
+
export const DEFAULT_UPLOAD_TIMEOUT_MS = 600000;
|
|
12
|
+
const routes = [
|
|
13
|
+
{
|
|
14
|
+
method: 'GET',
|
|
15
|
+
pattern: [
|
|
16
|
+
':id'
|
|
17
|
+
],
|
|
18
|
+
handler: getBlob
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
method: 'HEAD',
|
|
22
|
+
pattern: [
|
|
23
|
+
':id'
|
|
24
|
+
],
|
|
25
|
+
handler: getBlob
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
method: 'GET',
|
|
29
|
+
pattern: [
|
|
30
|
+
':id',
|
|
31
|
+
'meta'
|
|
32
|
+
],
|
|
33
|
+
handler: getBlobMeta
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
method: 'HEAD',
|
|
37
|
+
pattern: [
|
|
38
|
+
':id',
|
|
39
|
+
'meta'
|
|
40
|
+
],
|
|
41
|
+
handler: getBlobMeta
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
method: 'GET',
|
|
45
|
+
pattern: [
|
|
46
|
+
':id',
|
|
47
|
+
'manifest'
|
|
48
|
+
],
|
|
49
|
+
handler: getBlobManifest
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
method: 'HEAD',
|
|
53
|
+
pattern: [
|
|
54
|
+
':id',
|
|
55
|
+
'manifest'
|
|
56
|
+
],
|
|
57
|
+
handler: getBlobManifest
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
method: 'POST',
|
|
61
|
+
pattern: [],
|
|
62
|
+
handler: uploadBlob
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
method: 'GET',
|
|
66
|
+
pattern: [],
|
|
67
|
+
handler: listBlobs
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
method: 'HEAD',
|
|
71
|
+
pattern: [],
|
|
72
|
+
handler: listBlobs
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
method: 'PUT',
|
|
76
|
+
pattern: [
|
|
77
|
+
':id'
|
|
78
|
+
],
|
|
79
|
+
handler: uploadBlob
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
method: 'DELETE',
|
|
83
|
+
pattern: [
|
|
84
|
+
':id'
|
|
85
|
+
],
|
|
86
|
+
handler: deleteBlob
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
method: 'PUT',
|
|
90
|
+
pattern: [
|
|
91
|
+
':id',
|
|
92
|
+
'pin'
|
|
93
|
+
],
|
|
94
|
+
handler: pinBlob
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
method: 'DELETE',
|
|
98
|
+
pattern: [
|
|
99
|
+
':id',
|
|
100
|
+
'pin'
|
|
101
|
+
],
|
|
102
|
+
handler: unpinBlob
|
|
103
|
+
}
|
|
104
|
+
];
|
|
105
|
+
export function createBlobHandlers(params) {
|
|
106
|
+
const maxUploadSize = params.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE;
|
|
107
|
+
const uploadTimeoutMs = params.uploadTimeoutMs ?? DEFAULT_UPLOAD_TIMEOUT_MS;
|
|
108
|
+
assertUploadLimits({
|
|
109
|
+
maxUploadSize,
|
|
110
|
+
uploadTimeoutMs,
|
|
111
|
+
maxBlobSize: params.service.limits.maxBlobSize
|
|
112
|
+
});
|
|
113
|
+
const resolved = {
|
|
114
|
+
...params,
|
|
115
|
+
basePath: resolveBasePath(params.basePath ?? DEFAULT_BASE_PATH),
|
|
116
|
+
maxUploadSize,
|
|
117
|
+
uploadTimeoutMs,
|
|
118
|
+
secureTransport: params.secureTransport ?? true,
|
|
119
|
+
logger: params.logger ?? getLogger([
|
|
120
|
+
'teikyo',
|
|
121
|
+
'blob'
|
|
122
|
+
])
|
|
123
|
+
};
|
|
124
|
+
return async (request, context = {})=>{
|
|
125
|
+
// Set once a route handler owns the request; every earlier exit releases the body.
|
|
126
|
+
let dispatched = false;
|
|
127
|
+
try {
|
|
128
|
+
const segments = relativeSegments(new URL(request.url).pathname, resolved.basePath);
|
|
129
|
+
if (segments == null) return errorResponse(404, 'NOT_FOUND');
|
|
130
|
+
const match = matchRoute(routes, segments, request.method);
|
|
131
|
+
if (match.type === 'not-found') return errorResponse(404, 'NOT_FOUND');
|
|
132
|
+
if (match.type === 'method-not-allowed') return methodNotAllowed(match.allow);
|
|
133
|
+
let blobID;
|
|
134
|
+
if (match.encodedID != null) {
|
|
135
|
+
const decoded = decodeSegment(match.encodedID);
|
|
136
|
+
if (decoded == null) return errorResponse(400, 'INVALID_ID');
|
|
137
|
+
// Throws InvalidBlobIDError (400) for malformed IDs.
|
|
138
|
+
blobID = resolved.service.codec.canonicalize(decoded);
|
|
139
|
+
}
|
|
140
|
+
dispatched = true;
|
|
141
|
+
return await match.handler({
|
|
142
|
+
params: resolved,
|
|
143
|
+
request,
|
|
144
|
+
context,
|
|
145
|
+
blobID
|
|
146
|
+
});
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return toErrorResponse(error, resolved.logger);
|
|
149
|
+
} finally{
|
|
150
|
+
if (!dispatched) await cancelBody(request);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { InvalidCursorError } from '@hozon/store-blob';
|
|
2
|
+
import { errorResponse } from '../errors.js';
|
|
3
|
+
import { authorize } from './guard.js';
|
|
4
|
+
const DEFAULT_LIST_LIMIT = 100;
|
|
5
|
+
const MAX_LIST_LIMIT = 1000;
|
|
6
|
+
const noContent = ()=>new Response(null, {
|
|
7
|
+
status: 204
|
|
8
|
+
});
|
|
9
|
+
export const deleteBlob = async (input)=>{
|
|
10
|
+
const { params, request, context, blobID } = input;
|
|
11
|
+
if (blobID == null) return errorResponse(404, 'NOT_FOUND');
|
|
12
|
+
await authorize({
|
|
13
|
+
guard: params,
|
|
14
|
+
request,
|
|
15
|
+
context,
|
|
16
|
+
action: 'delete',
|
|
17
|
+
blobID
|
|
18
|
+
});
|
|
19
|
+
const deleted = await params.service.delete(blobID);
|
|
20
|
+
return deleted ? noContent() : errorResponse(404, 'NOT_FOUND');
|
|
21
|
+
};
|
|
22
|
+
function setPinned(pinned) {
|
|
23
|
+
return async (input)=>{
|
|
24
|
+
const { params, request, context, blobID } = input;
|
|
25
|
+
if (blobID == null) return errorResponse(404, 'NOT_FOUND');
|
|
26
|
+
await authorize({
|
|
27
|
+
guard: params,
|
|
28
|
+
request,
|
|
29
|
+
context,
|
|
30
|
+
action: 'pin',
|
|
31
|
+
blobID
|
|
32
|
+
});
|
|
33
|
+
// Throws BlobNotFoundError (404) for an unknown blob.
|
|
34
|
+
await params.service.setPinned(blobID, pinned);
|
|
35
|
+
return noContent();
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export const pinBlob = setPinned(true);
|
|
39
|
+
export const unpinBlob = setPinned(false);
|
|
40
|
+
export const listBlobs = async (input)=>{
|
|
41
|
+
const { params, request, context } = input;
|
|
42
|
+
await authorize({
|
|
43
|
+
guard: params,
|
|
44
|
+
request,
|
|
45
|
+
context,
|
|
46
|
+
action: 'list'
|
|
47
|
+
});
|
|
48
|
+
const query = new URL(request.url).searchParams;
|
|
49
|
+
const rawLimit = query.get('limit');
|
|
50
|
+
let limit = DEFAULT_LIST_LIMIT;
|
|
51
|
+
if (rawLimit != null) {
|
|
52
|
+
if (!/^\d+$/.test(rawLimit)) return errorResponse(400, 'INVALID_LIMIT');
|
|
53
|
+
limit = Number(rawLimit);
|
|
54
|
+
if (limit < 1 || limit > MAX_LIST_LIMIT) return errorResponse(400, 'INVALID_LIMIT');
|
|
55
|
+
}
|
|
56
|
+
const cursor = query.get('cursor') ?? undefined;
|
|
57
|
+
let page;
|
|
58
|
+
try {
|
|
59
|
+
page = await params.service.list({
|
|
60
|
+
limit,
|
|
61
|
+
cursor
|
|
62
|
+
});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
// hozon throws InvalidCursorError for a malformed cursor.
|
|
65
|
+
if (cursor != null && error instanceof InvalidCursorError) {
|
|
66
|
+
return errorResponse(400, 'INVALID_CURSOR');
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
const { entries, nextCursor } = page;
|
|
71
|
+
const body = request.method === 'HEAD' ? null : JSON.stringify({
|
|
72
|
+
entries,
|
|
73
|
+
nextCursor
|
|
74
|
+
});
|
|
75
|
+
return new Response(body, {
|
|
76
|
+
status: 200,
|
|
77
|
+
headers: {
|
|
78
|
+
'Content-Type': 'application/json'
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { fromUTF, toB64U } from '@sozai/codec';
|
|
2
|
+
import { errorResponse } from '../errors.js';
|
|
3
|
+
import { parseRange } from '../range.js';
|
|
4
|
+
import { authorize } from './guard.js';
|
|
5
|
+
const CACHE_CONTROL = 'private, max-age=31536000, immutable';
|
|
6
|
+
// Stored user content is served on the API origin: forbid sniffing and sandbox any rendering.
|
|
7
|
+
const CONTENT_SECURITY_HEADERS = {
|
|
8
|
+
'X-Content-Type-Options': 'nosniff',
|
|
9
|
+
'Content-Security-Policy': 'sandbox'
|
|
10
|
+
};
|
|
11
|
+
/** Whether `If-None-Match` matches the (strong) ETag for `blobID`; weak and listed tags count. */ function matchesIfNoneMatch(header, blobID) {
|
|
12
|
+
if (header == null) return false;
|
|
13
|
+
return header.split(',').some((part)=>{
|
|
14
|
+
const tag = part.trim();
|
|
15
|
+
if (tag === '*') return true;
|
|
16
|
+
const strong = tag.startsWith('W/') ? tag.slice(2) : tag;
|
|
17
|
+
return strong === `"${blobID}"`;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function jsonResponse(body, isHead) {
|
|
21
|
+
const text = JSON.stringify(body);
|
|
22
|
+
return new Response(isHead ? null : text, {
|
|
23
|
+
status: 200,
|
|
24
|
+
headers: {
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
'Content-Length': String(fromUTF(text).byteLength)
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function notFound() {
|
|
31
|
+
return errorResponse(404, 'NOT_FOUND');
|
|
32
|
+
}
|
|
33
|
+
/** Authorized entry (any state), or a ready 404 response for an unknown blob. */ async function readEntry(input) {
|
|
34
|
+
const { params, request, context, blobID } = input;
|
|
35
|
+
if (blobID == null) return notFound();
|
|
36
|
+
await authorize({
|
|
37
|
+
guard: params,
|
|
38
|
+
request,
|
|
39
|
+
context,
|
|
40
|
+
action: 'read',
|
|
41
|
+
blobID
|
|
42
|
+
});
|
|
43
|
+
return await params.service.get(blobID) ?? notFound();
|
|
44
|
+
}
|
|
45
|
+
export const getBlob = async (input)=>{
|
|
46
|
+
const { params, request, blobID } = input;
|
|
47
|
+
const entry = await readEntry(input);
|
|
48
|
+
if (entry instanceof Response) return entry;
|
|
49
|
+
// Plain GET of a non-local blob is 404.
|
|
50
|
+
if (entry.state !== 'local' || blobID == null) return notFound();
|
|
51
|
+
const etag = `"${blobID}"`;
|
|
52
|
+
if (matchesIfNoneMatch(request.headers.get('If-None-Match'), blobID)) {
|
|
53
|
+
return new Response(null, {
|
|
54
|
+
status: 304,
|
|
55
|
+
headers: {
|
|
56
|
+
ETag: etag,
|
|
57
|
+
'Cache-Control': CACHE_CONTROL,
|
|
58
|
+
...CONTENT_SECURITY_HEADERS
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const length = entry.contentLength;
|
|
63
|
+
const headers = new Headers({
|
|
64
|
+
'Content-Type': entry.contentType || 'application/octet-stream',
|
|
65
|
+
'Accept-Ranges': 'bytes',
|
|
66
|
+
ETag: etag,
|
|
67
|
+
'Cache-Control': CACHE_CONTROL,
|
|
68
|
+
...CONTENT_SECURITY_HEADERS
|
|
69
|
+
});
|
|
70
|
+
const range = parseRange(request.headers.get('Range'), length);
|
|
71
|
+
if (range === 'unsatisfiable') {
|
|
72
|
+
return errorResponse(416, 'RANGE_NOT_SATISFIABLE', {
|
|
73
|
+
'Content-Range': `bytes */${length}`
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const isHead = request.method === 'HEAD';
|
|
77
|
+
let status = 200;
|
|
78
|
+
let contentLength = length;
|
|
79
|
+
if (range != null) {
|
|
80
|
+
status = 206;
|
|
81
|
+
contentLength = range.end - range.start + 1;
|
|
82
|
+
headers.set('Content-Range', `bytes ${range.start}-${range.end}/${length}`);
|
|
83
|
+
}
|
|
84
|
+
headers.set('Content-Length', String(contentLength));
|
|
85
|
+
if (isHead || contentLength === 0) return new Response(null, {
|
|
86
|
+
status,
|
|
87
|
+
headers
|
|
88
|
+
});
|
|
89
|
+
const body = await params.service.createReadStream(blobID, range ?? undefined);
|
|
90
|
+
return new Response(body, {
|
|
91
|
+
status,
|
|
92
|
+
headers
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
export const getBlobMeta = async (input)=>{
|
|
96
|
+
const entry = await readEntry(input);
|
|
97
|
+
if (entry instanceof Response) return entry;
|
|
98
|
+
return jsonResponse({
|
|
99
|
+
contentLength: entry.contentLength,
|
|
100
|
+
contentType: entry.contentType,
|
|
101
|
+
state: entry.state,
|
|
102
|
+
pinned: entry.pinned,
|
|
103
|
+
createdAt: entry.createdAt,
|
|
104
|
+
encrypted: entry.encrypted,
|
|
105
|
+
keyID: entry.keyID
|
|
106
|
+
}, input.request.method === 'HEAD');
|
|
107
|
+
};
|
|
108
|
+
export const getBlobManifest = async (input)=>{
|
|
109
|
+
const entry = await readEntry(input);
|
|
110
|
+
if (entry instanceof Response) return entry;
|
|
111
|
+
if (entry.state !== 'local' || input.blobID == null) return notFound();
|
|
112
|
+
const digests = await input.params.service.getChunkDigests(input.blobID);
|
|
113
|
+
return jsonResponse({
|
|
114
|
+
contentLength: entry.contentLength,
|
|
115
|
+
chunkSize: entry.chunkSize,
|
|
116
|
+
chunks: digests.map((digest)=>toB64U(digest))
|
|
117
|
+
}, input.request.method === 'HEAD');
|
|
118
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { RouteHandler } from './types.js';
|
|
2
|
+
export type Route = {
|
|
3
|
+
method: string;
|
|
4
|
+
/** Segments after `basePath`; `':id'` captures the blob ID. */
|
|
5
|
+
pattern: ReadonlyArray<string>;
|
|
6
|
+
handler: RouteHandler;
|
|
7
|
+
};
|
|
8
|
+
export type RouteMatch = {
|
|
9
|
+
type: 'matched';
|
|
10
|
+
handler: RouteHandler;
|
|
11
|
+
encodedID?: string;
|
|
12
|
+
} | {
|
|
13
|
+
type: 'method-not-allowed';
|
|
14
|
+
allow: Array<string>;
|
|
15
|
+
} | {
|
|
16
|
+
type: 'not-found';
|
|
17
|
+
};
|
|
18
|
+
/** Normalizes `basePath` to a leading slash with no trailing slash (`/` becomes empty). */
|
|
19
|
+
export declare function normalizeBasePath(basePath: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Normalizes `basePath` and checks that every segment is plain: unreserved URL characters only,
|
|
22
|
+
* not empty, `.` or `..`. The root path normalizes to an empty string and is returned as-is.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveBasePath(basePath: string): string;
|
|
25
|
+
/** Raw (still percent-encoded) segments after `basePath`, or null when outside it. */
|
|
26
|
+
export declare function relativeSegments(pathname: string, basePath: string): Array<string> | null;
|
|
27
|
+
/** Decodes a path segment exactly once; null when malformed or when the result contains `/`. */
|
|
28
|
+
export declare function decodeSegment(segment: string): string | null;
|
|
29
|
+
/** Matches raw path segments and the method against `routes`; the `:id` segment stays encoded. */
|
|
30
|
+
export declare function matchRoute(routes: ReadonlyArray<Route>, segments: Array<string>, method: string): RouteMatch;
|
|
31
|
+
export declare function methodNotAllowed(allow: Array<string>): Response;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { errorResponse } from '../errors.js';
|
|
2
|
+
/** Normalizes `basePath` to a leading slash with no trailing slash (`/` becomes empty). */ export function normalizeBasePath(basePath) {
|
|
3
|
+
const trimmed = basePath.replace(/\/+$/, '');
|
|
4
|
+
return trimmed === '' || trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
5
|
+
}
|
|
6
|
+
const PLAIN_SEGMENT = /^[A-Za-z0-9._~-]+$/;
|
|
7
|
+
/**
|
|
8
|
+
* Normalizes `basePath` and checks that every segment is plain: unreserved URL characters only,
|
|
9
|
+
* not empty, `.` or `..`. The root path normalizes to an empty string and is returned as-is.
|
|
10
|
+
*/ export function resolveBasePath(basePath) {
|
|
11
|
+
const normalized = normalizeBasePath(basePath);
|
|
12
|
+
if (normalized === '') return normalized;
|
|
13
|
+
const valid = normalized.slice(1).split('/').every((segment)=>PLAIN_SEGMENT.test(segment) && segment !== '.' && segment !== '..');
|
|
14
|
+
if (!valid) throw new Error('basePath must be "/" or plain path segments');
|
|
15
|
+
return normalized;
|
|
16
|
+
}
|
|
17
|
+
/** Raw (still percent-encoded) segments after `basePath`, or null when outside it. */ export function relativeSegments(pathname, basePath) {
|
|
18
|
+
if (pathname === basePath) return [];
|
|
19
|
+
if (!pathname.startsWith(`${basePath}/`)) return null;
|
|
20
|
+
return pathname.slice(basePath.length + 1).split('/');
|
|
21
|
+
}
|
|
22
|
+
/** Decodes a path segment exactly once; null when malformed or when the result contains `/`. */ export function decodeSegment(segment) {
|
|
23
|
+
try {
|
|
24
|
+
const decoded = decodeURIComponent(segment);
|
|
25
|
+
return decoded.includes('/') ? null : decoded;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Matches raw path segments and the method against `routes`; the `:id` segment stays encoded. */ export function matchRoute(routes, segments, method) {
|
|
31
|
+
const candidates = routes.filter((route)=>route.pattern.length === segments.length && route.pattern.every((part, index)=>part === ':id' ? segments[index] !== '' : part === segments[index]));
|
|
32
|
+
if (candidates.length === 0) return {
|
|
33
|
+
type: 'not-found'
|
|
34
|
+
};
|
|
35
|
+
const route = candidates.find((candidate)=>candidate.method === method);
|
|
36
|
+
if (route == null) {
|
|
37
|
+
return {
|
|
38
|
+
type: 'method-not-allowed',
|
|
39
|
+
allow: [
|
|
40
|
+
...new Set(candidates.map((c)=>c.method))
|
|
41
|
+
]
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const idIndex = route.pattern.indexOf(':id');
|
|
45
|
+
return {
|
|
46
|
+
type: 'matched',
|
|
47
|
+
handler: route.handler,
|
|
48
|
+
encodedID: idIndex === -1 ? undefined : segments[idIndex]
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function methodNotAllowed(allow) {
|
|
52
|
+
return errorResponse(405, 'METHOD_NOT_ALLOWED', {
|
|
53
|
+
Allow: allow.join(', ')
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { BlobService } from '@hozon/blob';
|
|
2
|
+
import type { Logger } from '@sozai/log';
|
|
3
|
+
import type { SecureTransportOption } from '../transport.js';
|
|
4
|
+
import type { BlobAuthenticator, BlobAuthorizer, BlobRequestContext } from '../types.js';
|
|
5
|
+
export type BlobHandlersParams = {
|
|
6
|
+
service: BlobService;
|
|
7
|
+
authenticator: BlobAuthenticator;
|
|
8
|
+
authorizer: BlobAuthorizer;
|
|
9
|
+
/** Default `/blobs`. */
|
|
10
|
+
basePath?: string;
|
|
11
|
+
/** Default 100 MiB. */
|
|
12
|
+
maxUploadSize?: number;
|
|
13
|
+
/** Default 10 minutes. */
|
|
14
|
+
uploadTimeoutMs?: number;
|
|
15
|
+
/** Default `true`. */
|
|
16
|
+
secureTransport?: SecureTransportOption;
|
|
17
|
+
logger?: Logger;
|
|
18
|
+
};
|
|
19
|
+
/** Params with defaults applied. */
|
|
20
|
+
export type ResolvedBlobHandlersParams = BlobHandlersParams & {
|
|
21
|
+
basePath: string;
|
|
22
|
+
maxUploadSize: number;
|
|
23
|
+
uploadTimeoutMs: number;
|
|
24
|
+
secureTransport: SecureTransportOption;
|
|
25
|
+
logger: Logger;
|
|
26
|
+
};
|
|
27
|
+
export type RouteInput = {
|
|
28
|
+
params: ResolvedBlobHandlersParams;
|
|
29
|
+
request: Request;
|
|
30
|
+
context: BlobRequestContext;
|
|
31
|
+
/** Canonical blob ID when the route pattern has an `:id` segment. */
|
|
32
|
+
blobID?: string;
|
|
33
|
+
};
|
|
34
|
+
/** Request handler returned by `createBlobHandlers`. */
|
|
35
|
+
export type BlobHandler = (request: Request, context?: BlobRequestContext) => Promise<Response>;
|
|
36
|
+
export type RouteHandler = (input: RouteInput) => Promise<Response>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { BlobWriteAbortedError } from '@hozon/blob';
|
|
2
|
+
import { errorResponse } from '../errors.js';
|
|
3
|
+
import { cancelBody } from './body.js';
|
|
4
|
+
import { authorize } from './guard.js';
|
|
5
|
+
const MAX_CONTENT_TYPE_LENGTH = 255;
|
|
6
|
+
/** Parses `Content-Length` into a non-negative integer, `null` when absent, `'invalid'` otherwise. */ function parseContentLength(header) {
|
|
7
|
+
if (header == null) return null;
|
|
8
|
+
if (!/^\d+$/.test(header)) return 'invalid';
|
|
9
|
+
const value = Number(header);
|
|
10
|
+
return Number.isSafeInteger(value) ? value : 'invalid';
|
|
11
|
+
}
|
|
12
|
+
/** Shared by `POST /` (no blobID) and `PUT /:id` (expected ID). */ export const uploadBlob = async (input)=>{
|
|
13
|
+
const state = {
|
|
14
|
+
handedOff: false
|
|
15
|
+
};
|
|
16
|
+
try {
|
|
17
|
+
return await upload(input, state);
|
|
18
|
+
} finally{
|
|
19
|
+
// Every early return or throw before the service owns the body releases it.
|
|
20
|
+
if (!state.handedOff) await cancelBody(input.request);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
async function upload(input, state) {
|
|
24
|
+
const { params, request, context, blobID } = input;
|
|
25
|
+
await authorize({
|
|
26
|
+
guard: params,
|
|
27
|
+
request,
|
|
28
|
+
context,
|
|
29
|
+
action: 'write',
|
|
30
|
+
blobID
|
|
31
|
+
});
|
|
32
|
+
const contentType = request.headers.get('Content-Type');
|
|
33
|
+
if (contentType != null && contentType.length > MAX_CONTENT_TYPE_LENGTH) {
|
|
34
|
+
return errorResponse(400, 'INVALID_CONTENT_TYPE');
|
|
35
|
+
}
|
|
36
|
+
const contentLength = parseContentLength(request.headers.get('Content-Length'));
|
|
37
|
+
if (contentLength === 'invalid') return errorResponse(400, 'INVALID_CONTENT_LENGTH');
|
|
38
|
+
// Checked before touching the body so an oversized upload is never read.
|
|
39
|
+
if (contentLength != null && contentLength > params.maxUploadSize) {
|
|
40
|
+
return errorResponse(413, 'TOO_LARGE');
|
|
41
|
+
}
|
|
42
|
+
const timeoutSignal = AbortSignal.timeout(params.uploadTimeoutMs);
|
|
43
|
+
const signal = AbortSignal.any([
|
|
44
|
+
request.signal,
|
|
45
|
+
timeoutSignal
|
|
46
|
+
]);
|
|
47
|
+
const body = request.body ?? new ReadableStream({
|
|
48
|
+
start: (c)=>c.close()
|
|
49
|
+
});
|
|
50
|
+
state.handedOff = true;
|
|
51
|
+
try {
|
|
52
|
+
const { entry, created } = await params.service.write({
|
|
53
|
+
stream: body,
|
|
54
|
+
maxSize: params.maxUploadSize,
|
|
55
|
+
signal,
|
|
56
|
+
contentType: contentType ?? undefined,
|
|
57
|
+
expectedID: blobID
|
|
58
|
+
});
|
|
59
|
+
return new Response(JSON.stringify({
|
|
60
|
+
id: entry.blobID,
|
|
61
|
+
contentLength: entry.contentLength,
|
|
62
|
+
contentType: entry.contentType
|
|
63
|
+
}), {
|
|
64
|
+
status: created ? 201 : 200,
|
|
65
|
+
headers: {
|
|
66
|
+
'Content-Type': 'application/json'
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof BlobWriteAbortedError || signal.aborted) {
|
|
71
|
+
// Benign race: a non-abort error coinciding with the timeout reports 408.
|
|
72
|
+
// A disconnected client never receives this 499; it only completes the handler contract.
|
|
73
|
+
if (request.signal.aborted) return errorResponse(499, 'CLIENT_CLOSED');
|
|
74
|
+
if (timeoutSignal.aborted) return errorResponse(408, 'UPLOAD_TIMEOUT');
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { type GrantsAuthorizerParams, grantsAuthorizer } from './auth/grants.js';
|
|
2
|
+
export { oauthAuthenticator, requiredScope } from './auth/oauth.js';
|
|
3
|
+
export { type CheckDelegation, type CheckDelegationParams, type TokenAuthenticator, type TokenAuthenticatorParams, tokenAuthenticator, } from './auth/token.js';
|
|
4
|
+
export { createReadCapabilityMinter, DEFAULT_READ_CAPABILITY_TTL_SECONDS, type MintReadCapability, type MintReadCapabilityParams, type ReadCapabilityMinterParams, } from './capability.js';
|
|
5
|
+
export { transportFromHono } from './context.js';
|
|
6
|
+
export { BlobAccessDeniedError, BlobAuthenticationError, BlobInsecureTransportError, errorResponse, toErrorResponse, } from './errors.js';
|
|
7
|
+
export { type BlobHandler, type BlobHandlersParams, createBlobHandlers, DEFAULT_BASE_PATH, DEFAULT_MAX_UPLOAD_SIZE, DEFAULT_UPLOAD_TIMEOUT_MS, } from './handlers/index.js';
|
|
8
|
+
export { BLOB_STORE, type BlobPluginAPI, type BlobPluginParams, blobPlugin, DEFAULT_STAGING_MAX_AGE_MS, } from './plugin.js';
|
|
9
|
+
export { type ParsedRange, parseRange } from './range.js';
|
|
10
|
+
export { capabilityPermission, grantResource } from './resources.js';
|
|
11
|
+
export { isTransportAllowed, type SecureTransportOption } from './transport.js';
|
|
12
|
+
export type { BlobAction, BlobAuthenticator, BlobAuthorizer, BlobPrincipal, BlobRequestContext, TokenPayload, TransportInfo, } from './types.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { grantsAuthorizer } from './auth/grants.js';
|
|
2
|
+
export { oauthAuthenticator, requiredScope } from './auth/oauth.js';
|
|
3
|
+
export { tokenAuthenticator } from './auth/token.js';
|
|
4
|
+
export { createReadCapabilityMinter, DEFAULT_READ_CAPABILITY_TTL_SECONDS } from './capability.js';
|
|
5
|
+
export { transportFromHono } from './context.js';
|
|
6
|
+
export { BlobAccessDeniedError, BlobAuthenticationError, BlobInsecureTransportError, errorResponse, toErrorResponse } from './errors.js';
|
|
7
|
+
export { createBlobHandlers, DEFAULT_BASE_PATH, DEFAULT_MAX_UPLOAD_SIZE, DEFAULT_UPLOAD_TIMEOUT_MS } from './handlers/index.js';
|
|
8
|
+
export { BLOB_STORE, blobPlugin, DEFAULT_STAGING_MAX_AGE_MS } from './plugin.js';
|
|
9
|
+
export { parseRange } from './range.js';
|
|
10
|
+
export { capabilityPermission, grantResource } from './resources.js';
|
|
11
|
+
export { isTransportAllowed } from './transport.js';
|
package/lib/limits.d.ts
ADDED
package/lib/limits.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// setTimeout/AbortSignal.timeout limit (2^32 - 1).
|
|
2
|
+
const MAX_TIMEOUT_MS = 4294967295;
|
|
3
|
+
/** Throws unless `maxUploadSize` is in 1..`maxBlobSize` and `uploadTimeoutMs` a valid timeout. */ export function assertUploadLimits(params) {
|
|
4
|
+
const { maxUploadSize, uploadTimeoutMs, maxBlobSize } = params;
|
|
5
|
+
if (!Number.isSafeInteger(maxUploadSize) || maxUploadSize <= 0 || maxUploadSize > maxBlobSize) {
|
|
6
|
+
throw new Error('Invalid maxUploadSize');
|
|
7
|
+
}
|
|
8
|
+
if (!Number.isSafeInteger(uploadTimeoutMs) || uploadTimeoutMs <= 0 || uploadTimeoutMs > MAX_TIMEOUT_MS) {
|
|
9
|
+
throw new Error('Invalid uploadTimeoutMs');
|
|
10
|
+
}
|
|
11
|
+
}
|
package/lib/plugin.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type BlobService, type BlobServiceParams } from '@hozon/blob';
|
|
2
|
+
import type { SigningIdentity } from '@kokuin/token';
|
|
3
|
+
import { type AnyPluginName, type HTTPPlugin } from '@sozai/http-server';
|
|
4
|
+
import { type TokenAuthenticatorParams } from './auth/token.js';
|
|
5
|
+
import { type MintReadCapability } from './capability.js';
|
|
6
|
+
import { type BlobHandler } from './handlers/index.js';
|
|
7
|
+
import type { SecureTransportOption } from './transport.js';
|
|
8
|
+
import type { BlobAction, BlobAuthenticator, BlobAuthorizer } from './types.js';
|
|
9
|
+
export declare const DEFAULT_STAGING_MAX_AGE_MS = 86400000;
|
|
10
|
+
export type BlobPluginParams = Pick<BlobServiceParams, 'backend' | 'codec' | 'chunkSize' | 'lock' | 'limits'> & {
|
|
11
|
+
/** Default `/blobs`; the root path is rejected. */
|
|
12
|
+
basePath?: string;
|
|
13
|
+
/** Default 100 MiB; a positive integer at most the service `maxBlobSize`. */
|
|
14
|
+
maxUploadSize?: number;
|
|
15
|
+
/** Default 10 minutes. */
|
|
16
|
+
uploadTimeoutMs?: number;
|
|
17
|
+
/** Replaces the default kokuin token authenticator. Not allowed with `oauth`. */
|
|
18
|
+
authenticator?: BlobAuthenticator;
|
|
19
|
+
/**
|
|
20
|
+
* Replaces the default grants authorizer, removing the `access:grants` dependency. With the
|
|
21
|
+
* default token authenticator, delegated principals reach it only after `checkDelegation`.
|
|
22
|
+
*/
|
|
23
|
+
authorizer?: BlobAuthorizer;
|
|
24
|
+
/** This service's identifier, required by the default token authenticator. */
|
|
25
|
+
audience?: string;
|
|
26
|
+
verifyOptions?: TokenAuthenticatorParams['verifyOptions'];
|
|
27
|
+
/** Check invocation tokens and their proofs through `access:revocation` (default authenticator only). */
|
|
28
|
+
revocation?: boolean;
|
|
29
|
+
/** OAuth mode through `oauth:resource`, with per-action scopes defaulting to `blob:<action>`. */
|
|
30
|
+
oauth?: {
|
|
31
|
+
scopes?: Partial<Record<BlobAction, string>>;
|
|
32
|
+
};
|
|
33
|
+
/** Server identity; enables `mintReadCapability`. Default token authenticator only. */
|
|
34
|
+
signer?: SigningIdentity;
|
|
35
|
+
/** Default `true`. */
|
|
36
|
+
secureTransport?: SecureTransportOption;
|
|
37
|
+
/** Staging older than this is pruned at startup. Default 24 hours, above `uploadTimeoutMs`. */
|
|
38
|
+
stagingMaxAgeMs?: number;
|
|
39
|
+
};
|
|
40
|
+
export type BlobPluginAPI = {
|
|
41
|
+
service: BlobService;
|
|
42
|
+
handlers: BlobHandler;
|
|
43
|
+
mintReadCapability?: MintReadCapability;
|
|
44
|
+
};
|
|
45
|
+
export declare const BLOB_STORE: import("@sozai/http-server").PluginName<"blob:store", BlobPluginAPI>;
|
|
46
|
+
/**
|
|
47
|
+
* Serve blob storage on `basePath`. Authenticates with kokuin invocation tokens by default (or
|
|
48
|
+
* OAuth bearer tokens with `oauth`) and authorizes through `access:grants` unless a custom
|
|
49
|
+
* `authorizer` is given.
|
|
50
|
+
*/
|
|
51
|
+
export declare function blobPlugin(params: BlobPluginParams): HTTPPlugin<'blob:store', BlobPluginAPI, Array<AnyPluginName>>;
|