Stop storing raw file bytes in media.text_content

`text_content` is an extracted-text cache, but nothing stopped it from
holding a dump of bytes:

- `fileToText` ended its switch with `default: return buffer.toString("utf-8")`
  and defaulted a missing extension to "txt", so any binary was decoded.
- Both extraction callers gated on `media_type == "document"`, and
  `attachmentFileKind` classes everything non-image/audio/video as a
  document: an SVG, a zip, a spreadsheet, a .bin.
- Nothing capped the length, so a 20 MB attachment could leave 20 MB of
  text in the database.
- `storeChatAttachments` copied the text onto a second `media` row, and
  `SELECT m.*` pulled the column into page props and API responses.

On a real install an SVG held 6852 characters of markup and a
`.designcraft` file held 142 KB of raw bytes.

Changes:

- `file-to-text.ts` lists the text extensions explicitly and returns
  undefined for anything else; no extension is no text.
- `can-extract-text.ts` is the one allowlist (reusing
  `BotTextFileExtensions`), used by both upload routes and by
  `uploadAndRecordMedia`; `extension-of.ts` is shared with the storage
  catalog instead of duplicated.
- `BotAttachmentTextStoreMaxChars` (200k) caps the column via
  `cap-text-content.ts`.
- `store-chat-attachments.ts` no longer copies the text; ingestion
  re-reads the bytes once.
- `media-columns.ts` selects every media column but `text_content`, which
  now has to be asked for (`grab-messages-media`'s reply path and
  ingestion do); the media CRUD routes sanitize rows they write.
- `ingest-bot-file.ts` decides on size alone, since a cached slice made
  an oversized file look already read.
- `bun run media:prune-text` cleans rows that were already stored
  (rerunnable, with a VACUUM hint).

Verified on the dev database: both bad rows cleared (146 KB), rerun
reports nothing to do.
This commit is contained in:
2026-10-23 05:22:04 +01:00
parent 5475241371
commit d3357cb7df
36 changed files with 1034 additions and 43 deletions
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import fileToText from "./file-to-text";
function stream({ text }: { text: string }) {
return Buffer.from(text, "utf-8") as Buffer<ArrayBuffer>;
}
describe("fileToText", () => {
test("reads the plain text types as they are", async () => {
for (const file_type of ["txt", "text", "md", "markdown", "csv", "tsv", "json", "html", "htm", "xml", "yml", "yaml", "log"]) {
expect(await fileToText({ file_type, file_stream: stream({ text: "hello" }) })).toBe("hello");
}
});
test("keeps the real case of the text", async () => {
expect(await fileToText({ file_type: "md", file_stream: stream({ text: "# Heading\n\nBody" }) })).toBe(
"# Heading\n\nBody",
);
});
/**
* The bug this guards: the switch used to fall through to
* `buffer.toString("utf-8")`, so a zip, an SVG or a spreadsheet was
* "extracted" as garbled bytes and stored in `media.text_content`.
*/
test("refuses a binary instead of decoding its bytes", async () => {
for (const file_type of ["svg", "zip", "xlsx", "pptx", "bin", "exe", ""]) {
expect(await fileToText({ file_type, file_stream: stream({ text: "PK\u0003\u0004binary" }) })).toBeUndefined();
}
});
test("no buffer is no text", async () => {
expect(await fileToText({ file_type: "txt" })).toBeUndefined();
});
});
@@ -36,9 +36,15 @@ export default async function fileToText({
buffer = file_path
? Buffer.from(await Bun.file(file_path).arrayBuffer())
: file_stream;
ext = (file_type || file_path?.split(".").pop() || "txt")
.toString()
.toLowerCase();
const known_ext = file_type || file_path?.split(".").pop();
/**
* No extension and no type: nothing says this is text, so nothing
* gets decoded. Guessing "txt" is how a binary became text.
*/
if (!known_ext) return undefined;
ext = known_ext.toString().toLowerCase();
}
if (!buffer) return undefined;
@@ -76,9 +82,32 @@ export default async function fileToText({
// }).join("\n\n");
// }
case "md":
/**
* The text types read as they are. Listed one by one, never a
* catch-all: decoding a zip, an SVG or a spreadsheet as UTF-8
* fills the media row with raw bytes instead of text.
*/
case "txt":
default:
case "text":
case "md":
case "markdown":
case "csv":
case "tsv":
case "json":
case "html":
case "htm":
case "xml":
case "yml":
case "yaml":
case "log":
return buffer.toString("utf-8");
default:
/**
* Nothing readable locally: the caller decides whether to read
* it another way (an image or audio through the harness) or to
* leave its text unset.
*/
return undefined;
}
}
@@ -17,6 +17,8 @@ import BunSQLite, { type APIResponseObject } from "@moduletrace/bun-sqlite";
import { existsSync, mkdirSync } from "fs";
import path from "path";
import sharp from "sharp";
import canExtractText from "@/src/utils/can-extract-text";
import capTextContent from "@/src/utils/cap-text-content";
import fileToText from "./file-to-text";
import MediaDataToArrayBuffer from "./media-data-to-array-buffer";
import sniffMediaExtension from "./sniff-media-ext";
@@ -180,14 +182,22 @@ export default async function uploadAndRecordMedia({
mode: 1,
});
if (extract_media_text_content) {
/**
* Only an extension the extractor really unpacks is read: any
* other binary has no text, and decoding it anyway stored raw
* bytes in the row.
*/
if (
extract_media_text_content &&
canExtractText({ name: media_name, mime_type: media_mime_type })
) {
const media_text = await fileToText({
file_type: media_ext,
file_stream: Buffer.from(media_array_buffer),
});
if (media_text) {
media_text_content = media_text;
media_text_content = capTextContent({ text: media_text });
}
}
}