Case study

Разбор проекта

Pastebin.Monster

Ephemeral paste sharing with slugs you can read out loud. Designed to decay: every paste is gone minutes after it stops being needed.

Эфемерный обмен сниппетами со слагами, которые можно продиктовать вслух. Спроектирован на исчезновение: каждая запись удаляется через считанные минуты после того, как перестала быть нужной.

LiveПродакшен SourceИсходники Status: live since 2025Статус: в проде с 2025

Node.jsFastifyTypeScriptSQLiteReactVitenginx

01 · Context

01 · Контекст

The problem

Moving a snippet of text between two machines, or two people, is still oddly heavy. Mainstream pastebins want accounts, show ads, and hand back permanent URLs made of random base62 characters. That last part is the real failure: try dictating xK9mQ2vTr8 over a voice call.

The insight this project is built on: a paste is a transfer, not a document. Its useful life is measured in minutes. Once the recipient has it, the honest thing for the system to do is destroy it: permanence is a liability, not a feature.

The scope is deliberately tiny. The exercise was to take a weekend-sized system and run it with full engineering ceremony: explicit requirements, seven ADRs, verified expiry semantics. It was a study in right-sizing, applying exactly as much architecture as the problem deserves and no more.

The system has grown once since, with image paste added as a schema-additive migration that left the original text-paste path untouched. The interesting part isn't the feature itself; it's that the same discipline held on the second pass: real requirements, documented trade-offs, and a harder look at unrecoverable deletion, a guarantee the text-only design had never actually had to earn.

Проблема

Передать фрагмент текста между двумя машинами, или двумя людьми, до сих пор неоправданно тяжело. Популярные пастбины требуют аккаунт, показывают рекламу и выдают вечные URL из случайных base62-символов. Последнее и есть главный провал: попробуйте продиктовать xK9mQ2vTr8 по голосовой связи.

Идея, на которой построен проект: паста представляет собой передачу, а не документ. Её полезная жизнь измеряется минутами. Как только получатель её открыл, система поступает честно, уничтожая содержимое. Постоянство здесь является не фичей, а риском.

Масштаб сознательно минимальный. Задача была взять систему «на выходные» и провести её через полный инженерный цикл: явные требования, семь ADR, верифицированная семантика истечения. Это было упражнение в соразмерности: ровно столько архитектуры, сколько заслуживает задача, и ни граммом больше.

Система выросла один раз с тех пор, добавив вставку изображений, реализованную как схема-аддитивная миграция, не тронувшая исходный путь текстовых паст. Интересна не сама фича; интересно, что та же дисциплина сохранилась и на втором проходе: реальные требования, задокументированные компромиссы и более пристальный взгляд на невосстановимое удаление, гарантию, которую текстовый дизайн раньше просто не имел случая заслужить.

02 · Requirements & constraints

02 · Требования и ограничения

What it must do, and under what conditions

FRCreate a paste and receive a short shareable URL. Retrieve a paste by slug. No registration, no login, no cookies required for either. Optionally attach a PNG image to a paste; retrieve it alongside the text.
NFRSlugs must survive a voice channel: speakable, memorable for about 30 seconds. Expired content must be unrecoverable: rows deleted, not flagged. For images specifically, that means not just the row but the underlying bytes, since deleted BLOB pages can otherwise linger until reused. Zero marginal cost per paste. An uploaded file's format is never trusted from what the client claims it is.
ConstraintsOne shared VPS hosting other Softwarean projects; nginx in front; API on port 3002; SQLite only, no database daemon; no auth system (the friction budget is zero). Image size capped at 5 MB, raised from an original 1 MB plan mid-implementation, a change not yet reconciled against the rate-limiting sizing done before it (see Retrospective).

Что система обязана делать, и в каких условиях

