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..0a13d57517 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"; @@ -191,14 +191,15 @@ 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 // 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/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 bb1979bf46..7cc850fcd7 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,69 @@ 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 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); + }); + + 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 f5eaaa2398..6e9b0be80b 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,8 +193,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 = metaStringList(initialMeta.image).filter((url) => !isStale(url)); + const existingThumbnails = metaStringList(initialMeta.thumbnails).filter((url) => !isStale(url)); const allImages = Array.from(new Set([...existingImages, ...bodyImages]));