Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions apps/web/src/app/publish/entry/[author]/[permlink]/_page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/app/submit/_page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -89,13 +89,22 @@ export class EntryMetadataBuilder {
selectedThumbnail: string | undefined,
images?: string[]
): Promise<this> {
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);
Expand Down
82 changes: 82 additions & 0 deletions apps/web/src/specs/features/entry-metadata-legacy-shapes.spec.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
66 changes: 65 additions & 1 deletion apps/web/src/specs/utils/posting.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
extractMetaData,
makeCommentOptions,
makeJsonMetaData,
makeJsonMetaDataReply
makeJsonMetaDataReply,
metaStringList
} from "../../utils/posting";

describe("Posting", () => {
Expand Down Expand Up @@ -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<typeof extractMetaData>[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"]
Expand Down
20 changes: 18 additions & 2 deletions apps/web/src/utils/posting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]));

Expand Down
Loading