FRСоздать пасту и получить короткий URL для передачи. Получить пасту по слагу. Без регистрации, без логина, без обязательных cookies. Опционально прикрепить PNG-изображение к пасте; получить его вместе с текстом.
NFRСлаг должен переживать голосовой канал: произносимый, запоминаемый на ~30 секунд. Истёкший контент невосстановим: строки удаляются, а не помечаются. Для изображений это касается не только строки, но и самих байтов, поскольку удалённые страницы BLOB иначе могут задержаться до переиспользования. Нулевая предельная стоимость одной пасты. Формат загруженного файла никогда не берётся на веру из того, что заявляет клиент.
ОграниченияОдин общий VPS с другими проектами Softwarean; nginx на фронте; API на порту 3002; только SQLite, без отдельного демона БД; без системы аутентификации (бюджет на трение равен нулю). Размер изображения ограничен 5 МБ, поднят с изначально запланированного 1 МБ по ходу реализации, изменение, ещё не сверенное с расчётом rate limiting, сделанным до него (см. Ретроспективу).

03 · Architecture

03 · Архитектура

Three moving parts, one file of state

A React SPA served as static files by nginx; a Fastify API in TypeScript behind the same nginx on port 3002; a single SQLite file as the only state. Expiry is enforced twice: lazily on every read, and by a periodic sweep that deletes what nobody came back for.

Image paste rides the same three components: no new service, no object storage added. Migrations run automatically at server startup, tracked against SQLite's built-in user_version pragma, so the schema change that added images required no manual deploy step beyond shipping the new server binary.

Три компонента, состояние в одном файле

React-SPA раздаётся как статика через nginx; Fastify-API на TypeScript за тем же nginx на порту 3002; единственное состояние: один файл SQLite. Истечение срока применяется дважды: лениво при каждом чтении и периодической зачисткой, удаляющей то, за чем никто не вернулся.

Вставка изображений едет на тех же трёх компонентах: ни нового сервиса, ни объектного хранилища не добавлено. Миграции выполняются автоматически при старте сервера, отслеживаются через встроенную SQLite-прагму user_version, так что изменение схемы, добавившее изображения, не потребовало ручного шага деплоя сверх поставки нового бинарника сервера.

Browser React SPA nginx static + proxy Fastify API TypeScript · :3002 SQLite single file · pastes HTTPS /api read / write expiry sweep
Container view. Solid lines are the request path; the dashed loop is the periodic sweep deleting expired rows.
Контейнерная диаграмма. Сплошные линии обозначают путь запроса; пунктирная петля обозначает периодическую зачистку истёкших строк.

04 · Key decisions

04 · Ключевые решения

Seven decisions that shaped the system

Digests of the ADRs that mattered. Each one names what the decision cost: a trade-off you don't acknowledge is a trade-off you didn't see. The last three came later, with image paste.

Семь решений, определивших систему

Выжимки из значимых ADR. В каждой названа цена решения: трейд-офф, который не назван, это трейд-офф, который не был замечен. Последние три появились позже, вместе со вставкой изображений.

Two-timer expiry

Двухтаймерное истечение

Options
Fixed TTL from creation · burn-after-reading · user-selected TTL.
Chosen
Two timers: 5 minutes from creation if never viewed, otherwise 5 minutes from the last view.
Why
Fixed TTL fails the "recipient opens the link late" case; burn-after-reading fails the multi-device and multi-recipient case. Two timers preserve the actual guarantee: content decays minutes after it stops being needed, not before and not long after.
What it cost
The semantics need a sentence to explain instead of a number. Two timestamps instead of one. And every read becomes a write, because viewing resets the clock.
Варианты
Фиксированный TTL от создания · burn-after-reading · TTL по выбору пользователя.
Выбрано
Два таймера: 5 минут от создания, если пасту не открывали, иначе 5 минут от последнего просмотра.
Почему
Фиксированный TTL ломается на сценарии «получатель открыл ссылку поздно»; burn-after-reading, на сценарии с несколькими устройствами или получателями. Два таймера сохраняют настоящую гарантию: контент исчезает через минуты после того, как перестал быть нужным, не раньше и не сильно позже.
Цена
Семантику приходится объяснять предложением, а не числом. Два таймстемпа вместо одного. И каждое чтение становится записью, потому что просмотр сбрасывает таймер.

Speakable slugs

Произносимые слаги

