A "raw" link is one of the oldest conventions in pastebins: a URL that returns the text and
nothing else, as text/plain, so a script or an importer can fetch it. ShareNotes
had one, tucked away at /api/v1/notes/<name>/raw. Then someone asked us,
through the Suggestion box, for a way to link straight to a note's raw text. Looking into it,
we found our raw link was not always returning text at all.
What the raw link actually returned
ShareNotes notes come from two places, and they are stored differently.
- Notes sent through the API or the MCP server are stored exactly as sent.
- Notes written on the website come from a rich-text editor, a
contenteditableelement. We store what that element contains, which is HTML.
The raw endpoint returned whatever was stored. For an API note, that was right. For a note
typed in the editor, it was HTML with the right Content-Type on top.
The worst case is code pasted from a site that colours it. The editor keeps the colouring,
so a one-line JSON object like {"a":1} is stored as something like this:
<span class="br0">{</span><span class="st0">"a"</span><span class="sy0">:</span><span class="nu0">1</span><span class="br0">}</span>
Fetch that through a "raw" link and feed it to a JSON parser, and the parser gives up at the first character. The note looked fine in the browser. Only the raw link was broken, which is exactly the link a program uses.
Knowing which notes are HTML
The obvious fix is "if it looks like HTML, convert it". That breaks the other half of our
users: someone who uses curl to post an HTML file, a JSX snippet or an XML config
through the API expects those tags back byte for byte. Stripping them would be its own bug.
So each note now records where its text came from. A new content_format column
says html for notes from the editor and text for notes from the API.
Editing a note in the browser makes it html, because the editor saves HTML. API
notes are never converted.
Older notes have no value in that column, so for them we guess: tags or entities the editor
writes mean HTML, and a whole document starting with <!doctype or
<html> means text, because the editor never stores one of those. The guess
only applies to notes saved before this change.
Turning editor HTML back into the text you see
The target is simple to state: the raw link should return the lines you see in the note. Getting there means making the decisions a browser makes when it lays the note out.
- Block elements such as
<div>and<p>start a new line, and<br>ends one. - A
<br>at the very end of a block does not add an empty line. The editor writes<div>text<br></div>a lot, and a browser shows one line there, not two. - Runs of spaces and newlines in the HTML collapse to one space, the way CSS collapses
them, except inside
<pre>or an inlinewhite-space: prestyle. becomes a plain space and is never collapsed. This is how the editor stores typed indentation, so it decides whether your Python still runs after the round trip.- Entities are decoded, and scripts, styles and hidden elements are skipped.
Why not use the browser's own innerText? There is no browser on the server,
and innerText is not quite what you see either. In Chrome, a blank line typed in
the editor comes out of innerText as two, because the empty
<div><br></div> counts both as a block and as a line break. In
code, that doubles every blank line. We wanted the text on the screen, not the text an API
happens to return.
The server parses the stored HTML with htmlparser2 and feeds each tag and piece of text to a small layout class that applies the rules above. The same class, as a byte-identical copy, runs in the browser. A test fails if the two copies ever differ.
The same rules in the note itself
Writing the rules down showed us a bug in the note view. When a note has no formatting and
looks like code, the viewer shows it as one syntax-highlighted block. It built that block from
the note's textContent, which ignores <div> boundaries. So
code typed line by line in the editor came back as one long line, and Copy text and
Download copied that one line.
Code pasted from elsewhere has the opposite shape: its line breaks are real newline characters inside the HTML, which normal layout would collapse. The code view now asks the shared layout for its lines and keeps whichever source has more of them, so typed code and pasted code both keep theirs. The raw link makes the same choice, so the two agree.
One two-segment route in a server that 404s everything else
The raw link people asked for is short: sharenotes.dev/<name>/raw. Our
front-end server deliberately answers 404 for any address with two or more path segments that
is not a real page, so that made-up URLs don't turn into indexable copies of the app. The raw route is now the one
exception, and it is matched narrowly:
location ~ ^/([A-Za-z0-9-]+)/raw$ {
resolver 127.0.0.11 valid=30s ipv6=off;
set $raw_backend sharenote-backend:3001;
proxy_pass http://$raw_backend/api/v1/notes/$1/raw;
}
- The character class is the alphabet our note names use, so nothing unexpected reaches
the URL we proxy to.
/name/raw/,/name/RAWand/a/raw/bstill get a 404. - The backend's address is looked up per request. With a fixed upstream name, nginx refuses to start while the backend container is down, and this server delivers every page of the site. A test starts it with no backend at all and checks the site still loads.
- The app's service worker would answer
/name/rawwith the app itself for anyone who has visited before, so the route is on its list of addresses to leave to the network.
What the raw link never opens
A raw link is easy to paste into a script, a chat or a README. That makes it a convenient way to leak a note that was meant to stay contained, so it obeys every rule the note itself does.
- Notes locked with a PIN for viewing return
401. The PIN is entered in a browser, and scripts never have it. - Notes that delete after the first read are used up by a raw read, like any other read. Link-preview bots in chat apps get an empty body and don't use the note up, and the browser that made the note doesn't either. The Raw button is not shown on these notes, so nobody spends the one read by accident.
- Every raw response is sent with
X-Robots-Tag: noindex, nofollowandCache-Control: no-store.
One more practical detail: converting a large note is not free. In our tests, 10 MB of editor HTML took about a second, and a server spending a second on one request is a server not answering anyone else. Large conversions are cached by note and last-edit time, so fetching the same raw link again costs nothing, and an edit replaces the cached copy.