From b0e4aa2a1b33ce60a4a6d7f22b3643e831b99bc2 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:51:35 +0000 Subject: [PATCH 1/3] fix(posting): Prevent TypeError when image/thumbnails metadata is not an array --- apps/web/src/utils/posting.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/utils/posting.ts b/apps/web/src/utils/posting.ts index f5eaaa2398..7724e589fc 100644 --- a/apps/web/src/utils/posting.ts +++ b/apps/web/src/utils/posting.ts @@ -177,8 +177,8 @@ export const extractMetaData = (body: string, initialMeta: MetaData = {}): MetaD (other) => other.startsWith(url) && (other[url.length] === "?" || other[url.length] === "#") ); const isStale = (url: string) => isBrokenTwin(url) || isCutShortCopy(url); - const existingImages = (initialMeta.image ?? []).filter((url) => !isStale(url)); - const existingThumbnails = (initialMeta.thumbnails ?? []).filter((url) => !isStale(url)); + const existingImages = (Array.isArray(initialMeta.image) ? initialMeta.image : []).filter((url) => !isStale(url)); + const existingThumbnails = (Array.isArray(initialMeta.thumbnails) ? initialMeta.thumbnails : []).filter((url) => !isStale(url)); const allImages = Array.from(new Set([...existingImages, ...bodyImages])); From 58024a3fe97cb7064258e9920232aec849612149 Mon Sep 17 00:00:00 2001 From: feruzm Date: Mon, 21 Sep 2026 13:33:52 +0000 Subject: [PATCH 2/3] Read untrusted json_metadata list fields through one helper The Array.isArray guard stops the crash but discards a legacy image that was stored as a bare string, so editing such a post strips its cover image from the metadata. It also leaves a malformed value in the output, which publishes the same bad shape back to the chain on the next save. metaStringList coerces a bare string to a one-item list and drops non-string entries, and extractMetaData rewrites image/thumbnails only when what arrived was not an array, so well-formed metadata is untouched. The same field is read three lines apart in the editors with only a truthiness guard, where a bare string is worse than a crash: image[0] is its first CHARACTER and Array.from(new Set(image)) is one entry per character, and both reach the published metadata. tags has the same shape problem. All of them now read through the helper. --- .../entry/[author]/[permlink]/_page.tsx | 13 ++-- apps/web/src/app/submit/_page.tsx | 5 +- apps/web/src/specs/utils/posting.spec.ts | 67 ++++++++++++++++++- apps/web/src/utils/posting.ts | 30 ++++++++- 4 files changed, 106 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/publish/entry/[author]/[permlink]/_page.tsx b/apps/web/src/app/publish/entry/[author]/[permlink]/_page.tsx index 01252d711f..18f1729ccc 100644 --- a/apps/web/src/app/publish/entry/[author]/[permlink]/_page.tsx +++ b/apps/web/src/app/publish/entry/[author]/[permlink]/_page.tsx @@ -4,6 +4,7 @@ import { PublishEditor, PublishModeHeader } from "@/app/publish/_components"; import { usePublishEditor, usePublishState } from "@/app/publish/_hooks"; import { useEntryDetector } from "@/app/submit/_hooks"; import { Entry } from "@/entities"; +import { metaStringList } from "@/utils"; import i18next from "i18next"; import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; @@ -56,14 +57,18 @@ export default function Publish() { setEnrty(entry); setStep("edit"); setTitle(entry.title); - setTags(Array.from(new Set(entry.json_metadata?.tags ?? []))); + setTags(Array.from(new Set(metaStringList(entry.json_metadata?.tags)))); setContent(entry.body); // todo // A published post carries its own generated summary, so hand the body over with it: // a description that is that summary keeps following the body as the author edits. loadMetaDescription(entry.json_metadata?.description ?? "", entry.body); - entry?.json_metadata?.image && setSelectedThumbnail(entry?.json_metadata?.image[0]); - entry?.json_metadata?.image && - setEntryImages(Array.from(new Set(entry.json_metadata?.image))); + // Read through metaStringList: a bare-string `image` indexes to its first + // CHARACTER here, and spreads into one entry per character below. + const metaImages = metaStringList(entry.json_metadata?.image); + if (metaImages.length > 0) { + setSelectedThumbnail(metaImages[0]); + setEntryImages(Array.from(new Set(metaImages))); + } entry?.json_metadata?.location && setLocation(entry?.json_metadata?.location); setEditorContent(entry.body); diff --git a/apps/web/src/app/submit/_page.tsx b/apps/web/src/app/submit/_page.tsx index be93a1d199..7277a439c6 100644 --- a/apps/web/src/app/submit/_page.tsx +++ b/apps/web/src/app/submit/_page.tsx @@ -43,7 +43,7 @@ import { error, Feedback } from "@/features/shared/feedback"; import { Navbar } from "@/features/shared/navbar"; import { Theme } from "@/features/shared/theme"; import i18next from "i18next"; -import { extractMetaData, isCommunity } from "@/utils"; +import { extractMetaData, isCommunity, metaStringList } from "@/utils"; import { Draft, Entry, RewardType } from "@/entities"; import { TextareaAutocomplete } from "@/features/shared/textarea-autocomplete"; import { useEntryPollExtractor } from "@/features/polls"; @@ -198,7 +198,8 @@ function Submit({ path, draftId, username, permlink, searchParams }: Props) { // being saved. Anything the author wrote is kept. The old fallback read the body from // state, which still held whatever was in the editor before this post loaded. setDescription(descriptionToEdit(entry.json_metadata?.description, entry.body)); - entry?.json_metadata?.image && setSelectedThumbnail(entry?.json_metadata?.image[0]); + const [firstImage] = metaStringList(entry.json_metadata?.image); + firstImage && setSelectedThumbnail(firstImage); setEditingEntry(entry); } else if (editingEntry) { setEditingEntry(null); diff --git a/apps/web/src/specs/utils/posting.spec.ts b/apps/web/src/specs/utils/posting.spec.ts index bb1979bf46..e3fa11a822 100644 --- a/apps/web/src/specs/utils/posting.spec.ts +++ b/apps/web/src/specs/utils/posting.spec.ts @@ -7,7 +7,8 @@ import { extractMetaData, makeCommentOptions, makeJsonMetaData, - makeJsonMetaDataReply + makeJsonMetaDataReply, + metaStringList } from "../../utils/posting"; describe("Posting", () => { @@ -205,6 +206,70 @@ describe("Posting", () => { expect(extractMetaData(`Source: ${url} for details`).image).toEqual([url]); }); + /** + * json_metadata is whatever the publishing client wrote, so the initial metadata + * handed to extractMetaData is untrusted. Sentry ECENCY-NEXT-1GQM was one of these + * reaching `.filter` on a 2022 post whose publisher writes "" for its list fields. + */ + const legacyMeta = (value: unknown) => value as Parameters[1]; + + it("(21) extractMetadata survives a list field that is not a list", () => { + const url = "https://i.ecency.com/DQmX/body.png"; + expect(() => extractMetaData(`![](${url})`, legacyMeta({ image: "" }))).not.toThrow(); + expect(extractMetaData(`![](${url})`, legacyMeta({ image: "" })).image).toEqual([url]); + expect(extractMetaData(`![](${url})`, legacyMeta({ thumbnails: "" })).thumbnails).toEqual([url]); + }); + + it("(22) extractMetadata keeps a legacy image stored as a bare string", () => { + const stored = "https://i.ecency.com/DQmX/cover.png"; + const body = "https://i.ecency.com/DQmY/body.png"; + + // Nothing in the body to recover it from: dropping it here is what strips the + // post's cover image the first time it is edited. + expect(extractMetaData("no images at all", legacyMeta({ image: stored })).image).toEqual([ + stored + ]); + expect(extractMetaData(`![](${body})`, legacyMeta({ image: stored })).image).toEqual([ + stored, + body + ]); + expect( + extractMetaData(`![](${body})`, legacyMeta({ thumbnails: stored })).thumbnails + ).toContain(stored); + }); + + it("(23) extractMetadata never hands a malformed field back in the shape it arrived", () => { + // The output is spread from the input, so without a rewrite the same bad value is + // published straight back to the chain on the next save. + expect(extractMetaData("no images at all", legacyMeta({ image: "" })).image).toEqual([]); + expect(extractMetaData("no images at all", legacyMeta({ thumbnails: 7 })).thumbnails).toEqual( + [] + ); + // A well-formed field with nothing to add is still left exactly as it was. + const kept = ["https://i.ecency.com/DQmX/kept.png"]; + expect(extractMetaData("no images at all", { image: kept }).image).toEqual(kept); + }); + + it("(24) extractMetadata ignores non-string entries inside a list field", () => { + const url = "https://i.ecency.com/DQmX/cover.png"; + expect( + extractMetaData("no images at all", legacyMeta({ image: [url, null, 7, ""] })).image + ).toEqual([url]); + }); + + it("(25) metaStringList reads any shape a publisher may have written", () => { + const url = "https://i.ecency.com/DQmX/cover.png"; + expect(metaStringList([url])).toEqual([url]); + expect(metaStringList(url)).toEqual([url]); + expect(metaStringList([url, null, 7, ""])).toEqual([url]); + expect(metaStringList("")).toEqual([]); + expect(metaStringList(undefined)).toEqual([]); + expect(metaStringList({ 0: url })).toEqual([]); + // The editors index this list and spread it. On a bare string both read characters. + expect(metaStringList(url)[0]).toBe(url); + expect(Array.from(new Set(metaStringList(url)))).toEqual([url]); + }); + it("makeJsonMetaData", () => { const meta = { image: ["http://www.xx.com/a.png", "https://img.esteem.ws/h74zrad2fh.jpg"] diff --git a/apps/web/src/utils/posting.ts b/apps/web/src/utils/posting.ts index 7724e589fc..7c2e6b2c21 100644 --- a/apps/web/src/utils/posting.ts +++ b/apps/web/src/utils/posting.ts @@ -141,6 +141,22 @@ const collectImages = (body: string, pattern: RegExp, needsExtension: boolean): return found; }; +/** + * A list field of json_metadata, read as the list it is declared to be. + * + * json_metadata is whatever the publishing client wrote, so `image`, `thumbnails` + * and `tags` all arrive as bare strings in the wild, and as arrays holding nulls + * or numbers. Reading one directly costs more than a crash: `image[0]` on a + * string is its first CHARACTER and `new Set(image)` is its characters, both of + * which pass every truthiness check and get published back to the chain. + */ +export const metaStringList = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === "string" && item.length > 0); + } + return typeof value === "string" && value.length > 0 ? [value] : []; +}; + export const extractMetaData = (body: string, initialMeta: MetaData = {}): MetaData => { // Match images with common file extensions (including RAW formats like .arw) const imgReg = /https?:\/\/[^\s"']+\.(?:tiff?|jpe?g|gif|png|svg|ico|heic|webp|arw)/gi; @@ -177,13 +193,23 @@ export const extractMetaData = (body: string, initialMeta: MetaData = {}): MetaD (other) => other.startsWith(url) && (other[url.length] === "?" || other[url.length] === "#") ); const isStale = (url: string) => isBrokenTwin(url) || isCutShortCopy(url); - const existingImages = (Array.isArray(initialMeta.image) ? initialMeta.image : []).filter((url) => !isStale(url)); - const existingThumbnails = (Array.isArray(initialMeta.thumbnails) ? initialMeta.thumbnails : []).filter((url) => !isStale(url)); + const existingImages = metaStringList(initialMeta.image).filter((url) => !isStale(url)); + const existingThumbnails = metaStringList(initialMeta.thumbnails).filter((url) => !isStale(url)); const allImages = Array.from(new Set([...existingImages, ...bodyImages])); const out: MetaData = { ...initialMeta }; + // The spread above carries a malformed field through untouched, which would publish + // the same bad shape back to the chain. Only a field that was not an array is + // rewritten, so well-formed metadata reaches the block below exactly as before. + if (initialMeta.image !== undefined && !Array.isArray(initialMeta.image)) { + out.image = existingImages; + } + if (initialMeta.thumbnails !== undefined && !Array.isArray(initialMeta.thumbnails)) { + out.thumbnails = existingThumbnails; + } + if (allImages.length > 0) { out.image = allImages.slice(0, 10); out.thumbnails = Array.from( From b891cc391fc428617640c569a14c64bc5888544c Mon Sep 17 00:00:00 2001 From: feruzm Date: Mon, 21 Sep 2026 13:44:57 +0000 Subject: [PATCH 3/3] Guard the metadata builder, which is what the edit actually publishes Adversarial review found the previous commit fixed the wrong layer. Neither edit save path calls extractMetaData with the entry's metadata: both build json_metadata with .extend(entry)...withSelectedThumbnail(selected), where extend copies json_metadata verbatim and withSelectedThumbnail spreads its image. So for the post in the Sentry report the published image list was one entry PER CHARACTER of the stored URL, with an image_ratios probe for each, and an image that is a number or an object threw "is not iterable" there and made the edit unsaveable. withSelectedThumbnail now reads image through metaStringList and rewrites a malformed thumbnails it is not replacing. That covers publish, edit, drafts, schedule and templates, which all go through this one method. Dropped the rewrite block added to extractMetaData: no path that writes to the chain passes initialMeta, so it was dead, and one of its two branches could only ever assign an empty list. Its test is replaced by the invariant that matters for extractFromBody, that a body without images invents no image keys. Also reads the classic editor's tags through the helper. sanitizeTags calls tag.slice, so a non-string tag threw while the entry was loading. --- apps/web/src/app/submit/_page.tsx | 2 +- .../entry-metadata-builder.ts | 15 +++- .../entry-metadata-legacy-shapes.spec.ts | 82 +++++++++++++++++++ apps/web/src/specs/utils/posting.spec.ts | 15 ++-- apps/web/src/utils/posting.ts | 10 --- 5 files changed, 102 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/specs/features/entry-metadata-legacy-shapes.spec.ts diff --git a/apps/web/src/app/submit/_page.tsx b/apps/web/src/app/submit/_page.tsx index 7277a439c6..0a13d57517 100644 --- a/apps/web/src/app/submit/_page.tsx +++ b/apps/web/src/app/submit/_page.tsx @@ -191,7 +191,7 @@ function Submit({ path, draftId, username, permlink, searchParams }: Props) { useEntryDetector(username, permlink, (entry) => { if (entry) { applyTitle(entry.title); - applyTags(Array.from(new Set(entry.json_metadata?.tags ?? []))); + applyTags(Array.from(new Set(metaStringList(entry.json_metadata?.tags)))); setBody(entry.body); // A description that is the post's own summary follows the body while it is rewritten, // the way the composer treats one: left empty here, the publish path summarises the body diff --git a/apps/web/src/features/entry-management/entry-metadata-manager/entry-metadata-builder.ts b/apps/web/src/features/entry-management/entry-metadata-manager/entry-metadata-builder.ts index d24d3f054e..0ed04ca601 100644 --- a/apps/web/src/features/entry-management/entry-metadata-manager/entry-metadata-builder.ts +++ b/apps/web/src/features/entry-management/entry-metadata-manager/entry-metadata-builder.ts @@ -2,7 +2,7 @@ import { postBodySummary, proxifyImageSrc } from "@ecency/render-helper"; import { PollSnapshot } from "../../polls"; import appPackage from "../../../../package.json"; import { getDimensionsFromDataUrl } from "./get-dimensions-from-data-url"; -import { extractMetaData, makeApp } from "@/utils/posting"; +import { extractMetaData, makeApp, metaStringList } from "@/utils/posting"; import { makeEntryPath } from "@/utils/make-path"; import { AiToolsMeta, Entry, MetaData } from "@/entities"; import { DECENTMEMES_METADATA_VERSION } from "@/api/decentmemes"; @@ -89,13 +89,22 @@ export class EntryMetadataBuilder { selectedThumbnail: string | undefined, images?: string[] ): Promise { - const { image } = this.temporaryMetadata; + const { image, thumbnails } = this.temporaryMetadata; - let nextImages = [...(images ?? []), ...(image ?? [])]; + // `extend(entry)` copies json_metadata verbatim, so `image` is whatever the + // publishing client wrote. Spreading it directly is how a legacy post whose + // `image` is one bare URL string gets published back as one entry PER + // CHARACTER, with an image_ratios probe for each, and how a numeric or object + // value throws "is not iterable" and makes the edit unsaveable. + let nextImages = [...(images ?? []), ...metaStringList(image)]; if (selectedThumbnail) { nextImages.unshift(selectedThumbnail); this.withField("thumbnails", [selectedThumbnail]); + } else if (thumbnails !== undefined) { + // Nothing new to set, so the copied value is what would be published. Write + // the list it was meant to be rather than the shape it arrived in. + this.withField("thumbnails", metaStringList(thumbnails)); } nextImages = Array.from(new Set(nextImages)).splice(0, 9); diff --git a/apps/web/src/specs/features/entry-metadata-legacy-shapes.spec.ts b/apps/web/src/specs/features/entry-metadata-legacy-shapes.spec.ts new file mode 100644 index 0000000000..b1fa067074 --- /dev/null +++ b/apps/web/src/specs/features/entry-metadata-legacy-shapes.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; + +// The real one probes each URL over the network, which never resolves under jsdom. +vi.mock("@/features/entry-management/entry-metadata-manager/get-dimensions-from-data-url", () => ({ + getDimensionsFromDataUrl: vi.fn(async (url: string) => `ratio:${url}`) +})); + +import { EntryMetadataBuilder } from "@/features/entry-management/entry-metadata-manager/entry-metadata-builder"; +import type { Entry } from "@/entities"; + +const COVER = "https://i.ecency.com/DQmX/cover.png"; + +const legacyEntry = (json_metadata: unknown) => ({ json_metadata }) as unknown as Entry; + +/** + * The edit save path is `.extend(entry)...withSelectedThumbnail(selected)`, which copies + * json_metadata verbatim and then spreads its `image`. json_metadata is whatever the + * publishing client wrote, and a 2022 Liketu post in production carries bare strings in + * fields declared as lists (Sentry ECENCY-NEXT-1GQM came from one of them). + */ +describe("EntryMetadataBuilder on legacy metadata shapes", () => { + it("does not publish one image entry per character of a bare-string image", async () => { + const builder = await new EntryMetadataBuilder() + .extend(legacyEntry({ image: COVER })) + .withSelectedThumbnail(COVER); + + expect(builder.build().image).toEqual([COVER]); + }); + + it("keeps a bare-string image when no thumbnail was chosen", async () => { + const builder = await new EntryMetadataBuilder() + .extend(legacyEntry({ image: COVER })) + .withSelectedThumbnail(undefined); + + expect(builder.build().image).toEqual([COVER]); + }); + + it("stays saveable when image is a shape that cannot be spread", async () => { + // `[...(image ?? [])]` threw "is not iterable" here, so the edit could not be saved. + for (const image of [7, { 0: COVER }, null]) { + const builder = await new EntryMetadataBuilder() + .extend(legacyEntry({ image })) + .withSelectedThumbnail(COVER); + + expect(builder.build().image).toEqual([COVER]); + } + }); + + it("drops junk entries rather than publishing them back", async () => { + const builder = await new EntryMetadataBuilder() + .extend(legacyEntry({ image: [COVER, null, 7, ""] })) + .withSelectedThumbnail(undefined); + + expect(builder.build().image).toEqual([COVER]); + }); + + it("rewrites a malformed thumbnails field it is not replacing", async () => { + const builder = await new EntryMetadataBuilder() + .extend(legacyEntry({ image: [COVER], thumbnails: COVER })) + .withSelectedThumbnail(undefined); + + expect(builder.build().thumbnails).toEqual([COVER]); + }); + + it("computes one image_ratios entry per real image, never per character", async () => { + const builder = await new EntryMetadataBuilder() + .extend(legacyEntry({ image: COVER })) + .withSelectedThumbnail(COVER); + + expect(builder.build().image_ratios).toHaveLength(1); + }); + + it("leaves well-formed metadata exactly as it was", async () => { + const images = [COVER, "https://i.ecency.com/DQmY/second.png"]; + const builder = await new EntryMetadataBuilder() + .extend(legacyEntry({ image: images, thumbnails: [COVER] })) + .withSelectedThumbnail(COVER); + + expect(builder.build().image).toEqual(images); + expect(builder.build().thumbnails).toEqual([COVER]); + }); +}); diff --git a/apps/web/src/specs/utils/posting.spec.ts b/apps/web/src/specs/utils/posting.spec.ts index e3fa11a822..7cc850fcd7 100644 --- a/apps/web/src/specs/utils/posting.spec.ts +++ b/apps/web/src/specs/utils/posting.spec.ts @@ -238,14 +238,13 @@ describe("Posting", () => { ).toContain(stored); }); - it("(23) extractMetadata never hands a malformed field back in the shape it arrived", () => { - // The output is spread from the input, so without a rewrite the same bad value is - // published straight back to the chain on the next save. - expect(extractMetaData("no images at all", legacyMeta({ image: "" })).image).toEqual([]); - expect(extractMetaData("no images at all", legacyMeta({ thumbnails: 7 })).thumbnails).toEqual( - [] - ); - // A well-formed field with nothing to add is still left exactly as it was. + it("(23) extractMetadata adds no image keys to a body that has none", () => { + // EntryMetadataBuilder.extractFromBody spreads this result over the metadata of + // every new post, so an `image: []` invented here would be published on posts + // that have no image at all. + expect(extractMetaData("plain text, no images")).toEqual({}); + expect(extractMetaData("plain text, no images", { tags: ["x"] })).toEqual({ tags: ["x"] }); + // A well-formed field with nothing to add is left exactly as it was. const kept = ["https://i.ecency.com/DQmX/kept.png"]; expect(extractMetaData("no images at all", { image: kept }).image).toEqual(kept); }); diff --git a/apps/web/src/utils/posting.ts b/apps/web/src/utils/posting.ts index 7c2e6b2c21..6e9b0be80b 100644 --- a/apps/web/src/utils/posting.ts +++ b/apps/web/src/utils/posting.ts @@ -200,16 +200,6 @@ export const extractMetaData = (body: string, initialMeta: MetaData = {}): MetaD const out: MetaData = { ...initialMeta }; - // The spread above carries a malformed field through untouched, which would publish - // the same bad shape back to the chain. Only a field that was not an array is - // rewritten, so well-formed metadata reaches the block below exactly as before. - if (initialMeta.image !== undefined && !Array.isArray(initialMeta.image)) { - out.image = existingImages; - } - if (initialMeta.thumbnails !== undefined && !Array.isArray(initialMeta.thumbnails)) { - out.thumbnails = existingThumbnails; - } - if (allImages.length > 0) { out.image = allImages.slice(0, 10); out.thumbnails = Array.from(