Options
Random base62 · UUID · user-chosen names · adjective-noun-number.
Chosen
adjective-noun-number, e.g. brave-falcon-42.
Why
Dictation over a voice channel is the founding use case. A slug built from real words survives a phone call; ten characters of base62 do not.
What it cost
A drastically smaller namespace: slugs are guessable in principle. Accepted because the enumeration window is minutes long, the product is positioned for non-sensitive content, and inserts retry on collision.
Варианты
Случайный base62 · UUID · имена от пользователя · прилагательное-существительное-число.
Выбрано
прилагательное-существительное-число, например brave-falcon-42.
Почему
Диктовка по голосовой связи является исходным сценарием продукта. Слаг из настоящих слов переживает телефонный звонок, а десять символов base62 не переживают.
Цена
Резко меньшее пространство имён: слаги в принципе перебираемы. Принято: окно для перебора живёт минуты, продукт позиционирован для нечувствительного контента, вставка повторяется при коллизии.

SQLite, not Postgres

SQLite, а не Postgres

Options
PostgreSQL · Redis with TTL · SQLite.
Chosen
SQLite, one file, accessed in-process.
Why
Write volume is tiny, data is ephemeral by design, and the VPS already runs enough daemons. Redis TTL was tempting, expiry for free, but it can't express the two-timer model without application logic anyway, so it buys nothing.
What it cost
A single-writer ceiling. Irrelevant at this scale, and the exit path (same SQL, different driver) is documented rather than pre-built.
Варианты
PostgreSQL · Redis с TTL · SQLite.
Выбрано
SQLite: один файл, доступ внутри процесса.
Почему
Объём записи крошечный, данные эфемерны по замыслу, а на VPS и так хватает демонов. Redis с TTL был соблазнителен, истечение «из коробки», но двухтаймерную модель он всё равно не выражает без логики в приложении, так что не даёт ничего.
Цена
Потолок «один писатель». На этом масштабе не имеет значения; путь миграции (тот же SQL, другой драйвер) задокументирован, но не построен заранее.

No accounts, ever

Никаких аккаунтов

Options
Optional accounts for paste management · anonymous with edit tokens · fully anonymous.
Chosen
Fully anonymous. Nothing to sign up for, nothing to manage.
Why
Identity adds nothing to an artifact that lives five minutes, and every field on a form spends the friction budget, and that budget is zero.
What it cost
No abuse control through identity. Abuse control has to come from the expiry model and size caps instead, a real limitation acknowledged in the retrospective.
Варианты
Опциональные аккаунты для управления пастами · анонимно с edit-токенами · полностью анонимно.
Выбрано
Полная анонимность. Нечего регистрировать, нечем управлять.
Почему
Идентичность ничего не даёт артефакту, живущему пять минут, а каждое поле формы тратит бюджет на трение, который равен нулю.
Цена
Нет контроля злоупотреблений через идентичность. Он должен обеспечиваться моделью истечения и лимитами размера, реальное ограничение, признанное в ретроспективе.

Images live in the row, not beside it

Изображения живут в строке, а не рядом с ней

Options
Filesystem storage, path referenced from the row · an object-storage bucket · the image as a BLOB column in the same SQLite row as the text.
Chosen
BLOB in the row. kind and image columns added via an additive migration; the text-only path is untouched.
Why
The product's one real guarantee is that expiry deletion is atomic and unrecoverable. A split store, a database row plus a file living elsewhere, turns one atomic delete into two operations that can fail independently; an image orphaned by a crash between them would sit on disk long after its paste claimed to be gone, quietly breaking the guarantee the whole product is built on. Keeping the image inside the row means the same DELETE that has always removed a paste removes the image too, with no second system to keep in sync.
What it cost
SQLite's connection needed secure_delete = ON added specifically for this: without it, deleted BLOB bytes can persist in freed pages until overwritten, which the text-only design never had to consider since short text rarely fills a page on its own. Every paste, including plain text ones, now pays a small write-amplification cost the original schema didn't.
Варианты
Файловое хранилище с путём в строке · бакет объектного хранилища · изображение как BLOB-колонка в той же строке SQLite, что и текст.
Выбрано
BLOB в строке. Колонки kind и image добавлены аддитивной миграцией; путь только для текста не тронут.
Почему
Единственная настоящая гарантия продукта в том, что удаление по истечении атомарно и невосстановимо. Разделённое хранилище, строка базы плюс файл где-то ещё, превращает одно атомарное удаление в две операции, способные упасть независимо; изображение, осиротевшее из-за краха между ними, пролежит на диске ещё долго после того, как его паста заявит, что её больше нет, тихо ломая гарантию, на которой построен весь продукт. Хранение изображения внутри строки означает, что тот же DELETE, что всегда удалял пасту, удаляет и изображение, без второй системы, которую нужно держать в синхроне.
Цена
Соединению SQLite специально ради этого потребовался secure_delete = ON: без него удалённые байты BLOB могут задержаться в освобождённых страницах до переиспользования, о чём текстовому дизайну никогда не приходилось задумываться, поскольку короткий текст редко заполняет страницу целиком. Теперь каждая паста, включая обычные текстовые, платит небольшую цену амплификации записи, которой не было в исходной схеме.

