@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.
Files changed (42) hide show
  1. package/dist/{app-CGHkOdme.js → app-CpmficmQ.js} +531 -204
  2. package/dist/app-DqKkZenB.js +6 -0
  3. package/dist/client/.vite/manifest.json +3 -3
  4. package/dist/client/_assets/client-BhHHVvSY.css +2 -0
  5. package/dist/client/_assets/{client-DYrWuaIk.js → client-Dd9U383b.js} +1 -1
  6. package/dist/client/_assets/{client-auth-B5Re0uCd.js → client-auth-DkpSdIDz.js} +80 -80
  7. package/dist/{export-DY1v5Iqu.js → export-Ba7NJImL.js} +92 -92
  8. package/dist/{github-sync-LefaslGJ.js → github-sync-BD4w2m8-.js} +2 -2
  9. package/dist/{github-sync-2_T7nbOv.js → github-sync-Cb4_6_i7.js} +1 -1
  10. package/dist/index.js +3 -3
  11. package/dist/node.js +4 -4
  12. package/package.json +1 -1
  13. package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +62 -0
  14. package/src/client/components/compose-types.ts +4 -0
  15. package/src/client/components/jant-compose-editor.ts +111 -0
  16. package/src/client/compose-bridge.ts +25 -8
  17. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
  18. package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
  19. package/src/client/tiptap/__tests__/rehost-images.test.ts +139 -0
  20. package/src/client/tiptap/create-editor.ts +3 -0
  21. package/src/client/tiptap/extensions.ts +4 -0
  22. package/src/client/tiptap/inline-image-upload.ts +174 -50
  23. package/src/client/tiptap/rehost-images.ts +104 -0
  24. package/src/i18n/locales/public/en.po +10 -0
  25. package/src/i18n/locales/public/en.ts +1 -1
  26. package/src/i18n/locales/public/zh-Hans.po +10 -0
  27. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  28. package/src/i18n/locales/public/zh-Hant.po +10 -0
  29. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  30. package/src/lib/__tests__/upload-sideload.test.ts +78 -0
  31. package/src/lib/__tests__/url-fetch.test.ts +181 -0
  32. package/src/lib/upload.ts +111 -0
  33. package/src/lib/url-fetch.ts +263 -0
  34. package/src/routes/api/__tests__/uploads.test.ts +63 -1
  35. package/src/routes/api/uploads.ts +52 -0
  36. package/src/services/__tests__/media.test.ts +168 -1
  37. package/src/services/media.ts +111 -0
  38. package/src/styles/ui.css +1 -1
  39. package/src/ui/compose/ComposeDialog.tsx +16 -0
  40. package/src/ui/layouts/BaseLayout.tsx +12 -0
  41. package/dist/app-D24n0DoH.js +0 -6
  42. package/dist/client/_assets/client-xWDl78yi.css +0 -2
@@ -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
@@ -2237,7 +2237,7 @@
2237
2237
  }
2238
2238
 
2239
2239
  .post-header-block {
2240
- margin-bottom: 1rem;
2240
+ margin-bottom: 1.5rem;
2241
2241
  }
2242
2242
 
2243
2243
  .post-header-block-detail {
@@ -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" />
@@ -1,6 +0,0 @@
1
- import "./url-BMYO-Zlt.js";
2
- import { t as createApp } from "./app-CGHkOdme.js";
3
- import "./export-DY1v5Iqu.js";
4
- import "./env-OHRKGcMj.js";
5
- import "./github-sync-2_T7nbOv.js";
6
- export { createApp };