@jant/core 0.6.10 → 0.6.11
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-CGHkOdme.js → app-CpmficmQ.js} +531 -204
- package/dist/app-DqKkZenB.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BhHHVvSY.css +2 -0
- package/dist/client/_assets/{client-DYrWuaIk.js → client-Dd9U383b.js} +1 -1
- package/dist/client/_assets/{client-auth-B5Re0uCd.js → client-auth-DkpSdIDz.js} +80 -80
- package/dist/{export-DY1v5Iqu.js → export-Ba7NJImL.js} +92 -92
- package/dist/{github-sync-LefaslGJ.js → github-sync-BD4w2m8-.js} +2 -2
- package/dist/{github-sync-2_T7nbOv.js → github-sync-Cb4_6_i7.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +62 -0
- package/src/client/components/compose-types.ts +4 -0
- package/src/client/components/jant-compose-editor.ts +111 -0
- package/src/client/compose-bridge.ts +25 -8
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
- package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
- package/src/client/tiptap/__tests__/rehost-images.test.ts +139 -0
- package/src/client/tiptap/create-editor.ts +3 -0
- package/src/client/tiptap/extensions.ts +4 -0
- package/src/client/tiptap/inline-image-upload.ts +174 -50
- package/src/client/tiptap/rehost-images.ts +104 -0
- package/src/i18n/locales/public/en.po +10 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +10 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +10 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/lib/__tests__/upload-sideload.test.ts +78 -0
- package/src/lib/__tests__/url-fetch.test.ts +181 -0
- package/src/lib/upload.ts +111 -0
- package/src/lib/url-fetch.ts +263 -0
- package/src/routes/api/__tests__/uploads.test.ts +63 -1
- package/src/routes/api/uploads.ts +52 -0
- package/src/services/__tests__/media.test.ts +168 -1
- package/src/services/media.ts +111 -0
- package/src/styles/ui.css +1 -1
- package/src/ui/compose/ComposeDialog.tsx +16 -0
- package/src/ui/layouts/BaseLayout.tsx +12 -0
- package/dist/app-D24n0DoH.js +0 -6
- package/dist/client/_assets/client-xWDl78yi.css +0 -2
package/src/services/media.ts
CHANGED
|
@@ -32,10 +32,18 @@ import { renderTiptapJson } from "../lib/tiptap-render.js";
|
|
|
32
32
|
import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
|
|
33
33
|
import {
|
|
34
34
|
generateStorageKey,
|
|
35
|
+
imageExtensionForMimeType,
|
|
36
|
+
isAllowedSideloadImageType,
|
|
35
37
|
SITE_ASSET_STORAGE_KEY_LIKE_PATTERN,
|
|
38
|
+
sniffImageMimeType,
|
|
36
39
|
toMediaKind,
|
|
37
40
|
validateUploadFileMetadata,
|
|
38
41
|
} from "../lib/upload.js";
|
|
42
|
+
import {
|
|
43
|
+
IMAGE_DIMENSION_PEEK_BYTES,
|
|
44
|
+
parseImageDimensions,
|
|
45
|
+
} from "../lib/image-dimensions.js";
|
|
46
|
+
import { assertPublicHttpUrl, fetchImageBytes } from "../lib/url-fetch.js";
|
|
39
47
|
import type {
|
|
40
48
|
Media,
|
|
41
49
|
MediaKind,
|
|
@@ -57,6 +65,26 @@ import type { HostedControlPlaneClient } from "../lib/hosted-control-plane.js";
|
|
|
57
65
|
|
|
58
66
|
const DEFAULT_MEDIA_POSITION = "a0";
|
|
59
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Derive a display filename from a remote image URL, falling back to
|
|
70
|
+
* `image.<ext>` when the path has no usable basename.
|
|
71
|
+
*
|
|
72
|
+
* @param url - The remote image URL
|
|
73
|
+
* @param ext - Extension to use when the URL path lacks one
|
|
74
|
+
* @returns A sanitized original filename
|
|
75
|
+
*/
|
|
76
|
+
function remoteImageName(url: URL, ext: string): string {
|
|
77
|
+
let base: string;
|
|
78
|
+
try {
|
|
79
|
+
base = decodeURIComponent(url.pathname.split("/").pop() ?? "");
|
|
80
|
+
} catch {
|
|
81
|
+
base = url.pathname.split("/").pop() ?? "";
|
|
82
|
+
}
|
|
83
|
+
base = base.trim().replace(/[\r\n"\\]+/g, "");
|
|
84
|
+
if (!base) return `image.${ext}`;
|
|
85
|
+
return base.includes(".") ? base : `${base}.${ext}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
60
88
|
/**
|
|
61
89
|
* Recycle-window length for deleted media storage objects. On delete we
|
|
62
90
|
* hard-remove the DB row but defer deleting the underlying storage object
|
|
@@ -187,6 +215,12 @@ export interface TextAttachmentDeps {
|
|
|
187
215
|
maxFileSizeMB: number;
|
|
188
216
|
}
|
|
189
217
|
|
|
218
|
+
export interface IngestFromUrlDeps {
|
|
219
|
+
storage: StorageDriver;
|
|
220
|
+
storageDriver: string;
|
|
221
|
+
maxFileSizeMB: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
190
224
|
export interface MediaService {
|
|
191
225
|
assertCanWriteBytes(additionalBytes: number): Promise<void>;
|
|
192
226
|
getById(id: string): Promise<Media | null>;
|
|
@@ -195,6 +229,24 @@ export interface MediaService {
|
|
|
195
229
|
getByPostIds(postIds: string[]): Promise<Map<string, Media[]>>;
|
|
196
230
|
list(filters?: MediaFilters): Promise<Media[]>;
|
|
197
231
|
create(data: CreateMediaData): Promise<Media>;
|
|
232
|
+
/**
|
|
233
|
+
* Fetch a remote image URL server-side and store it as the site's own media.
|
|
234
|
+
*
|
|
235
|
+
* Used to rehost images pasted from external articles: the server downloads
|
|
236
|
+
* the bytes (bypassing browser CORS), verifies they are a real image format,
|
|
237
|
+
* and stores them. The created row has `postId = null` like other compose
|
|
238
|
+
* uploads (reaped by the orphan sweep if the post is never published).
|
|
239
|
+
*
|
|
240
|
+
* @param input.url - The remote http(s) image URL
|
|
241
|
+
* @param input.alt - Optional alt text
|
|
242
|
+
* @param deps - Storage driver, provider name, and the max file size
|
|
243
|
+
* @returns The created media row
|
|
244
|
+
* @throws {ValidationError} For unsafe URLs, non-image content, or oversize files
|
|
245
|
+
*/
|
|
246
|
+
ingestFromUrl(
|
|
247
|
+
input: { url: string; alt?: string },
|
|
248
|
+
deps: IngestFromUrlDeps,
|
|
249
|
+
): Promise<Media>;
|
|
198
250
|
/**
|
|
199
251
|
* Validate media IDs: checks count limit and verifies all IDs exist in the database.
|
|
200
252
|
* No-op when the array is empty.
|
|
@@ -788,6 +840,65 @@ export function createMediaService(
|
|
|
788
840
|
return toMedia(result[0]!);
|
|
789
841
|
},
|
|
790
842
|
|
|
843
|
+
async ingestFromUrl(input, deps) {
|
|
844
|
+
const url = assertPublicHttpUrl(input.url);
|
|
845
|
+
const maxBytes = deps.maxFileSizeMB * 1024 * 1024;
|
|
846
|
+
|
|
847
|
+
const { bytes } = await fetchImageBytes(url, {
|
|
848
|
+
maxBytes,
|
|
849
|
+
timeoutMs: 15000,
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
// Trust the bytes, not the server's content-type header: only store data
|
|
853
|
+
// we can positively identify as a supported image format. This blocks
|
|
854
|
+
// content-type spoofing (e.g. an HTML/script payload served as an image).
|
|
855
|
+
const sniffed = sniffImageMimeType(bytes);
|
|
856
|
+
const mimeType =
|
|
857
|
+
sniffed && isAllowedSideloadImageType(sniffed) ? sniffed : null;
|
|
858
|
+
if (!mimeType) {
|
|
859
|
+
throw new ValidationError(
|
|
860
|
+
"That URL didn't return a supported image. Try a different image.",
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
await assertCanWriteBytes(bytes.byteLength);
|
|
865
|
+
|
|
866
|
+
const dimensions = parseImageDimensions(
|
|
867
|
+
mimeType,
|
|
868
|
+
bytes.subarray(0, IMAGE_DIMENSION_PEEK_BYTES),
|
|
869
|
+
);
|
|
870
|
+
const ext = imageExtensionForMimeType(mimeType) ?? "bin";
|
|
871
|
+
const { id, filename, storageKey } = generateStorageKey(
|
|
872
|
+
siteId,
|
|
873
|
+
`image.${ext}`,
|
|
874
|
+
);
|
|
875
|
+
const originalName = remoteImageName(url, ext);
|
|
876
|
+
|
|
877
|
+
// SVG can carry scripts. Display still works via <img> (browsers disable
|
|
878
|
+
// scripting there), and attachment disposition makes direct navigation to
|
|
879
|
+
// the raw object download instead of render — neutralizing the XSS vector.
|
|
880
|
+
await deps.storage.put(storageKey, bytes, {
|
|
881
|
+
contentType: mimeType,
|
|
882
|
+
contentDisposition:
|
|
883
|
+
mimeType === "image/svg+xml" ? "attachment" : "inline",
|
|
884
|
+
cacheControl: "public, max-age=31536000, immutable",
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
return this.create({
|
|
888
|
+
id,
|
|
889
|
+
filename,
|
|
890
|
+
originalName,
|
|
891
|
+
mimeType,
|
|
892
|
+
size: bytes.byteLength,
|
|
893
|
+
storageKey,
|
|
894
|
+
provider: deps.storageDriver,
|
|
895
|
+
mediaKind: "image",
|
|
896
|
+
width: dimensions?.width,
|
|
897
|
+
height: dimensions?.height,
|
|
898
|
+
alt: input.alt?.trim() || undefined,
|
|
899
|
+
});
|
|
900
|
+
},
|
|
901
|
+
|
|
791
902
|
async createTextAttachment(data, deps) {
|
|
792
903
|
if (!deps.storage) {
|
|
793
904
|
throw new ConfigurationError(
|
package/src/styles/ui.css
CHANGED
|
@@ -43,6 +43,22 @@ export const ComposeForm: FC<ComposeFormProps> = ({
|
|
|
43
43
|
comment: "@context: Close compose dialog",
|
|
44
44
|
}),
|
|
45
45
|
),
|
|
46
|
+
imageNotRehosted: i18n._(
|
|
47
|
+
msg({
|
|
48
|
+
message:
|
|
49
|
+
"An image couldn't be saved to your library — its original link was kept.",
|
|
50
|
+
comment:
|
|
51
|
+
"@context: Toast when a single pasted remote image couldn't be rehosted (e.g. blocked by the source's hotlink protection)",
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
54
|
+
imagesNotRehosted: i18n._(
|
|
55
|
+
msg({
|
|
56
|
+
message:
|
|
57
|
+
"{count} images couldn't be saved to your library — their original links were kept.",
|
|
58
|
+
comment:
|
|
59
|
+
"@context: Toast when several pasted remote images couldn't be rehosted; {count} is the number of images",
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
46
62
|
note: i18n._(
|
|
47
63
|
msg({
|
|
48
64
|
message: "Note",
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
toPublicAssetPath,
|
|
19
19
|
} from "../../lib/asset-path.js";
|
|
20
20
|
import { getJantIconHref } from "../../lib/jant-branding.js";
|
|
21
|
+
import { getPublicUrlForProvider } from "../../lib/image.js";
|
|
21
22
|
import { getThemeBrowserColors, resolveBuiltinTheme } from "../../lib/theme.js";
|
|
22
23
|
import { toAbsoluteAssetUrl, toPublicPath } from "../../lib/url.js";
|
|
23
24
|
import {
|
|
@@ -133,6 +134,16 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
|
|
|
133
134
|
const assetBasePath = IS_VITE_DEV
|
|
134
135
|
? "/"
|
|
135
136
|
: appConfig?.assetBasePath || getPublicAssetBasePath(sitePathPrefix);
|
|
137
|
+
// Public base URL for the active media provider, exposed so the client can
|
|
138
|
+
// tell which pasted images are already ours and skip rehosting them. Empty
|
|
139
|
+
// means media is served same-origin (the client's same-origin check covers it).
|
|
140
|
+
const mediaBase =
|
|
141
|
+
getPublicUrlForProvider(
|
|
142
|
+
appConfig?.storageDriver ?? "",
|
|
143
|
+
appConfig?.r2PublicUrl,
|
|
144
|
+
appConfig?.s3PublicUrl,
|
|
145
|
+
appConfig?.localPublicUrl,
|
|
146
|
+
) ?? "";
|
|
136
147
|
const currentUrl = c ? c.get("publicRequestUrl") : undefined;
|
|
137
148
|
const rawPath = c?.req?.path ?? "/";
|
|
138
149
|
const manifestStartPath = sitePathPrefix
|
|
@@ -281,6 +292,7 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
|
|
|
281
292
|
data-theme-mode={themeMode}
|
|
282
293
|
data-site-path-prefix={sitePathPrefix}
|
|
283
294
|
data-asset-base-path={assetBasePath}
|
|
295
|
+
data-media-base={mediaBase}
|
|
284
296
|
>
|
|
285
297
|
<head>
|
|
286
298
|
<meta charset="UTF-8" />
|