Trust the bytes, not the declared type

Доверять байтам, а не заявленному типу

Options
Trust the client's Content-Type header · trust the uploaded filename's extension · inspect the file's own magic bytes, server-side, regardless of what the client claims.
Chosen
Magic-byte inspection. A file is accepted as PNG only if it actually starts with the PNG signature, checked on the server.
Why
A Content-Type header or a filename extension is just a string the client chose to send: trusting either means the server's understanding of what a file is comes entirely from something the sender fully controls and has every reason to lie about if they want to smuggle something else through. Checking the bytes themselves is the only version of "this is a PNG" that isn't also a claim.
What it cost
A small amount of parsing work on every upload, and a validator that has to be kept correct rather than delegated to a header the browser fills in for free.
Варианты
Доверять заголовку Content-Type от клиента · доверять расширению имени загруженного файла · проверять собственные магические байты файла на сервере, независимо от заявлений клиента.
Выбрано
Проверка магических байтов. Файл принимается как PNG только если он реально начинается с сигнатуры PNG, проверяемой на сервере.
Почему
Заголовок Content-Type или расширение имени файла являются просто строкой, которую решил отправить клиент; доверие любому из них означает, что понимание сервером того, что за файл перед ним, целиком строится на том, что полностью контролирует отправитель и о чём у него есть все причины солгать, если он хочет протащить что-то другое. Проверка самих байтов является единственной версией «это PNG», которая не является одновременно и заявлением.
Цена
Немного работы по разбору на каждой загрузке, и валидатор, который нужно держать корректным, а не делегировать заголовку, который браузер и так бесплатно заполняет.

Retrieval gated behind a same-origin check

Получение закрыто проверкой same-origin

Options
Point <img src> directly at the API, like a normal image URL · gate the image endpoint behind a custom header only the SPA's own fetch logic sends, and render the result as a blob object URL.
Chosen
The gated fetch. The frontend requests the image with a custom header, receives it as a blob, and renders it via an object URL, never a bare <img src> pointed at the API.
Why
The same problem the two-timer model already had to solve for text, a messenger's link-preview bot fetching a URL before any human does and that fetch silently counting as the view, is worse for a direct <img src>, since browsers and preview bots fetch image URLs automatically and eagerly, with no way to distinguish that from the real recipient opening the link. A custom header is something neither a plain <img> tag nor an unfurling bot can send; only the SPA's own JavaScript can, so only a real visit through the app can ever count as the view.
What it cost
The image can't be hotlinked or cached the way a normal <img src> URL would be, and the frontend carries a small amount of extra plumbing: a fetch, a blob, an object URL, and remembering to revoke it, in place of one HTML attribute.
Варианты
Направить <img src> прямо на API, как обычный URL изображения · закрыть эндпоинт изображения кастомным заголовком, который отправляет только собственная fetch-логика SPA, и отрендерить результат как blob object URL.
Выбрано
Закрытый fetch. Фронтенд запрашивает изображение с кастомным заголовком, получает его как blob и рендерит через object URL, никогда голый <img src>, указывающий на API.
Почему
Та же проблема, что двухтаймерной модели уже приходилось решать для текста: бот превью ссылок мессенджера запрашивает URL раньше любого человека, и этот запрос незаметно засчитывается как просмотр. Она острее для прямого <img src>, поскольку браузеры и боты превью запрашивают URL изображений автоматически и охотно, без способа отличить это от реального открытия ссылки получателем. Кастомный заголовок относится к тому, что не может отправить ни голый тег <img>, ни разворачивающий бот; может только собственный JavaScript SPA, так что просмотром может засчитаться только реальный визит через приложение.
Цена
Изображение нельзя захотлинковать или закешировать так, как обычный URL <img src>, а фронтенд несёт немного лишней сантехники: fetch, blob, object URL и необходимость не забыть его отозвать, вместо одного HTML-атрибута.

