@aphrody/shenron-dbxv2 0.0.0-stage → 1.4.7
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 +201 -0
- package/README.md +20 -2
- package/package.json +77 -4
- package/src/cache.ts +84 -0
- package/src/client.ts +623 -0
- package/src/db.ts +67 -0
- package/src/declarations.ts +135 -0
- package/src/env.ts +111 -0
- package/src/errors.ts +141 -0
- package/src/guard.ts +115 -0
- package/src/index.ts +15 -0
- package/src/pixel-perfect.ts +139 -0
- package/src/plugin.ts +204 -0
- package/src/preload.ts +13 -0
- package/src/proxy.ts +243 -0
- package/src/refs.ts +126 -0
- package/src/runtime.ts +130 -0
- package/src/schemas.ts +60 -0
- package/src/texture.ts +83 -0
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun plugin for `dbxv2:<cpk>/<path-in-cpk>?<format>` imports (private, development only).
|
|
3
|
+
*
|
|
4
|
+
* import url from "dbxv2:CHARA/chara/001.emb?png"; // signed loopback URL (string)
|
|
5
|
+
* import wav from "dbxv2:BGM/bgm/theme.acb?wav"; // idem, audio/wav
|
|
6
|
+
* import clip from "dbxv2:MOVIE/op.usm?video"; // idem, mp4/webm/mpeg
|
|
7
|
+
* import model from "dbxv2:CHARA/chara/001.emd?model"; // validated model JSON (top-level await)
|
|
8
|
+
* import glb from "dbxv2:CHARA/chara/001.emd?glb"; // signed URL of the binary glTF
|
|
9
|
+
* import bytes from "dbxv2:CHARA/chara/001.emd?raw"; // ArrayBuffer (top-level await)
|
|
10
|
+
*
|
|
11
|
+
* Extra parameters follow the format: `?png&index=2`, `?wav&cue=1`, `?model&emb=...&esk=...`.
|
|
12
|
+
* Generated modules read the server from the environment at run time: no game byte, URL or token
|
|
13
|
+
* is ever fixed in a module. `Bun.build` is refused for public/production builds.
|
|
14
|
+
*/
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { Dbxv2ConfigError, Dbxv2DisabledError } from "./errors";
|
|
17
|
+
import { dbxv2Status, describeDisabledReason, type Dbxv2EnvInput } from "./env";
|
|
18
|
+
import { assertDbxv2BuildAllowed, DBXV2_MARKER, type BuildConfigLike } from "./guard";
|
|
19
|
+
import { parseRef, type Dbxv2Ref, type Dbxv2ResourceKind, type Dbxv2ResourceParams } from "./refs";
|
|
20
|
+
|
|
21
|
+
export const DBXV2_NAMESPACE = "dbxv2";
|
|
22
|
+
|
|
23
|
+
export type Dbxv2Format = "png" | "wav" | "video" | "model" | "glb" | "raw";
|
|
24
|
+
|
|
25
|
+
const FORMATS: readonly Dbxv2Format[] = ["png", "wav", "video", "model", "glb", "raw"];
|
|
26
|
+
|
|
27
|
+
const KIND_OF: Readonly<Record<Dbxv2Format, Dbxv2ResourceKind>> = {
|
|
28
|
+
png: "png",
|
|
29
|
+
wav: "wav",
|
|
30
|
+
video: "video",
|
|
31
|
+
model: "model.json",
|
|
32
|
+
glb: "model.glb",
|
|
33
|
+
raw: "raw",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface ParsedSpecifier {
|
|
37
|
+
readonly ref: Dbxv2Ref;
|
|
38
|
+
readonly format: Dbxv2Format;
|
|
39
|
+
readonly params: Dbxv2ResourceParams;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Parses `dbxv2:<cpk>/<path>?<format>[¶m=value]`. */
|
|
43
|
+
export function parseSpecifier(specifier: string): ParsedSpecifier {
|
|
44
|
+
if (!specifier.startsWith("dbxv2:"))
|
|
45
|
+
throw new Dbxv2ConfigError(`not a dbxv2: specifier: ${specifier}`);
|
|
46
|
+
const body = specifier.slice("dbxv2:".length);
|
|
47
|
+
const cut = body.indexOf("?");
|
|
48
|
+
const refPart = cut < 0 ? body : body.slice(0, cut);
|
|
49
|
+
const query = new URLSearchParams(cut < 0 ? "" : body.slice(cut + 1));
|
|
50
|
+
const ref = parseRef(refPart);
|
|
51
|
+
const formats = FORMATS.filter((f) => query.has(f));
|
|
52
|
+
if (formats.length !== 1) {
|
|
53
|
+
throw new Dbxv2ConfigError(
|
|
54
|
+
`dbxv2:${refPart} needs exactly one format suffix (${FORMATS.map((f) => `?${f}`).join(", ")})`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const format = formats[0]!;
|
|
58
|
+
const int = (name: string): number | undefined => {
|
|
59
|
+
const raw = query.get(name);
|
|
60
|
+
if (raw === null) return undefined;
|
|
61
|
+
const n = Number(raw);
|
|
62
|
+
if (!Number.isInteger(n) || n < 0)
|
|
63
|
+
throw new Dbxv2ConfigError(`dbxv2: parameter ${name} must be a non-negative integer`);
|
|
64
|
+
return n;
|
|
65
|
+
};
|
|
66
|
+
const params: Dbxv2ResourceParams = {
|
|
67
|
+
index: int("index"),
|
|
68
|
+
cue: int("cue"),
|
|
69
|
+
emb: query.get("emb") ?? undefined,
|
|
70
|
+
esk: query.get("esk") ?? undefined,
|
|
71
|
+
};
|
|
72
|
+
return { ref, format, params };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const defined = <T extends object>(value: T): Partial<T> =>
|
|
76
|
+
Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial<T>;
|
|
77
|
+
|
|
78
|
+
const RUNTIME_FILE = join(import.meta.dir, "runtime.ts");
|
|
79
|
+
|
|
80
|
+
/** Source of the module generated for a specifier (a thin wrapper over the runtime). */
|
|
81
|
+
export function generateModuleSource(parsed: ParsedSpecifier): string {
|
|
82
|
+
const { ref, format, params } = parsed;
|
|
83
|
+
const head = [
|
|
84
|
+
`/*${DBXV2_MARKER}*/`,
|
|
85
|
+
// Survives bundling (comments do not): the public build guard looks for this literal.
|
|
86
|
+
`globalThis[Symbol.for(${JSON.stringify(DBXV2_MARKER)})] = true;`,
|
|
87
|
+
`import { resource } from ${JSON.stringify(RUNTIME_FILE)};`,
|
|
88
|
+
`const r = resource(${JSON.stringify(ref)}, ${JSON.stringify(KIND_OF[format])}, ${JSON.stringify(defined(params))});`,
|
|
89
|
+
"export const ref = r.ref;",
|
|
90
|
+
];
|
|
91
|
+
switch (format) {
|
|
92
|
+
case "model":
|
|
93
|
+
return [
|
|
94
|
+
...head,
|
|
95
|
+
`const glb = resource(${JSON.stringify(ref)}, "model.glb", ${JSON.stringify(defined(params))});`,
|
|
96
|
+
"export const jsonUrl = r.url();",
|
|
97
|
+
"export const glbUrl = glb.url();",
|
|
98
|
+
"export default await r.model();",
|
|
99
|
+
].join("\n");
|
|
100
|
+
case "raw":
|
|
101
|
+
return [
|
|
102
|
+
...head,
|
|
103
|
+
"export const url = r.url();",
|
|
104
|
+
"export const blob = (signal) => r.blob(signal);",
|
|
105
|
+
"export default await r.arrayBuffer();",
|
|
106
|
+
].join("\n");
|
|
107
|
+
default:
|
|
108
|
+
return [
|
|
109
|
+
...head,
|
|
110
|
+
"export const url = r.url();",
|
|
111
|
+
"export const blob = (signal) => r.blob(signal);",
|
|
112
|
+
"export const arrayBuffer = (signal) => r.arrayBuffer(signal);",
|
|
113
|
+
"export default url;",
|
|
114
|
+
].join("\n");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface Dbxv2PluginOptions {
|
|
119
|
+
/** Environment (default `process.env`). */
|
|
120
|
+
readonly env?: Dbxv2EnvInput;
|
|
121
|
+
/** Explicit publication manifest: immutable approved derivatives only, never private runtime imports. */
|
|
122
|
+
readonly publicAssets?: Readonly<
|
|
123
|
+
Record<string, { readonly url: string; readonly sha256: string; readonly provenance: string }>
|
|
124
|
+
>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The plugin object. Under `Bun.build` the build config is checked first: public, production and
|
|
129
|
+
* minified builds are refused. At run time (`Bun.plugin`), a disabled DBXV2 fails the import with
|
|
130
|
+
* the reason and the variables to set.
|
|
131
|
+
*/
|
|
132
|
+
export function dbxv2Plugin(options: Dbxv2PluginOptions = {}): Bun.BunPlugin {
|
|
133
|
+
return {
|
|
134
|
+
name: "shenron-dbxv2",
|
|
135
|
+
setup(build) {
|
|
136
|
+
const env = options.env ?? (process.env as Dbxv2EnvInput);
|
|
137
|
+
const config = (build as { config?: BuildConfigLike }).config;
|
|
138
|
+
if (config && !options.publicAssets) assertDbxv2BuildAllowed(config, env);
|
|
139
|
+
build.onResolve({ filter: /^dbxv2:/ }, (args) => ({
|
|
140
|
+
path: args.path,
|
|
141
|
+
namespace: DBXV2_NAMESPACE,
|
|
142
|
+
}));
|
|
143
|
+
build.onLoad({ filter: /.*/, namespace: DBXV2_NAMESPACE }, (args) => {
|
|
144
|
+
// Bun hands the namespace load the path without the `dbxv2:` scheme (query kept).
|
|
145
|
+
const parsed = parseSpecifier(
|
|
146
|
+
args.path.startsWith("dbxv2:") ? args.path : `dbxv2:${args.path}`,
|
|
147
|
+
);
|
|
148
|
+
if (options.publicAssets) {
|
|
149
|
+
const key = args.path.startsWith("dbxv2:") ? args.path : `dbxv2:${args.path}`;
|
|
150
|
+
const asset = options.publicAssets[key];
|
|
151
|
+
const url = asset ? URL.parse(asset.url) : null;
|
|
152
|
+
if (
|
|
153
|
+
!asset ||
|
|
154
|
+
!url ||
|
|
155
|
+
url.protocol !== "https:" ||
|
|
156
|
+
url.username ||
|
|
157
|
+
url.password ||
|
|
158
|
+
url.search ||
|
|
159
|
+
url.hash ||
|
|
160
|
+
!/^[a-f0-9]{64}$/.test(asset.sha256) ||
|
|
161
|
+
!url.pathname.includes(asset.sha256) ||
|
|
162
|
+
!asset.provenance.trim() ||
|
|
163
|
+
!["png", "wav", "video", "glb"].includes(parsed.format)
|
|
164
|
+
) {
|
|
165
|
+
throw new Dbxv2ConfigError(
|
|
166
|
+
"Public imports require an approved immutable derivative with provenance",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
contents: `export const url = ${JSON.stringify(url.href)}; export const sha256 = ${JSON.stringify(asset.sha256)}; export default url;`,
|
|
171
|
+
loader: "js",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (config) {
|
|
175
|
+
// Building (development): re-check at load, nothing is resolved against the server.
|
|
176
|
+
assertDbxv2BuildAllowed(config, env);
|
|
177
|
+
} else {
|
|
178
|
+
const status = dbxv2Status(env);
|
|
179
|
+
if (status.enabled && status.config.mode === "public") {
|
|
180
|
+
throw new Dbxv2ConfigError(
|
|
181
|
+
"Public plugin imports require the publicAssets publication manifest",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (!status.enabled) {
|
|
185
|
+
throw new Dbxv2DisabledError(
|
|
186
|
+
`cannot import dbxv2:${parsed.ref.cpk}/${parsed.ref.path}: ${describeDisabledReason(status.reason)}. ` +
|
|
187
|
+
"Set DBXV2_SERVER_URL and DBXV2_TOKEN to enable the private DBXV2 server.",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { contents: generateModuleSource(parsed), loader: "js" };
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Registers the plugin for the current process (`Bun.plugin`). Idempotent per process. */
|
|
198
|
+
export function registerDbxv2Plugin(options: Dbxv2PluginOptions = {}): void {
|
|
199
|
+
const flag = Symbol.for("shenron.dbxv2.plugin");
|
|
200
|
+
const holder = globalThis as unknown as Record<symbol, boolean | undefined>;
|
|
201
|
+
if (holder[flag]) return;
|
|
202
|
+
holder[flag] = true;
|
|
203
|
+
Bun.plugin(dbxv2Plugin(options));
|
|
204
|
+
}
|
package/src/preload.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bunfig preload: registers the `dbxv2:` import plugin.
|
|
3
|
+
*
|
|
4
|
+
* [test]
|
|
5
|
+
* preload = ["./packages/dbxv2/src/preload.ts"]
|
|
6
|
+
* [run]
|
|
7
|
+
* preload = ["./packages/dbxv2/src/preload.ts"]
|
|
8
|
+
*
|
|
9
|
+
* Harmless when DBXV2 is disabled: importing a `dbxv2:` module then fails with an explicit message.
|
|
10
|
+
*/
|
|
11
|
+
import { registerDbxv2Plugin } from "./plugin";
|
|
12
|
+
|
|
13
|
+
registerDbxv2Plugin();
|
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback proxy that signs short-lived resource URLs, so `<img>`, `<audio>`, `<video>` or a
|
|
3
|
+
* native engine can load DBXV2 resources without ever seeing the bearer token.
|
|
4
|
+
*
|
|
5
|
+
* `proxy.url.png(ref)` returns `http://127.0.0.1:<port>/r/png/<cpk>/<path>?exp=<unix>&sig=<hmac>`.
|
|
6
|
+
* The signature (HMAC-SHA256 with a per-process random secret) covers kind, entry, parameters and
|
|
7
|
+
* expiry. The proxy adds `Authorization` itself, forwards `Range`, `If-Range` and
|
|
8
|
+
* `If-None-Match`, and streams the answer. It binds to loopback only.
|
|
9
|
+
*/
|
|
10
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
11
|
+
import { Dbxv2ConfigError, Dbxv2DisabledError } from "./errors";
|
|
12
|
+
import {
|
|
13
|
+
DBXV2_MIN_TOKEN_LENGTH,
|
|
14
|
+
describeDisabledReason,
|
|
15
|
+
isPublicContext,
|
|
16
|
+
normalizeServerUrl,
|
|
17
|
+
type Dbxv2EnvInput,
|
|
18
|
+
} from "./env";
|
|
19
|
+
import {
|
|
20
|
+
DBXV2_RESOURCE_KINDS,
|
|
21
|
+
parseRef,
|
|
22
|
+
resourcePath,
|
|
23
|
+
resourceQuery,
|
|
24
|
+
type Dbxv2RefInput,
|
|
25
|
+
type Dbxv2ResourceKind,
|
|
26
|
+
type Dbxv2ResourceParams,
|
|
27
|
+
} from "./refs";
|
|
28
|
+
|
|
29
|
+
export interface Dbxv2ProxyOptions {
|
|
30
|
+
readonly upstream: { readonly baseUrl: string; readonly token: string };
|
|
31
|
+
/** Lifetime of a signed URL in seconds (default 300, maximum 3600). */
|
|
32
|
+
readonly ttlSeconds?: number;
|
|
33
|
+
/** Loopback address only (default `127.0.0.1`). */
|
|
34
|
+
readonly hostname?: "127.0.0.1" | "::1" | "localhost";
|
|
35
|
+
/** 0 picks a free port. */
|
|
36
|
+
readonly port?: number;
|
|
37
|
+
/** Secret of the signatures (default: 32 random bytes per process). */
|
|
38
|
+
readonly secret?: string | Uint8Array;
|
|
39
|
+
readonly fetch?: typeof fetch;
|
|
40
|
+
/** Environment checked for a public render (default `process.env`). */
|
|
41
|
+
readonly env?: Dbxv2EnvInput;
|
|
42
|
+
readonly now?: () => number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Dbxv2UrlBuilders {
|
|
46
|
+
raw(ref: Dbxv2RefInput): string;
|
|
47
|
+
png(ref: Dbxv2RefInput, params?: { readonly index?: number }): string;
|
|
48
|
+
audio(ref: Dbxv2RefInput, params?: { readonly cue?: number }): string;
|
|
49
|
+
video(ref: Dbxv2RefInput): string;
|
|
50
|
+
/** Web fragmented MP4 (H.264, no audio) transcoded on demand by the server: the format browsers can play. */
|
|
51
|
+
mp4(ref: Dbxv2RefInput): string;
|
|
52
|
+
modelJson(ref: Dbxv2RefInput, params?: { readonly emb?: string; readonly esk?: string }): string;
|
|
53
|
+
modelGlb(ref: Dbxv2RefInput, params?: { readonly emb?: string; readonly esk?: string }): string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface Dbxv2Proxy {
|
|
57
|
+
/** `http://127.0.0.1:<port>`. */
|
|
58
|
+
readonly origin: string;
|
|
59
|
+
readonly url: Dbxv2UrlBuilders;
|
|
60
|
+
/** Signs any kind/parameters; `ttlSeconds` overrides the default for this URL. */
|
|
61
|
+
sign(
|
|
62
|
+
kind: Dbxv2ResourceKind,
|
|
63
|
+
ref: Dbxv2RefInput,
|
|
64
|
+
params?: Dbxv2ResourceParams,
|
|
65
|
+
ttlSeconds?: number,
|
|
66
|
+
): string;
|
|
67
|
+
stop(): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const MAX_TTL = 3600;
|
|
71
|
+
const PASS_REQUEST = ["range", "if-range", "if-none-match"] as const;
|
|
72
|
+
const PASS_RESPONSE = [
|
|
73
|
+
"content-type",
|
|
74
|
+
"content-length",
|
|
75
|
+
"content-range",
|
|
76
|
+
"accept-ranges",
|
|
77
|
+
"etag",
|
|
78
|
+
] as const;
|
|
79
|
+
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
80
|
+
|
|
81
|
+
const encodePath = (path: string): string => path.split("/").map(encodeURIComponent).join("/");
|
|
82
|
+
|
|
83
|
+
function canonical(kind: string, entry: string, query: URLSearchParams, exp: number): string {
|
|
84
|
+
const sorted = new URLSearchParams(query);
|
|
85
|
+
sorted.sort();
|
|
86
|
+
return `${kind}\n${entry}\n${sorted.toString()}\n${exp}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function jsonError(status: number, error: string): Response {
|
|
90
|
+
return new Response(JSON.stringify({ error }), {
|
|
91
|
+
status,
|
|
92
|
+
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createDbxv2Proxy(options: Dbxv2ProxyOptions): Dbxv2Proxy {
|
|
97
|
+
const env = options.env ?? (process.env as Dbxv2EnvInput);
|
|
98
|
+
const blocked = isPublicContext(env);
|
|
99
|
+
if (blocked === "public-render") throw new Dbxv2DisabledError(describeDisabledReason(blocked));
|
|
100
|
+
const upstream = normalizeServerUrl(options.upstream.baseUrl);
|
|
101
|
+
if (!upstream) throw new Dbxv2ConfigError("upstream.baseUrl must be a valid http(s) URL");
|
|
102
|
+
const token = options.upstream.token;
|
|
103
|
+
if (token.length < DBXV2_MIN_TOKEN_LENGTH)
|
|
104
|
+
throw new Dbxv2ConfigError(
|
|
105
|
+
`upstream.token must hold at least ${DBXV2_MIN_TOKEN_LENGTH} characters`,
|
|
106
|
+
);
|
|
107
|
+
const hostname = options.hostname ?? "127.0.0.1";
|
|
108
|
+
if (!LOOPBACK.has(hostname)) throw new Dbxv2ConfigError("the proxy binds to loopback only");
|
|
109
|
+
const ttlDefault = Math.min(Math.max(options.ttlSeconds ?? 300, 1), MAX_TTL);
|
|
110
|
+
const secret = options.secret ?? randomBytes(32);
|
|
111
|
+
if (typeof secret === "string" && secret.length < 16)
|
|
112
|
+
throw new Dbxv2ConfigError("secret must hold at least 16 characters");
|
|
113
|
+
const doFetch = options.fetch ?? fetch;
|
|
114
|
+
const now = options.now ?? Date.now;
|
|
115
|
+
|
|
116
|
+
const mac = (message: string): Buffer => createHmac("sha256", secret).update(message).digest();
|
|
117
|
+
|
|
118
|
+
const handle = async (req: Request): Promise<Response> => {
|
|
119
|
+
if (req.method !== "GET" && req.method !== "HEAD") return jsonError(405, "method not allowed");
|
|
120
|
+
const url = new URL(req.url);
|
|
121
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
122
|
+
if (parts[0] !== "r" || parts.length < 4) return jsonError(404, "not found");
|
|
123
|
+
const kind = parts[1] as Dbxv2ResourceKind;
|
|
124
|
+
if (!DBXV2_RESOURCE_KINDS.includes(kind)) return jsonError(404, "not found");
|
|
125
|
+
let ref;
|
|
126
|
+
try {
|
|
127
|
+
ref = parseRef({ cpk: parts[2]!, path: parts.slice(3).map(decodeURIComponent).join("/") });
|
|
128
|
+
} catch {
|
|
129
|
+
return jsonError(400, "bad entry reference");
|
|
130
|
+
}
|
|
131
|
+
const exp = Number(url.searchParams.get("exp"));
|
|
132
|
+
const sig = url.searchParams.get("sig") ?? "";
|
|
133
|
+
const signed = new URLSearchParams(url.searchParams);
|
|
134
|
+
signed.delete("exp");
|
|
135
|
+
signed.delete("sig");
|
|
136
|
+
// Only the parameters the kind understands are forwarded and signed.
|
|
137
|
+
let allowed: URLSearchParams;
|
|
138
|
+
try {
|
|
139
|
+
allowed = resourceQuery(kind, {
|
|
140
|
+
index: numberOf(signed.get("index")),
|
|
141
|
+
cue: numberOf(signed.get("cue")),
|
|
142
|
+
emb: signed.get("emb") ?? undefined,
|
|
143
|
+
esk: signed.get("esk") ?? undefined,
|
|
144
|
+
});
|
|
145
|
+
} catch {
|
|
146
|
+
return jsonError(400, "bad parameters");
|
|
147
|
+
}
|
|
148
|
+
if (allowed.toString() !== sortedString(signed)) return jsonError(400, "unexpected parameters");
|
|
149
|
+
const expected = mac(canonical(kind, `${ref.cpk}/${ref.path}`, allowed, exp));
|
|
150
|
+
let given: Buffer;
|
|
151
|
+
try {
|
|
152
|
+
given = Buffer.from(sig, "hex");
|
|
153
|
+
} catch {
|
|
154
|
+
return jsonError(403, "invalid signature");
|
|
155
|
+
}
|
|
156
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected))
|
|
157
|
+
return jsonError(403, "invalid signature");
|
|
158
|
+
if (!Number.isFinite(exp) || exp * 1000 < now()) return jsonError(403, "signed URL expired");
|
|
159
|
+
|
|
160
|
+
const headers = new Headers({ Authorization: `Bearer ${token}` });
|
|
161
|
+
for (const name of PASS_REQUEST) {
|
|
162
|
+
const value = req.headers.get(name);
|
|
163
|
+
if (value) headers.set(name, value);
|
|
164
|
+
}
|
|
165
|
+
const target = `${upstream}${resourcePath(kind, ref)}${allowed.size > 0 ? `?${allowed}` : ""}`;
|
|
166
|
+
let res: Response;
|
|
167
|
+
try {
|
|
168
|
+
res = await doFetch(target, {
|
|
169
|
+
method: req.method,
|
|
170
|
+
headers,
|
|
171
|
+
redirect: "error",
|
|
172
|
+
credentials: "omit",
|
|
173
|
+
signal: req.signal,
|
|
174
|
+
});
|
|
175
|
+
} catch {
|
|
176
|
+
return jsonError(502, "upstream unreachable");
|
|
177
|
+
}
|
|
178
|
+
const out = new Headers({
|
|
179
|
+
"Cache-Control": "private, max-age=60",
|
|
180
|
+
"X-Content-Type-Options": "nosniff",
|
|
181
|
+
"Cross-Origin-Resource-Policy": "same-site",
|
|
182
|
+
"Referrer-Policy": "no-referrer",
|
|
183
|
+
});
|
|
184
|
+
for (const name of PASS_RESPONSE) {
|
|
185
|
+
const value = res.headers.get(name);
|
|
186
|
+
if (value) out.set(name, value);
|
|
187
|
+
}
|
|
188
|
+
if (res.status >= 400)
|
|
189
|
+
return jsonError(res.status === 401 ? 502 : res.status, "upstream error");
|
|
190
|
+
return new Response(req.method === "HEAD" ? null : res.body, {
|
|
191
|
+
status: res.status,
|
|
192
|
+
headers: out,
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const server = Bun.serve({
|
|
197
|
+
hostname,
|
|
198
|
+
port: options.port ?? 0,
|
|
199
|
+
fetch: (req) => handle(req),
|
|
200
|
+
});
|
|
201
|
+
const host = hostname === "::1" ? "[::1]" : hostname;
|
|
202
|
+
const origin = `http://${host}:${server.port}`;
|
|
203
|
+
|
|
204
|
+
const sign: Dbxv2Proxy["sign"] = (kind, input, params = {}, ttl) => {
|
|
205
|
+
const ref = parseRef(input);
|
|
206
|
+
const query = resourceQuery(kind, params);
|
|
207
|
+
const exp = Math.floor(now() / 1000) + Math.min(Math.max(ttl ?? ttlDefault, 1), MAX_TTL);
|
|
208
|
+
const signature = mac(canonical(kind, `${ref.cpk}/${ref.path}`, query, exp)).toString("hex");
|
|
209
|
+
const search = new URLSearchParams(query);
|
|
210
|
+
search.set("exp", String(exp));
|
|
211
|
+
search.set("sig", signature);
|
|
212
|
+
return `${origin}/r/${kind}/${ref.cpk}/${encodePath(ref.path)}?${search}`;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
origin,
|
|
217
|
+
sign,
|
|
218
|
+
url: {
|
|
219
|
+
raw: (ref) => sign("raw", ref),
|
|
220
|
+
png: (ref, params) => sign("png", ref, params),
|
|
221
|
+
audio: (ref, params) => sign("wav", ref, params),
|
|
222
|
+
video: (ref) => sign("video", ref),
|
|
223
|
+
mp4: (ref) => sign("mp4", ref),
|
|
224
|
+
modelJson: (ref, params) => sign("model.json", ref, params),
|
|
225
|
+
modelGlb: (ref, params) => sign("model.glb", ref, params),
|
|
226
|
+
},
|
|
227
|
+
stop: () => {
|
|
228
|
+
void server.stop(true);
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function numberOf(value: string | null): number | undefined {
|
|
234
|
+
if (value === null) return undefined;
|
|
235
|
+
const n = Number(value);
|
|
236
|
+
return Number.isInteger(n) && n >= 0 ? n : Number.NaN;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function sortedString(params: URLSearchParams): string {
|
|
240
|
+
const copy = new URLSearchParams(params);
|
|
241
|
+
copy.sort();
|
|
242
|
+
return copy.toString();
|
|
243
|
+
}
|
package/src/refs.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** References to entries of a CPK and the resource kinds served by the private server. */
|
|
2
|
+
import { Dbxv2ConfigError } from "./errors";
|
|
3
|
+
|
|
4
|
+
export interface Dbxv2Ref {
|
|
5
|
+
readonly cpk: string;
|
|
6
|
+
/** Path inside the CPK, forward slashes, no leading slash. */
|
|
7
|
+
readonly path: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type Dbxv2RefInput = Dbxv2Ref | string;
|
|
11
|
+
|
|
12
|
+
export type Dbxv2ResourceKind =
|
|
13
|
+
| "raw"
|
|
14
|
+
| "png"
|
|
15
|
+
| "wav"
|
|
16
|
+
| "video"
|
|
17
|
+
| "mp4"
|
|
18
|
+
| "model.json"
|
|
19
|
+
| "model.glb";
|
|
20
|
+
|
|
21
|
+
export interface Dbxv2ResourceParams {
|
|
22
|
+
/** `png`: entry index inside the EMB. */
|
|
23
|
+
readonly index?: number;
|
|
24
|
+
/** `wav`: sub-sound index inside the ACB. */
|
|
25
|
+
readonly cue?: number;
|
|
26
|
+
/** `mp4`: segment start and length in seconds (server caps the length). */
|
|
27
|
+
readonly t?: number;
|
|
28
|
+
readonly d?: number;
|
|
29
|
+
/** `model.*`: explicit sibling EMB / ESK paths. */
|
|
30
|
+
readonly emb?: string;
|
|
31
|
+
readonly esk?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DBXV2_RESOURCE_KINDS: readonly Dbxv2ResourceKind[] = [
|
|
35
|
+
"raw",
|
|
36
|
+
"png",
|
|
37
|
+
"wav",
|
|
38
|
+
"video",
|
|
39
|
+
"mp4",
|
|
40
|
+
"model.json",
|
|
41
|
+
"model.glb",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const CPK_NAME = /^[A-Za-z0-9_]+$/;
|
|
45
|
+
|
|
46
|
+
/** Parses `"<cpk>/<path>"` or validates an object reference. */
|
|
47
|
+
export function parseRef(input: Dbxv2RefInput): Dbxv2Ref {
|
|
48
|
+
let cpk: string;
|
|
49
|
+
let path: string;
|
|
50
|
+
if (typeof input === "string") {
|
|
51
|
+
const cut = input.indexOf("/");
|
|
52
|
+
if (cut < 0)
|
|
53
|
+
throw new Dbxv2ConfigError(`invalid DBXV2 reference "${input}": expected cpk/path`);
|
|
54
|
+
cpk = input.slice(0, cut);
|
|
55
|
+
path = input.slice(cut + 1);
|
|
56
|
+
} else {
|
|
57
|
+
cpk = input.cpk;
|
|
58
|
+
path = input.path;
|
|
59
|
+
}
|
|
60
|
+
if (!CPK_NAME.test(cpk)) throw new Dbxv2ConfigError(`invalid CPK name "${cpk}"`);
|
|
61
|
+
if (!path || path.startsWith("/") || path.includes("\0") || path.split("/").includes(".."))
|
|
62
|
+
throw new Dbxv2ConfigError(`invalid CPK entry path "${path}"`);
|
|
63
|
+
return { cpk, path };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function formatRef(ref: Dbxv2Ref): string {
|
|
67
|
+
return `${ref.cpk}/${ref.path}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const encodePath = (path: string): string => path.split("/").map(encodeURIComponent).join("/");
|
|
71
|
+
|
|
72
|
+
const secondsParam = (name: string, value: number | undefined, query: URLSearchParams): void => {
|
|
73
|
+
if (value === undefined) return;
|
|
74
|
+
if (!Number.isFinite(value) || value < 0)
|
|
75
|
+
throw new Dbxv2ConfigError(`${name} must be a non-negative number`);
|
|
76
|
+
query.set(name, String(value));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const intParam = (name: string, value: number | undefined, query: URLSearchParams): void => {
|
|
80
|
+
if (value === undefined) return;
|
|
81
|
+
if (!Number.isInteger(value) || value < 0)
|
|
82
|
+
throw new Dbxv2ConfigError(`${name} must be a non-negative integer`);
|
|
83
|
+
query.set(name, String(value));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Query parameters accepted by each kind (anything else is dropped). Keys are sorted. */
|
|
87
|
+
export function resourceQuery(
|
|
88
|
+
kind: Dbxv2ResourceKind,
|
|
89
|
+
params: Dbxv2ResourceParams = {},
|
|
90
|
+
): URLSearchParams {
|
|
91
|
+
const query = new URLSearchParams();
|
|
92
|
+
if (kind === "png") intParam("index", params.index, query);
|
|
93
|
+
if (kind === "wav") intParam("cue", params.cue, query);
|
|
94
|
+
if (kind === "mp4") {
|
|
95
|
+
secondsParam("t", params.t, query);
|
|
96
|
+
secondsParam("d", params.d, query);
|
|
97
|
+
}
|
|
98
|
+
if (kind === "model.json" || kind === "model.glb") {
|
|
99
|
+
if (params.emb !== undefined) query.set("emb", params.emb);
|
|
100
|
+
if (params.esk !== undefined) query.set("esk", params.esk);
|
|
101
|
+
}
|
|
102
|
+
query.sort();
|
|
103
|
+
return query;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Server path (no origin, no query) of a resource, e.g. `/cpk/CHARA/png/a/b.emb`. */
|
|
107
|
+
export function resourcePath(kind: Dbxv2ResourceKind, input: Dbxv2RefInput): string {
|
|
108
|
+
const { cpk, path } = parseRef(input);
|
|
109
|
+
const base = `/cpk/${cpk}`;
|
|
110
|
+
switch (kind) {
|
|
111
|
+
case "raw":
|
|
112
|
+
return `${base}/raw/${encodePath(path)}`;
|
|
113
|
+
case "png":
|
|
114
|
+
return `${base}/png/${encodePath(path)}`;
|
|
115
|
+
case "wav":
|
|
116
|
+
return `${base}/wav/${encodePath(path)}`;
|
|
117
|
+
case "video":
|
|
118
|
+
return `${base}/video/${encodePath(path)}`;
|
|
119
|
+
case "mp4":
|
|
120
|
+
return `${base}/mp4/${encodePath(path)}`;
|
|
121
|
+
case "model.json":
|
|
122
|
+
return `${base}/model/${encodePath(path)}.json`;
|
|
123
|
+
case "model.glb":
|
|
124
|
+
return `${base}/model/${encodePath(path)}.glb`;
|
|
125
|
+
}
|
|
126
|
+
}
|