diff --git a/platforms/pictique/api/src/database/entities/Post.ts b/platforms/pictique/api/src/database/entities/Post.ts index c9aecc735..aea380ec9 100644 --- a/platforms/pictique/api/src/database/entities/Post.ts +++ b/platforms/pictique/api/src/database/entities/Post.ts @@ -13,7 +13,10 @@ export class Post { @Column("text") text!: string; // was content - @Column("simple-array", { nullable: true }) + // simple-json (JSON.stringify/parse) is used instead of simple-array because + // base64 data URLs contain literal commas, which simple-array's naive + // comma-join/split encoding corrupts on read. + @Column("simple-json", { nullable: true }) images!: string[]; // was mediaUrls @OneToMany(() => Comment, (comment: Comment) => comment.post) diff --git a/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts b/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts new file mode 100644 index 000000000..6a9fc5975 --- /dev/null +++ b/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts @@ -0,0 +1,145 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Re-encode the `posts.images` column from the legacy `simple-array` format + * (naive comma-join) to `simple-json` (JSON.stringify/parse). + * + * Both column types compile down to a plain `text` column, so there is no + * schema change — only the stored payloads need converting. Without this, rows + * written under the old encoding would throw a JSON.parse error the first time + * the entity is read back after the decorator switches to `simple-json`. + * + * Base64 data URLs (`data:;base64,`) always contain a comma in + * their MIME prefix, so the old comma-join is ambiguous. We recover the + * original array by splitting only on commas that immediately precede a new + * `data:` URL — the `data:` token cannot occur inside a base64 payload (whose + * alphabet excludes `:`), so this boundary is unambiguous for image posts. + * Values that are already valid JSON arrays are left untouched (idempotent). + */ +export class ReencodePostImages1784133148233 implements MigrationInterface { + + // Number of rows loaded per keyset-paginated batch. Kept modest because + // each image payload can be a multi-MB base64 data URL. + private static readonly BATCH_SIZE = 200; + + public async up(queryRunner: QueryRunner): Promise { + const batchSize = ReencodePostImages1784133148233.BATCH_SIZE; + // Keyset cursor: the all-zero UUID is the lower bound, so `id > cursor` + // starts from the first row. Paging by id (never by the mutated + // `images` column) means re-encoded rows can't reappear in a later + // batch, so the walk always terminates. + let cursor = "00000000-0000-0000-0000-000000000000"; + let batch: Array<{ id: string; images: string | null }>; + + do { + batch = await queryRunner.query( + `SELECT "id", "images" FROM "posts" + WHERE "images" IS NOT NULL AND "id" > $1 + ORDER BY "id" ASC + LIMIT $2`, + [cursor, batchSize], + ); + if (batch.length === 0) break; + cursor = batch[batch.length - 1].id; + + const updates: Array<{ id: string; images: string }> = []; + for (const row of batch) { + if (typeof row.images !== "string") continue; + const reencoded = this.reencode(row.images); + // `undefined` = already valid JSON, no write needed. + if (reencoded !== undefined) { + updates.push({ id: row.id, images: reencoded }); + } + } + + if (updates.length > 0) { + // Single bulk UPDATE per batch via a VALUES join, instead of + // one round-trip per row. + const valuesSql = updates + .map((_, i) => `($${i * 2 + 1}::uuid, $${i * 2 + 2}::text)`) + .join(", "); + const params = updates.flatMap((u) => [u.id, u.images]); + await queryRunner.query( + `UPDATE "posts" AS p + SET "images" = v.images + FROM (VALUES ${valuesSql}) AS v(id, images) + WHERE p."id" = v.id`, + params, + ); + } + } while (batch.length === batchSize); + } + + /** + * Convert a single legacy `images` value to its `simple-json` encoding. + * Returns the new string to store, or `undefined` when the value is already + * a valid JSON array and should be left untouched. + */ + private reencode(raw: string): string | undefined { + // Empty string is how simple-array encoded an empty array. Left as "", + // simple-json would choke on JSON.parse("") — convert to "[]". + if (raw === "") return "[]"; + + // Already migrated (valid JSON array) — leave as-is. + if (raw.trimStart().startsWith("[")) { + try { + JSON.parse(raw); + return undefined; + } catch { + // Not actually valid JSON; fall through and re-encode. + } + } + + // Reconstruct the array from the legacy comma-joined string. + // Walk comma-separated tokens: a base64 data URL got split across two + // tokens ("data:;base64" + "") by the comma in its own + // prefix, so rejoin that pair. Any other value (e.g. a Firebase/HTTP + // download URL) contains no internal comma and stands alone. This + // recovers pure-data, pure-URL, and mixed posts in any order. + const tokens = raw.split(","); + const images: string[] = []; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i].trim(); + if (token === "") continue; + + if (token.startsWith("data:")) { + const payload = (tokens[i + 1] ?? "").trim(); + images.push(payload ? `${token},${payload}` : token); + i++; // consume the payload token + } else { + images.push(token); + } + } + + return JSON.stringify(images); + } + + public async down(queryRunner: QueryRunner): Promise { + // Reverse: re-encode JSON arrays back to the legacy comma-joined format. + const rows: Array<{ id: string; images: string | null }> = + await queryRunner.query( + `SELECT "id", "images" FROM "posts" WHERE "images" IS NOT NULL AND "images" <> ''`, + ); + + for (const row of rows) { + const raw = row.images; + if (raw === null || raw === "") continue; + + let images: string[]; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) continue; + images = parsed; + } catch { + // Not JSON — already in legacy format. + continue; + } + + await queryRunner.query( + `UPDATE "posts" SET "images" = $1 WHERE "id" = $2`, + [images.join(","), row.id], + ); + } + } + +} diff --git a/platforms/pictique/client/src/lib/fragments/Post/Post.svelte b/platforms/pictique/client/src/lib/fragments/Post/Post.svelte index 3d7f6618a..9c276d498 100644 --- a/platforms/pictique/client/src/lib/fragments/Post/Post.svelte +++ b/platforms/pictique/client/src/lib/fragments/Post/Post.svelte @@ -29,36 +29,11 @@ options?: Array<{ name: string; handler: () => void }>; } - function pairAndJoinChunks(chunks: string[]): string[] { - const result: string[] = []; - - console.log('chunks', chunks); - for (let i = 0; i < chunks.length; i += 2) { - const dataPart = chunks[i]; - const chunkPart = chunks[i + 1]; - - if (dataPart && chunkPart) { - if (dataPart.startsWith('data:')) { - result.push(`${dataPart},${chunkPart}`); - } else { - result.push(dataPart); - result.push(chunkPart); - } - } else { - if (!dataPart.startsWith('data:')) result.push(dataPart); - console.warn(`Skipping incomplete pair at index ${i}`); - } - } - console.log('result', result); - - return result; - } - const { avatar, userId, username, - imgUris: uris, + imgUris, text, count, callback, @@ -67,7 +42,6 @@ ...restProps }: IPostProps = $props(); - let imgUris = $derived(pairAndJoinChunks(uris)); let galleryRef: HTMLDivElement | undefined = $state(); let currentIndex = $state(0); diff --git a/platforms/pictique/client/src/lib/fragments/PostModal/PostModal.svelte b/platforms/pictique/client/src/lib/fragments/PostModal/PostModal.svelte index d61c91fcb..961adfea4 100644 --- a/platforms/pictique/client/src/lib/fragments/PostModal/PostModal.svelte +++ b/platforms/pictique/client/src/lib/fragments/PostModal/PostModal.svelte @@ -42,36 +42,11 @@ ownerProfile?: userProfile; } - function pairAndJoinChunks(chunks: string[]): string[] { - const result: string[] = []; - - console.log('chunks', chunks); - for (let i = 0; i < chunks.length; i += 2) { - const dataPart = chunks[i]; - const chunkPart = chunks[i + 1]; - - if (dataPart && chunkPart) { - if (dataPart.startsWith('data:')) { - result.push(`${dataPart},${chunkPart}`); - } else { - result.push(dataPart); - result.push(chunkPart); - } - } else { - if (!dataPart.startsWith('data:')) result.push(dataPart); - console.warn(`Skipping incomplete pair at index ${i}`); - } - } - console.log('result', result); - - return result; - } - const { avatar, userId, username, - imgUris: uris, + imgUris, text, count, callback, @@ -82,7 +57,6 @@ ...restProps }: IPostProps = $props(); - let imgUris = $derived(pairAndJoinChunks(uris)); let galleryRef: HTMLDivElement | undefined = $state(); let currentIndex = $state(0); let commentValue = $state('');