05 · Data model

05 · Модель данных

One table is the whole truth

pastes(slug PK, kind, content, image, created_at, last_viewed_at). Expiry is computed from the two timestamps at read time, never stored: a nullable last_viewed_at is what distinguishes the "never opened" branch of the timer from the "viewed" branch. Storing a precomputed expires_at would have meant updating it on every view anyway; deriving it keeps one source of truth.

kind and image arrived as an additive migration: new nullable columns, no rewrite of existing rows, no change to a single line in the text-paste path. The migration runner tracks progress against SQLite's own user_version pragma and applies anything pending automatically at server startup, so shipping the schema change was the same deploy as shipping the code that uses it.

Одна таблица, вся правда

pastes(slug PK, kind, content, image, created_at, last_viewed_at). Срок истечения вычисляется из двух таймстемпов при чтении и нигде не хранится: nullable-поле last_viewed_at и есть развилка между ветками «не открывали» и «просмотрено». Хранение готового expires_at всё равно потребовало бы обновления при каждом просмотре; вычисление оставляет один источник истины.

kind и image появились аддитивной миграцией: новые nullable-колонки, без перезаписи существующих строк, без изменения хоть одной строки в пути только для текста. Раннер миграций отслеживает прогресс через встроенную SQLite-прагму user_version и применяет всё ожидающее автоматически при старте сервера, так что поставка изменения схемы оказалась тем же деплоем, что и поставка кода, который её использует.

06 · Hard problems

06 · Трудные места

What counts as a "view"?

The two-timer model rests on one word, viewed, and that word turned out to be load-bearing. Paste a link into a messenger and the messenger's link-preview bot fetches the URL before any human does. If a bot fetch counts as a view, it silently resets (or effectively consumes) the recipient's timer. The fix is definitional, not technical: a "view" is the API call the SPA makes for content, not a hit on the HTML shell, and preview bots never execute the SPA. The same problem reappeared, sharper, when images shipped; see the same-origin retrieval decision above, since a direct image URL can't be defended definitionally the way a text hit can.

The expiry race

A read arriving in the same instant the sweep deletes the row is a classic check-then-act race. Resolved by making the read path authoritative: it evaluates expiry itself and returns 404 for a stale row even if the sweep hasn't collected it yet. The sweep is garbage collection, not the enforcement mechanism.

Что считается «просмотром»?

Двухтаймерная модель держится на одном слове, просмотр, и это слово оказалось несущим. Вставьте ссылку в мессенджер, и бот превью ссылок запросит URL раньше любого человека. Если запрос бота считается просмотром, он незаметно сбрасывает (а по сути расходует) таймер получателя. Решение определением, а не технологией: просмотром считается API-запрос контента из SPA, а не обращение к HTML-оболочке; боты превью SPA не исполняют. Та же проблема вернулась острее с появлением изображений; см. решение о same-origin получении выше, поскольку прямой URL изображения нельзя защитить определением так, как текстовое обращение.

Гонка на истечении

Чтение, приходящее в тот же момент, когда зачистка удаляет строку, представляет собой классическую гонку check-then-act. Решено тем, что путь чтения сделан авторитетным: он сам вычисляет истечение и возвращает 404 для просроченной строки, даже если зачистка её ещё не собрала. Зачистка выполняет роль сборки мусора, а не механизма гарантии.

07 · Verification

07 · Верификация

