Upload text blobs with explicit charset=utf-8 so browsers decode UTF-8 correctly (em dashes rendered as mojibake on charset-less text/plain); keep NIP-94 m tag as the bare semantic MIME type

This commit is contained in:
Laan Tungir
2026-08-22 08:58:50 -04:00
parent 8da7527abf
commit c714baaebd
3 changed files with 41 additions and 14 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "nostr-publish",
"displayName": "Nostr Publish",
"description": "Publish files to Nostr from Codium/VSCode. Phase 0: raw unsigned event JSON. Phase 1: long-form notes (.md). Phase 2: signer (Rust n_signer port) signing backend.",
"version": "0.1.2",
"version": "0.1.3",
"publisher": "laantungir",
"license": "MIT",
"repository": {
+30 -7
View File
@@ -1860,6 +1860,20 @@ function resolveBlossomServers(): string[] {
return [];
}
/**
* Append an explicit `charset=utf-8` parameter to `text/*` MIME types.
*
* Blossom servers store the upload `Content-Type` and echo it verbatim when
* serving the blob. Without a charset parameter, browsers fall back to
* Windows-1252 for text responses, rendering UTF-8 content as mojibake (an
* em dash `—` shows as `â€"`). Non-text types are returned unchanged.
*/
function withUtf8Charset(mime: string): string {
if (!mime.startsWith("text/")) return mime;
if (/;\s*charset=/i.test(mime)) return mime;
return `${mime}; charset=utf-8`;
}
/**
* Shared upload + publish core for both the quick upload and header-based
* publish commands.
@@ -1870,11 +1884,15 @@ function resolveBlossomServers(): string[] {
* 4. Builds a kind 1063 NIP-94 event from the first successful descriptor.
* 5. Signs and publishes the 1063 event to the enabled relays.
*
* Markdown bodies are uploaded as `text/plain` (browsers render text/plain
* inline; no mainstream browser displays text/markdown, and servers send
* `X-Content-Type-Options: nosniff` which forbids guessing). The kind 1063
* event's `m` tag still advertises `text/markdown` so Nostr clients that
* render markdown can detect the file's true semantic type.
* Markdown bodies are uploaded as `text/plain; charset=utf-8` (browsers
* render text/plain inline; no mainstream browser displays text/markdown,
* and servers send `X-Content-Type-Options: nosniff` which forbids
* guessing). The explicit charset is required: Blossom servers echo the
* upload `Content-Type` verbatim when serving the blob, and without it
* browsers fall back to Windows-1252, rendering UTF-8 text as mojibake
* (e.g. an em dash `—` becomes `â€"`). The kind 1063 event's `m` tag still
* advertises the bare semantic type (`text/markdown`) so Nostr clients
* that render markdown can detect the file's true type.
*/
async function uploadAndPublishMetadata(
state: StateService,
@@ -1885,9 +1903,14 @@ async function uploadAndPublishMetadata(
): Promise<void> {
// Hybrid MIME handling: markdown uploads go out as text/plain for
// browser-renderability; the semantic type is preserved for the m tag.
// Text bodies carry an explicit charset=utf-8 (see withUtf8Charset).
const isMarkdown = mime === "text/markdown";
const uploadMime = isMarkdown ? "text/plain" : mime;
const semanticMime = isMarkdown ? "text/markdown" : undefined;
const uploadMime = withUtf8Charset(isMarkdown ? "text/plain" : mime);
// The m tag must carry the bare semantic MIME (no charset parameter):
// markdown → text/markdown; other text types → the bare type, since the
// server-reported descriptor type echoes the charset-annotated upload
// Content-Type. Binary types pass through unchanged (no override needed).
const semanticMime = isMarkdown || uploadMime !== mime ? mime : undefined;
// Require sign-in.
let signer = getSigner(state, config);
+10 -6
View File
@@ -16,12 +16,16 @@ export interface FileMetadataInput {
/**
* Override for the `m` tag (the file's semantic MIME type).
*
* Markdown blobs are uploaded as `text/plain` so browsers render them
* inline (no mainstream browser displays `text/markdown`, and
* `X-Content-Type-Options: nosniff` forbids guessing). The server then
* reports `type: text/plain` in the descriptor. This override keeps the
* Nostr `m` tag advertising the true semantic type (`text/markdown`) so
* clients that render markdown can still detect it.
* Markdown blobs are uploaded as `text/plain; charset=utf-8` so browsers
* render them inline (no mainstream browser displays `text/markdown`, and
* `X-Content-Type-Options: nosniff` forbids guessing). Text uploads carry
* an explicit charset because Blossom servers echo the upload
* `Content-Type` verbatim when serving blobs — without it browsers fall
* back to Windows-1252 and render UTF-8 text as mojibake. The server then
* reports the charset-annotated type (e.g. `text/plain; charset=utf-8`) in
* the descriptor. This override keeps the Nostr `m` tag advertising the
* bare semantic type (`text/markdown`, `text/plain`, …) so clients that
* render markdown can still detect it.
*/
mimeType?: string;
}