@jant/core 0.6.5 → 0.6.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/dist/{app-ChOEIV6L.js → app-C1QgMNRY.js} +1 -1
- package/dist/{app-B21j3s4K.js → app-L1UPUArB.js} +62 -4
- package/dist/client/.vite/manifest.json +2 -2
- package/dist/client/_assets/{client-CBOLmWbM.js → client-B0MvB2r0.js} +1 -1
- package/dist/client/_assets/{client-auth-CORG1c3c.js → client-auth-CwwuucF_.js} +201 -201
- package/dist/index.js +1 -1
- package/dist/node.js +2 -2
- package/package.json +1 -1
- package/src/client/__tests__/json.test.ts +94 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +44 -0
- package/src/client/components/jant-collection-directory.ts +1 -0
- package/src/client/components/jant-collection-form.ts +1 -0
- package/src/client/components/jant-command-palette.ts +4 -0
- package/src/client/components/jant-compose-dialog.ts +8 -0
- package/src/client/components/jant-compose-editor.ts +1 -0
- package/src/client/components/jant-compose-fullscreen.ts +3 -0
- package/src/client/components/jant-nav-manager.ts +4 -0
- package/src/client/components/jant-post-menu.ts +3 -0
- package/src/client/components/jant-repo-picker.ts +3 -0
- package/src/client/components/jant-settings-general.ts +3 -0
- package/src/client/json.ts +56 -2
- package/src/client/media-metadata.ts +2 -2
- package/src/client/multipart-upload.ts +17 -7
- package/src/client/upload-session.ts +17 -9
- package/src/client/video-processor.ts +2 -2
- package/src/routes/api/internal/sites.ts +33 -0
- package/src/services/site-admin.ts +121 -0
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { v as url_exports } from "./url-XF0GbKGO.js";
|
|
2
|
-
import { A as MAX_MEDIA_ATTACHMENTS, C as toMediaView, D as toPostViews, E as toPostView, F as STATUSES, I as TEXT_ATTACHMENT_CONTENT_FORMATS, M as MEDIA_KINDS, N as NAV_ITEM_TYPES, O as toSearchResultView, P as SORT_ORDERS, S as toArchiveGroupsWithMedia, T as toNavItemViews, b as createMediaContext, h as defaultFeedRenderer, j as MAX_PINNED_POSTS, k as FORMATS, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-
|
|
2
|
+
import { A as MAX_MEDIA_ATTACHMENTS, C as toMediaView, D as toPostViews, E as toPostView, F as STATUSES, I as TEXT_ATTACHMENT_CONTENT_FORMATS, M as MEDIA_KINDS, N as NAV_ITEM_TYPES, O as toSearchResultView, P as SORT_ORDERS, S as toArchiveGroupsWithMedia, T as toNavItemViews, b as createMediaContext, h as defaultFeedRenderer, j as MAX_PINNED_POSTS, k as FORMATS, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-L1UPUArB.js";
|
|
3
3
|
import { T as time_exports, a as markdown_exports } from "./export-DLukCOO3.js";
|
|
4
4
|
import "./env-CoSe-1y4.js";
|
|
5
5
|
import "./github-sync-BtHY2AST.js";
|
package/dist/node.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import "./url-XF0GbKGO.js";
|
|
2
|
-
import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as resolveConfig, d as setWebhook, f as BUILTIN_FONT_THEMES, g as pgSchemaBundle, i as createSiteService, l as getWebhookUrl, m as getFontThemeCssVariables, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getCjkSerifCssVariables, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as setMyCommands, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-
|
|
2
|
+
import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as resolveConfig, d as setWebhook, f as BUILTIN_FONT_THEMES, g as pgSchemaBundle, i as createSiteService, l as getWebhookUrl, m as getFontThemeCssVariables, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getCjkSerifCssVariables, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as setMyCommands, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-L1UPUArB.js";
|
|
3
3
|
import { t as createExportService } from "./export-DLukCOO3.js";
|
|
4
4
|
import { C as shouldTrustProxy, S as getTelegramWebhookSecret, b as getSiteResolutionMode, d as getHostedControlPlaneBaseUrl, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as getTelegramBotPool, y as getPort } from "./env-CoSe-1y4.js";
|
|
5
5
|
import "./github-sync-BtHY2AST.js";
|
|
@@ -529,7 +529,7 @@ async function createNodeRequestHandler(options) {
|
|
|
529
529
|
async function start(env = process.env, app) {
|
|
530
530
|
const handler = await createNodeRequestHandler({
|
|
531
531
|
env,
|
|
532
|
-
app: async () => app ?? (await import("./app-
|
|
532
|
+
app: async () => app ?? (await import("./app-C1QgMNRY.js")).createApp()
|
|
533
533
|
});
|
|
534
534
|
const hostname = resolveHost(env);
|
|
535
535
|
const port = resolvePort(env);
|
package/package.json
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
readErrorMessage,
|
|
5
|
+
readErrorMessageFromText,
|
|
6
|
+
readJsonObject,
|
|
7
|
+
} from "../json.js";
|
|
8
|
+
|
|
9
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
10
|
+
return new Response(JSON.stringify(body), {
|
|
11
|
+
status,
|
|
12
|
+
headers: { "Content-Type": "application/json" },
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function textResponse(body: string, status: number): Response {
|
|
17
|
+
return new Response(body, {
|
|
18
|
+
status,
|
|
19
|
+
headers: { "Content-Type": "text/plain" },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("readJsonObject", () => {
|
|
24
|
+
it("parses a JSON object body", async () => {
|
|
25
|
+
const res = jsonResponse({ id: "abc", count: 2 });
|
|
26
|
+
expect(await readJsonObject(res)).toEqual({ id: "abc", count: 2 });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns empty object for empty body", async () => {
|
|
30
|
+
const res = new Response("", { status: 200 });
|
|
31
|
+
expect(await readJsonObject(res)).toEqual({});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns empty object for a JSON primitive", async () => {
|
|
35
|
+
expect(await readJsonObject(jsonResponse("hi"))).toEqual({});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("throws an informative error when body is not JSON", async () => {
|
|
39
|
+
const res = textResponse("Not Found", 404);
|
|
40
|
+
await expect(readJsonObject(res)).rejects.toThrow(
|
|
41
|
+
/Expected JSON \(HTTP 404\) but got: Not Found/,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("readErrorMessage", () => {
|
|
47
|
+
it("returns the JSON `error` field when present", async () => {
|
|
48
|
+
const res = jsonResponse({ error: "Quota exceeded" }, 400);
|
|
49
|
+
expect(await readErrorMessage(res, "Default")).toBe("Quota exceeded");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("surfaces plain-text body when not JSON (the Not Found case)", async () => {
|
|
53
|
+
const res = textResponse("Not Found", 404);
|
|
54
|
+
expect(await readErrorMessage(res, "Failed to start upload")).toBe(
|
|
55
|
+
"Not Found",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("falls back when body is empty", async () => {
|
|
60
|
+
const res = new Response("", { status: 500 });
|
|
61
|
+
expect(await readErrorMessage(res, "Default")).toBe("Default");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("truncates very long bodies (e.g. error HTML pages)", async () => {
|
|
65
|
+
const long = "x".repeat(500);
|
|
66
|
+
const res = textResponse(long, 502);
|
|
67
|
+
const result = await readErrorMessage(res, "Default");
|
|
68
|
+
expect(result.length).toBeLessThanOrEqual(201);
|
|
69
|
+
expect(result.endsWith("…")).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("falls back when JSON has no `error` field", async () => {
|
|
73
|
+
const res = jsonResponse({ status: "bad" }, 400);
|
|
74
|
+
expect(await readErrorMessage(res, "Default")).toBe('{"status":"bad"}');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("readErrorMessageFromText", () => {
|
|
79
|
+
it("extracts error from JSON text", () => {
|
|
80
|
+
expect(
|
|
81
|
+
readErrorMessageFromText('{"error":"part too small"}', "Default"),
|
|
82
|
+
).toBe("part too small");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns plain text when not JSON", () => {
|
|
86
|
+
expect(readErrorMessageFromText("Internal Error", "Default")).toBe(
|
|
87
|
+
"Internal Error",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("falls back when text is blank", () => {
|
|
92
|
+
expect(readErrorMessageFromText(" ", "Default")).toBe("Default");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -3319,6 +3319,50 @@ describe("JantComposeDialog", () => {
|
|
|
3319
3319
|
expect(el._confirmPanelOpen).toBe(false);
|
|
3320
3320
|
});
|
|
3321
3321
|
|
|
3322
|
+
it("ignores Escape while an IME is composing (e.g. CJK candidate popup)", async () => {
|
|
3323
|
+
// Regression test for GitHub issue #120: when a user types pinyin and
|
|
3324
|
+
// presses Escape to dismiss the IME candidate popup, the compose dialog
|
|
3325
|
+
// must not interpret it as a close request.
|
|
3326
|
+
const el = await createElement();
|
|
3327
|
+
const editor = requireElement(
|
|
3328
|
+
el.querySelector<JantComposeEditor>("jant-compose-editor"),
|
|
3329
|
+
"expected compose editor",
|
|
3330
|
+
);
|
|
3331
|
+
editor._bodyJson = {
|
|
3332
|
+
type: "doc",
|
|
3333
|
+
content: [
|
|
3334
|
+
{
|
|
3335
|
+
type: "paragraph",
|
|
3336
|
+
content: [{ type: "text", text: "已经写了一些内容" }],
|
|
3337
|
+
},
|
|
3338
|
+
],
|
|
3339
|
+
};
|
|
3340
|
+
await editor.updateComplete;
|
|
3341
|
+
|
|
3342
|
+
const requestCloseSpy = vi.spyOn(el, "requestClose");
|
|
3343
|
+
el.dispatchEvent(
|
|
3344
|
+
new globalThis.KeyboardEvent("keydown", {
|
|
3345
|
+
key: "Escape",
|
|
3346
|
+
isComposing: true,
|
|
3347
|
+
bubbles: true,
|
|
3348
|
+
}),
|
|
3349
|
+
);
|
|
3350
|
+
await el.updateComplete;
|
|
3351
|
+
|
|
3352
|
+
expect(requestCloseSpy).not.toHaveBeenCalled();
|
|
3353
|
+
expect(el._confirmPanelOpen).toBe(false);
|
|
3354
|
+
|
|
3355
|
+
// Sanity: once composition ends, Escape works as before.
|
|
3356
|
+
el.dispatchEvent(
|
|
3357
|
+
new globalThis.KeyboardEvent("keydown", {
|
|
3358
|
+
key: "Escape",
|
|
3359
|
+
bubbles: true,
|
|
3360
|
+
}),
|
|
3361
|
+
);
|
|
3362
|
+
await el.updateComplete;
|
|
3363
|
+
expect(requestCloseSpy).toHaveBeenCalledTimes(1);
|
|
3364
|
+
});
|
|
3365
|
+
|
|
3322
3366
|
it("still closes normally after file picker selection", async () => {
|
|
3323
3367
|
const el = await createElement();
|
|
3324
3368
|
const editor = requireElement(
|
|
@@ -1245,6 +1245,7 @@ export class JantCollectionsManager extends LitElement {
|
|
|
1245
1245
|
(e.currentTarget as HTMLInputElement).value,
|
|
1246
1246
|
)}
|
|
1247
1247
|
@keydown=${(e: globalThis.KeyboardEvent) => {
|
|
1248
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
1248
1249
|
const target = e.currentTarget as HTMLInputElement;
|
|
1249
1250
|
if (e.key === "Enter") {
|
|
1250
1251
|
e.preventDefault();
|
|
@@ -102,6 +102,7 @@ export class JantCollectionForm extends LitElement {
|
|
|
102
102
|
connectedCallback() {
|
|
103
103
|
super.connectedCallback();
|
|
104
104
|
this.#boundKeydown = (e: KeyboardEvent) => {
|
|
105
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
105
106
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
|
106
107
|
e.preventDefault();
|
|
107
108
|
void this.#handleSubmit(e);
|
|
@@ -386,6 +386,10 @@ export class JantCommandPalette extends LitElement {
|
|
|
386
386
|
};
|
|
387
387
|
|
|
388
388
|
#handleKeydown = (event: globalThis.KeyboardEvent) => {
|
|
389
|
+
// Let IME consume keys during composition — Enter commits the candidate,
|
|
390
|
+
// Escape dismisses the candidate popup, arrows pick candidates.
|
|
391
|
+
if (event.isComposing || event.keyCode === 229) return;
|
|
392
|
+
|
|
389
393
|
const items = this.#displayItems;
|
|
390
394
|
|
|
391
395
|
if (event.key === "ArrowDown") {
|
|
@@ -2269,6 +2269,10 @@ export class JantComposeDialog extends LitElement {
|
|
|
2269
2269
|
|
|
2270
2270
|
private _handleDialogCancel = (e: Event) => {
|
|
2271
2271
|
e.preventDefault();
|
|
2272
|
+
// Defensive: some browsers dispatch <dialog> `cancel` for Escape even
|
|
2273
|
+
// when the IME consumed it. Mirror the guard from _handleKeydown.
|
|
2274
|
+
const ke = e as Partial<globalThis.KeyboardEvent>;
|
|
2275
|
+
if (ke.isComposing || ke.keyCode === 229) return;
|
|
2272
2276
|
if (this._shouldIgnoreEscapeClose()) return;
|
|
2273
2277
|
if (this._dismissEscapeOverlay()) return;
|
|
2274
2278
|
this.requestClose();
|
|
@@ -2411,6 +2415,10 @@ export class JantComposeDialog extends LitElement {
|
|
|
2411
2415
|
|
|
2412
2416
|
private _handleKeydown = (e: Event) => {
|
|
2413
2417
|
const ke = e as globalThis.KeyboardEvent;
|
|
2418
|
+
// Let IME consume keys during composition (e.g. CJK candidate selection).
|
|
2419
|
+
// Without this, pressing Escape to dismiss the IME popup would trigger the
|
|
2420
|
+
// "Save to drafts?" prompt. See GitHub issue #120.
|
|
2421
|
+
if (ke.isComposing || ke.keyCode === 229) return;
|
|
2414
2422
|
if (ke.key !== "Escape") {
|
|
2415
2423
|
this._clearFilePickerEscapeState();
|
|
2416
2424
|
}
|
|
@@ -1736,6 +1736,7 @@ export class JantComposeEditor extends LitElement {
|
|
|
1736
1736
|
@input=${(e: Event) => this._onInput("_title", e)}
|
|
1737
1737
|
@focus=${(e: Event) => this._onFieldFocus(e)}
|
|
1738
1738
|
@keydown=${(e: globalThis.KeyboardEvent) => {
|
|
1739
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
1739
1740
|
if (e.key === "Enter") {
|
|
1740
1741
|
e.preventDefault();
|
|
1741
1742
|
this._editor?.commands.focus("start");
|
|
@@ -188,6 +188,8 @@ export class JantComposeFullscreen extends LitElement {
|
|
|
188
188
|
|
|
189
189
|
private _onDocumentKeydown = (e: globalThis.KeyboardEvent) => {
|
|
190
190
|
if (!this._open || e.key !== "Escape") return;
|
|
191
|
+
// Let IME consume Escape during composition (e.g. CJK candidate dismiss).
|
|
192
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
191
193
|
if (this._hasActiveEscapeOverlay()) return;
|
|
192
194
|
|
|
193
195
|
e.preventDefault();
|
|
@@ -273,6 +275,7 @@ export class JantComposeFullscreen extends LitElement {
|
|
|
273
275
|
this._title = (e.target as HTMLInputElement).value;
|
|
274
276
|
}}
|
|
275
277
|
@keydown=${(e: globalThis.KeyboardEvent) => {
|
|
278
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
276
279
|
if (e.key === "Enter") {
|
|
277
280
|
e.preventDefault();
|
|
278
281
|
this._editor?.commands.focus("start");
|
|
@@ -103,6 +103,10 @@ export class JantNavManager extends LitElement {
|
|
|
103
103
|
if (!("key" in event) || event.key !== "Escape" || !this._showPreviewMore) {
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
106
|
+
// Defensive: nav editor has many text inputs; let IME swallow Escape
|
|
107
|
+
// when the user is dismissing a CJK candidate popup.
|
|
108
|
+
const ke = event as globalThis.KeyboardEvent;
|
|
109
|
+
if (ke.isComposing || ke.keyCode === 229) return;
|
|
106
110
|
|
|
107
111
|
event.preventDefault();
|
|
108
112
|
this.#closePreviewMore();
|
|
@@ -169,6 +169,9 @@ export class JantPostMenu extends LitElement {
|
|
|
169
169
|
|
|
170
170
|
#handleKeydown = (e: Event) => {
|
|
171
171
|
const ke = e as globalThis.KeyboardEvent;
|
|
172
|
+
// Let IME consume Escape during composition (e.g. dismissing the CJK
|
|
173
|
+
// candidate popup in the collection search input).
|
|
174
|
+
if (ke.isComposing || ke.keyCode === 229) return;
|
|
172
175
|
if (ke.key === "Escape") {
|
|
173
176
|
if (this._addCollectionPanelOpen) {
|
|
174
177
|
this.#closeAddCollectionPanel();
|
|
@@ -418,6 +418,9 @@ export class JantRepoPicker extends LitElement {
|
|
|
418
418
|
|
|
419
419
|
#handleEscape = (e: KeyboardEvent) => {
|
|
420
420
|
if (e.key !== "Escape") return;
|
|
421
|
+
// Let IME consume Escape during composition (e.g. dismissing the CJK
|
|
422
|
+
// candidate popup in the owner/repo search inputs).
|
|
423
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
421
424
|
if (this._ownerOpen || this._repoOpen) {
|
|
422
425
|
this._ownerOpen = false;
|
|
423
426
|
this._repoOpen = false;
|
|
@@ -450,6 +450,7 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
450
450
|
};
|
|
451
451
|
|
|
452
452
|
private _onLocalePickerKeydown = (e: KeyboardEvent) => {
|
|
453
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
453
454
|
if (e.key === "Escape" && this._localeOpen) {
|
|
454
455
|
this._localeOpen = false;
|
|
455
456
|
this._localeQuery = "";
|
|
@@ -614,6 +615,8 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
614
615
|
dirty: boolean,
|
|
615
616
|
loading: boolean,
|
|
616
617
|
) {
|
|
618
|
+
// Pressing Enter to commit an IME candidate must not also submit the form.
|
|
619
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
617
620
|
if (
|
|
618
621
|
e.key === "Enter" &&
|
|
619
622
|
!loading &&
|
package/src/client/json.ts
CHANGED
|
@@ -32,6 +32,60 @@ export function getJsonNumber(value: unknown, key: string): number | undefined {
|
|
|
32
32
|
export async function readJsonObject(
|
|
33
33
|
response: Response,
|
|
34
34
|
): Promise<Record<string, unknown>> {
|
|
35
|
-
const
|
|
36
|
-
|
|
35
|
+
const text = await response.text();
|
|
36
|
+
if (!text.trim()) return {};
|
|
37
|
+
try {
|
|
38
|
+
const data = JSON.parse(text);
|
|
39
|
+
return isJsonObject(data) ? data : {};
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Expected JSON (HTTP ${response.status}) but got: ${truncate(text.trim(), 200)}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read a server error message from a failed Response.
|
|
49
|
+
* Prefers JSON `{ error }`, falls back to the raw text body so server-side
|
|
50
|
+
* failures (e.g. plain-text 404 from an edge/proxy) reach the user instead of
|
|
51
|
+
* being masked by a cryptic JSON parse error.
|
|
52
|
+
*/
|
|
53
|
+
export async function readErrorMessage(
|
|
54
|
+
response: Response,
|
|
55
|
+
fallback: string,
|
|
56
|
+
): Promise<string> {
|
|
57
|
+
let text: string;
|
|
58
|
+
try {
|
|
59
|
+
text = await response.text();
|
|
60
|
+
} catch {
|
|
61
|
+
return fallback;
|
|
62
|
+
}
|
|
63
|
+
return extractErrorMessage(text, fallback);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Same as readErrorMessage but for a body already read as text (e.g. XHR). */
|
|
67
|
+
export function readErrorMessageFromText(
|
|
68
|
+
text: string,
|
|
69
|
+
fallback: string,
|
|
70
|
+
): string {
|
|
71
|
+
return extractErrorMessage(text, fallback);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function extractErrorMessage(text: string, fallback: string): string {
|
|
75
|
+
const trimmed = text.trim();
|
|
76
|
+
if (!trimmed) return fallback;
|
|
77
|
+
try {
|
|
78
|
+
const data = JSON.parse(trimmed);
|
|
79
|
+
if (isJsonObject(data)) {
|
|
80
|
+
const msg = getJsonString(data, "error");
|
|
81
|
+
if (msg) return msg;
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Not JSON — fall through and surface the raw text below.
|
|
85
|
+
}
|
|
86
|
+
return truncate(trimmed, 200);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function truncate(value: string, max: number): string {
|
|
90
|
+
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
37
91
|
}
|
|
@@ -45,7 +45,7 @@ export async function extractImageMetadata(
|
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* Extract metadata from a video file.
|
|
48
|
-
* Loads the video to get dimensions, then seeks to `min(duration * 0.1,
|
|
48
|
+
* Loads the video to get dimensions, then seeks to `min(duration * 0.1, 1)` and
|
|
49
49
|
* captures a frame for blurhash (32px canvas) and a poster image (640px WebP).
|
|
50
50
|
* Uses an 8s timeout — returns only dimensions if capture times out.
|
|
51
51
|
*/
|
|
@@ -81,7 +81,7 @@ export async function extractVideoMetadata(file: File): Promise<{
|
|
|
81
81
|
let blurhash: string | undefined;
|
|
82
82
|
let poster: Blob | undefined;
|
|
83
83
|
try {
|
|
84
|
-
const seekTime = Math.min(duration * 0.1,
|
|
84
|
+
const seekTime = Math.min(duration * 0.1, 1);
|
|
85
85
|
const result = await Promise.race([
|
|
86
86
|
captureVideoFrameAndPoster(video, width, height, seekTime),
|
|
87
87
|
timeout(8000),
|
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
* when a file is larger than MULTIPART_THRESHOLD.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
getJsonNumber,
|
|
11
|
+
getJsonString,
|
|
12
|
+
readErrorMessage,
|
|
13
|
+
readJsonObject,
|
|
14
|
+
} from "./json.js";
|
|
10
15
|
|
|
11
16
|
/** Files at or above this size use multipart upload (95MB, below 100MB Worker limit) */
|
|
12
17
|
export const MULTIPART_THRESHOLD = 95 * 1024 * 1024;
|
|
@@ -59,8 +64,7 @@ export async function uploadMultipart(
|
|
|
59
64
|
});
|
|
60
65
|
|
|
61
66
|
if (!initRes.ok) {
|
|
62
|
-
|
|
63
|
-
throw new Error(getJsonString(data, "error") ?? "Failed to start upload");
|
|
67
|
+
throw new Error(await readErrorMessage(initRes, "Failed to start upload"));
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
const initData = await readJsonObject(initRes);
|
|
@@ -87,7 +91,9 @@ export async function uploadMultipart(
|
|
|
87
91
|
});
|
|
88
92
|
|
|
89
93
|
if (!posterRes.ok) {
|
|
90
|
-
throw new Error(
|
|
94
|
+
throw new Error(
|
|
95
|
+
await readErrorMessage(posterRes, "Failed to upload poster"),
|
|
96
|
+
);
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
const posterData = await readJsonObject(posterRes);
|
|
@@ -118,7 +124,12 @@ export async function uploadMultipart(
|
|
|
118
124
|
);
|
|
119
125
|
|
|
120
126
|
if (!partRes.ok) {
|
|
121
|
-
throw new Error(
|
|
127
|
+
throw new Error(
|
|
128
|
+
await readErrorMessage(
|
|
129
|
+
partRes,
|
|
130
|
+
`Failed to upload part ${partNumber}`,
|
|
131
|
+
),
|
|
132
|
+
);
|
|
122
133
|
}
|
|
123
134
|
|
|
124
135
|
const partData = await readJsonObject(partRes);
|
|
@@ -155,9 +166,8 @@ export async function uploadMultipart(
|
|
|
155
166
|
});
|
|
156
167
|
|
|
157
168
|
if (!completeRes.ok) {
|
|
158
|
-
const data = await readJsonObject(completeRes);
|
|
159
169
|
throw new Error(
|
|
160
|
-
|
|
170
|
+
await readErrorMessage(completeRes, "Failed to complete upload"),
|
|
161
171
|
);
|
|
162
172
|
}
|
|
163
173
|
|
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
getJsonNumber,
|
|
3
3
|
getJsonString,
|
|
4
4
|
isJsonObject,
|
|
5
|
+
readErrorMessage,
|
|
6
|
+
readErrorMessageFromText,
|
|
5
7
|
readJsonObject,
|
|
6
8
|
} from "./json.js";
|
|
7
9
|
import { publicPath } from "./runtime-paths.js";
|
|
@@ -155,8 +157,7 @@ async function initiateUpload(file: File): Promise<InitiateResponse> {
|
|
|
155
157
|
});
|
|
156
158
|
|
|
157
159
|
if (!res.ok) {
|
|
158
|
-
|
|
159
|
-
throw new Error(getJsonString(data, "error") ?? "Failed to start upload");
|
|
160
|
+
throw new Error(await readErrorMessage(res, "Failed to start upload"));
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
const data = await readJsonObject(res);
|
|
@@ -176,7 +177,9 @@ async function uploadPoster(uploadId: string, poster: Blob): Promise<void> {
|
|
|
176
177
|
body: poster,
|
|
177
178
|
});
|
|
178
179
|
if (!response.ok) {
|
|
179
|
-
throw new Error(
|
|
180
|
+
throw new Error(
|
|
181
|
+
await readErrorMessage(response, "Failed to upload poster"),
|
|
182
|
+
);
|
|
180
183
|
}
|
|
181
184
|
}
|
|
182
185
|
|
|
@@ -202,9 +205,8 @@ async function completeUpload(
|
|
|
202
205
|
);
|
|
203
206
|
|
|
204
207
|
if (!completeRes.ok) {
|
|
205
|
-
const data = await readJsonObject(completeRes);
|
|
206
208
|
throw new Error(
|
|
207
|
-
|
|
209
|
+
await readErrorMessage(completeRes, "Failed to complete upload"),
|
|
208
210
|
);
|
|
209
211
|
}
|
|
210
212
|
|
|
@@ -246,7 +248,12 @@ async function uploadMultipartRelay(
|
|
|
246
248
|
},
|
|
247
249
|
);
|
|
248
250
|
if (!response.ok) {
|
|
249
|
-
throw new Error(
|
|
251
|
+
throw new Error(
|
|
252
|
+
readErrorMessageFromText(
|
|
253
|
+
response.text,
|
|
254
|
+
`Failed to upload part ${partNumber}`,
|
|
255
|
+
),
|
|
256
|
+
);
|
|
250
257
|
}
|
|
251
258
|
const data = parseJsonObjectFromText(response.text);
|
|
252
259
|
const uploadedPart = data ? getJsonNumber(data, "partNumber") : null;
|
|
@@ -278,7 +285,9 @@ export async function uploadViaSession(
|
|
|
278
285
|
onProgress,
|
|
279
286
|
);
|
|
280
287
|
if (!response.ok) {
|
|
281
|
-
throw new Error(
|
|
288
|
+
throw new Error(
|
|
289
|
+
readErrorMessageFromText(response.text, "Upload failed"),
|
|
290
|
+
);
|
|
282
291
|
}
|
|
283
292
|
onProgress?.(1);
|
|
284
293
|
} else if (transport.kind === "relay") {
|
|
@@ -289,9 +298,8 @@ export async function uploadViaSession(
|
|
|
289
298
|
onProgress,
|
|
290
299
|
);
|
|
291
300
|
if (!response.ok) {
|
|
292
|
-
const data = parseJsonObjectFromText(response.text);
|
|
293
301
|
throw new Error(
|
|
294
|
-
(
|
|
302
|
+
readErrorMessageFromText(response.text, "Upload failed"),
|
|
295
303
|
);
|
|
296
304
|
}
|
|
297
305
|
onProgress?.(1);
|
|
@@ -56,7 +56,7 @@ function isSupported(): boolean {
|
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* Extract a poster frame, blurhash, and source dimensions from a video file.
|
|
59
|
-
* Seeks to `min(duration × 0.1,
|
|
59
|
+
* Seeks to `min(duration × 0.1, 1s)` and captures the frame.
|
|
60
60
|
* Also returns the original video dimensions so the caller can compute
|
|
61
61
|
* the correct output size without opening a second Input instance.
|
|
62
62
|
*
|
|
@@ -85,7 +85,7 @@ async function extractPoster(file: File): Promise<{
|
|
|
85
85
|
|
|
86
86
|
const duration = await input.computeDuration();
|
|
87
87
|
const durationSeconds = normalizeDurationSeconds(duration);
|
|
88
|
-
const seekTime = Math.min(duration * 0.1,
|
|
88
|
+
const seekTime = Math.min(duration * 0.1, 1);
|
|
89
89
|
|
|
90
90
|
const sink = new CanvasSink(videoTrack);
|
|
91
91
|
const wrapped = await sink.getCanvas(seekTime);
|
|
@@ -60,6 +60,20 @@ const ManagedSiteDomainSchema = z.object({
|
|
|
60
60
|
makePrimary: z.boolean().optional(),
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
const RenameManagedSiteSchema = z.object({
|
|
64
|
+
key: ManagedSiteKeySchema,
|
|
65
|
+
primaryHost: z
|
|
66
|
+
.string()
|
|
67
|
+
.trim()
|
|
68
|
+
.toLowerCase()
|
|
69
|
+
.min(1)
|
|
70
|
+
.max(255)
|
|
71
|
+
.regex(
|
|
72
|
+
/^(?=.{1,255}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/,
|
|
73
|
+
"Primary host must be a valid hostname.",
|
|
74
|
+
),
|
|
75
|
+
});
|
|
76
|
+
|
|
63
77
|
export const internalSitesRoutes = new Hono<Env>();
|
|
64
78
|
|
|
65
79
|
function assertHostBasedMode(env: Bindings) {
|
|
@@ -198,6 +212,25 @@ internalSitesRoutes.post(
|
|
|
198
212
|
},
|
|
199
213
|
);
|
|
200
214
|
|
|
215
|
+
internalSitesRoutes.post(
|
|
216
|
+
"/:siteId/rename",
|
|
217
|
+
requireInternalAdminApi(),
|
|
218
|
+
async (c) => {
|
|
219
|
+
assertHostBasedMode(c.env);
|
|
220
|
+
const body = parseValidated(RenameManagedSiteSchema, await c.req.json());
|
|
221
|
+
const result = await c.var.services.siteAdmin.renameManagedSite(
|
|
222
|
+
c.req.param("siteId"),
|
|
223
|
+
body,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
return c.json({
|
|
227
|
+
primaryHost: result.domain.host,
|
|
228
|
+
siteId: result.site.id,
|
|
229
|
+
status: result.site.status,
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
|
|
201
234
|
internalSitesRoutes.get(
|
|
202
235
|
"/:siteId/domains",
|
|
203
236
|
requireInternalAdminApi(),
|