The NFRs were tested, not assumed

  • Timed curl sequences against production confirmed both timer branches: a never-viewed paste returns 404 shortly after t+5:00; a paste polled continuously survives, then dies five minutes after the last poll.
  • Unrecoverability checked at the storage layer: after expiry, the row is absent from the SQLite file: deleted, not soft-flagged. For an image paste specifically, the file was inspected post-expiry for any recoverable trace of the image bytes with secure_delete on versus off, confirming the pragma is actually doing the job it was added for.
  • The read-path-authoritative rule verified by querying a paste in the window between logical expiry and the next sweep: 404, as designed.
  • Magic-byte validation checked by uploading a non-PNG file with a forged Content-Type: image/png header and confirming rejection; the header alone was never enough to pass.
  • The retrieval gate checked by requesting an image URL the way a browser's own <img> tag or a link-preview bot would, with no custom header, and confirming it's refused, then confirming the same request succeeds with the header the SPA actually sends.

NFR проверены, а не предположены

  • Хронометрированные последовательности curl к продакшену подтвердили обе ветки таймера: непросмотренная паста отдаёт 404 вскоре после t+5:00; паста под непрерывным опросом живёт, и умирает через пять минут после последнего запроса.
  • Невосстановимость проверена на уровне хранилища: после истечения строка отсутствует в файле SQLite: удалена, а не помечена. Для пасты с изображением файл отдельно осмотрен после истечения на предмет восстановимого следа байтов изображения с secure_delete включённым и выключенным, подтверждая, что прагма действительно делает то, ради чего добавлена.
  • Правило «путь чтения авторитетен» проверено запросом в окне между логическим истечением и следующей зачисткой: 404, как и спроектировано.
  • Проверка магических байтов протестирована загрузкой не-PNG файла с подделанным заголовком Content-Type: image/png и подтверждением отказа; одного заголовка оказалось недостаточно, чтобы пройти.
  • Шлюз получения проверен запросом URL изображения так, как это сделал бы собственный тег <img> браузера или бот превью ссылок, без кастомного заголовка, с подтверждением отказа, затем подтверждением, что тот же запрос проходит с заголовком, который реально отправляет SPA.

08 · Retrospective

08 · Ретроспектива

If I rebuilt it

  • Rate limiting and size caps would come before slug design, not after. With no accounts, they are the entire abuse-control story, and I underweighted that.
  • Client-side encryption with the key in the URL fragment would make the server zero-knowledge: the natural v2, and it composes cleanly with the existing model since the server never needs to read content.
  • The two-timer semantics deserve a visible countdown in the UI. A guarantee the user can't see is a guarantee they don't trust.
  • Full ADR ceremony on a weekend project felt heavy in the moment. It paid for itself the day I wrote this page.
  • The image size cap moved from 1 MB to 5 MB mid-implementation, a deliberate call at the time, but the rate-limiting numbers were sized against the smaller figure and haven't been revisited since. That's an open item, not a settled one: a 5 MB ceiling times whatever the current request rate allows is a bigger abuse surface than the one the original limits were built for.

Если бы строил заново

  • Rate limiting и лимиты размера шли бы до дизайна слагов, а не после. Без аккаунтов они составляют весь контроль злоупотреблений, и я недооценил это.
  • Шифрование на клиенте с ключом во фрагменте URL сделало бы сервер zero-knowledge: естественная v2, чисто состыкуется с текущей моделью, поскольку серверу и так не нужно читать контент.
  • Двухтаймерная семантика заслуживает видимого обратного отсчёта в UI. Гарантия, которую пользователь не видит, это гарантия, которой он не верит.
  • Полный церемониал ADR на проекте «на выходные» казался избыточным. Он окупился в день, когда я писал эту страницу.
  • Лимит размера изображения поднялся с 1 МБ до 5 МБ по ходу реализации, осознанное решение на тот момент, но цифры rate limiting были рассчитаны под меньшую величину и с тех пор не пересматривались. Это открытый вопрос, а не закрытый: потолок в 5 МБ, помноженный на то, что позволяет текущая частота запросов, представляет собой большую поверхность для злоупотреблений, чем та, под которую строились исходные лимиты.