Заметки в своём GitHub без сервера: как я оживил BatNoter и написал синхронизацию на Git Data API

в 13:19, , рубрики: codemirror, git data api, github api, indexeddb, markdown, open source, pwa, заметки, синхронизация

Мне давно нравится идея хранить заметки обычными Markdown‑файлами в своём репозитории на GitHub. Файлы остаются твоими, их можно открыть на github.com или в любом редакторе, а история изменений достаётся даром, это просто коммиты.

Был проект, который делал ровно это: BatNoter, 2,4 тысячи звёзд на GitHub. Веб‑приложение на React, заметки в вашем репозитории. Но у него был свой сервер на Go: через него шёл вход по OAuth и все запросы к GitHub. В конце 2022 года сервер batnoter.com перестал отвечать. В issue “Log in with github error” люди до сих пор спрашивают, есть ли решение.

Я переписал приложение так, чтобы сервер был не нужен вовсе. Получился Notewing: https://perruer.github.io/notewing/ (MIT, код на GitHub). В статье расскажу, как устроена синхронизация без бэкенда, где были подводные камни и как всё это тестировать, не трогая настоящий GitHub.

Notewing

Notewing

Почему сервер не нужен

Сервер BatNoter решал две задачи:

  1. OAuth. Для обмена кода авторизации на токен нужен client_secret, а секрет в браузере не спрячешь.

  2. Прокси к API. Он брал токен из своей базы и ходил в GitHub от имени пользователя.

Вторая задача в браузере решается сама: api.github.com отдаёт CORS‑заголовки, и fetch из любого сайта с заголовком Authorization работает.

С первой сложнее. У GitHub есть device flow, где секрет не нужен, но эндпоинт github.com/login/device/code CORS не поддерживает. Из обычной веб‑страницы его не вызвать.

Я выбрал fine‑grained personal access token. Пользователь создаёт токен сам и даёт ему доступ ровно к одному репозиторию с правом Contents: Read and write. По сравнению с OAuth‑прокси это даже безопаснее:

  • токен никогда не покидает браузер и уходит только на api.github.com;

  • он не видит остальные репозитории;

  • его можно отозвать в один клик на GitHub.

Цена — лишний шаг при первом входе. Чтобы шаг был короче, ссылка на создание токена сразу заполняет название и описание.

Модель данных: снимок и очередь

Главная идея: приложение ничего не пишет в GitHub сразу. Любая правка сначала попадает в локальную очередь в IndexedDB. Поэтому:

  • интерфейс не ждёт сеть;

  • без интернета можно спокойно работать;

  • перезагрузка страницы ничего не теряет.

Локально хранятся две структуры:

interface Snapshot {
  head: string | null;            // коммит, на котором мы стоим
  tree: string | null;            // его дерево
  files: Record<string, string>;  // путь -> sha блоба
}

interface Change {
  text?: string;       // новый текст заметки
  data?: string;       // двоичный файл (картинка), base64
  blob?: string;       // уже существующий блоб (для переноса)
  del?: true;          // удаление
  base: string | null; // sha файла, когда правка началась
  rev: number;         // номер правки
}

Рабочая копия — это snapshot.files, поверх которого наложена очередь pending: Record<path, Change>.

Поле base — ключ ко всему остальному: оно запоминает, от какой версии файла мы отталкивались.

Небольшая, но приятная деталь: sha блоба git можно посчитать прямо в браузере. Это SHA-1 от строки blob <длина><содержимое>:

export async function blobSha(data: string | Uint8Array): Promise<string> {
  const body = typeof data === "string" ? utf8(data) : data;
  const head = utf8(`blob ${body.length}`);
  const all = new Uint8Array(head.length + body.length);
  all.set(head);
  all.set(body, head.length);
  const digest = await crypto.subtle.digest("SHA-1", all);
  return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}

Благодаря этому, если пользователь напечатал текст, а потом вернул его к исходному, правка просто исчезает из очереди: sha совпал с base. Лишнего коммита «ничего не изменил» не будет.

Отправка: один коммит через Git Data API

Самый очевидный способ писать в репозиторий — Contents API (PUT /repos/{owner}/{repo}/contents/{path}). Но каждый вызов этого метода делает отдельный коммит. Переименование заметки превращается в два коммита, «создать» и «удалить», а перенос папки с двадцатью заметками — в сорок.

Поэтому Notewing работает на уровень ниже, через Git Data API, как это делает сам git:

  1. POST /git/trees с base_tree — новое дерево. Текстовые файлы передаются прямо полем content, удаления — как sha: null, картинки сначала загружаются через POST /git/blobs.

  2. POST /git/commits — коммит с этим деревом и родителем head.

  3. PATCH /git/refs/heads/main с force: false — передвинуть ветку.

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

for (const [path, c] of batch) {
  if (c.del) entries.push({ path, mode: "100644", type: "blob", sha: null });
  else if (c.text !== undefined) entries.push({ path, mode: "100644", type: "blob", content: c.text });
  else if (c.data !== undefined) entries.push({ path, mode: "100644", type: "blob", sha: await gh.createBlob(ref, c.data) });
  else entries.push({ path, mode: "100644", type: "blob", sha: c.blob! }); // перенос
}
const tree = await gh.createTree(ref, snapshot.tree, entries);
const commit = await gh.createCommit(ref, message(batch), tree, [snapshot.head]);
await gh.updateRef(ref, commit); // force: false

force: false — это наша защита от гонок. Если с другого устройства в ветку успели что‑то закоммитить, GitHub ответит 422 Update is not a fast forward. Тогда мы подтягиваем свежий снимок, перебазируем очередь и пробуем ещё раз.

Перебазирование и конфликты

При подтягивании новой версии для каждой правки в очереди сравниваются три sha:

  • base — версия, от которой мы начали;

  • now — что сейчас на GitHub;

  • mine — что получится после нашей правки.

for (const [path, c] of Object.entries(this.pending)) {
  const now = remote[path] ?? null;
  if (now === c.base) continue;                        // там файл не трогали
  if (now === await this.targetSha(c)) { drop(path); continue; } // обе стороны пришли к одному
  if (c.del) { drop(path); continue; }                 // мы удалили, там изменили: оставляем их версию
  if (now === null) { c.base = null; continue; }       // там удалили, мы изменили: создаём заново
  // изменили с обеих сторон
  saveAsConflictCopy(path, c);                         // "Plan (conflict 2026-09-22 1530).md"
}

Я сознательно не стал делать трёхстороннее слияние текста. Для заметок самый понятный вариант такой: путь остаётся за удалённой версией, а ваш текст сохраняется рядом копией. Пользователь получает уведомление с кнопкой «Открыть». Ничего не теряется и ничего не склеивается молча.

Отдельно пришлось обработать правки, которые пользователь сделал во время отправки. Для этого и нужен rev: после успешного коммита из очереди убираются только те записи, чей rev не изменился. У остальных просто обновляется base.

Подводный камень: пустой репозиторий

Пока всё было проверено на репозитории с README, всё работало. На пустом репозитории всё сломалось: Git Data API отвечает 409 Git Repository is empty, причём и на создание блоба, и на создание дерева.

Решение — первый файл отправляется старым Contents API, который умеет создавать первый коммит. Всё остальное уходит следующим циклом через Git Data API.

Подводный камень: управляемый редактор

Редактор — CodeMirror 6. Сначала я подключил его как обычный управляемый компонент: есть value, есть onChange, а если value снаружи отличается от документа, документ заменяется. Это дало две ошибки, одна другой хуже.

Первая. При быстром наборе редактор зависал намертво. Пользователь набирает «a», потом «b». Документ уже …ab, а компонент ещё не перерисовался и передаёт старое value = …a. Редактор «исправляет» документ обратно на …a, это вызывает onChange, и два состояния начинают бесконечно гонять друг друга.

Вторая, коварнее. Любая программная замена документа тоже вызывала onChange и считалась правкой пользователя. Если в момент открытия заметки приходила свежая версия с GitHub, старый текст мог уйти в очередь как «изменение» и затереть её.

Обе ошибки поймали E2E‑тесты, и лечение у них одно: внешние изменения передаются в редактор не значением, а явной ревизией, и помечаются аннотацией.

const external = Annotation.define<boolean>();

// в редакторе: вызываем onChange только для правок пользователя
EditorView.updateListener.of((u) => {
  if (u.docChanged && u.transactions.some((tr) => !tr.annotation(external))) onChange(u.state.doc.toString());
});

// снаружи пришёл новый текст (обновление с GitHub, галочка в просмотре, восстановленная версия)
useEffect(() => {
  view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value }, annotations: external.of(true) });
}, [revision]);

Картинки из приватного репозитория

Скриншот можно вставить в заметку из буфера обмена. Он сохраняется в assets/ в репозитории, а в текст вставляется относительная ссылка ![](../assets/…png). Благодаря этому картинка видна и на github.com.

А вот показать её в предпросмотре не так просто: для приватного репозитория raw.githubusercontent.com без авторизации отдаст 404. Поэтому предпросмотр находит относительные src, загружает файл через API с токеном (или из локальной очереди, если картинку ещё не отправили) и подставляет blob: URL.

Безопасность: токен на странице

Раз токен живёт в браузере, любой XSS превращается в утечку токена. А заметки — это произвольный HTML из репозитория. Поэтому:

  • весь отрендеренный Markdown проходит через DOMPurify. Разрешены чекбоксы задач, внешние ссылки получают rel="noopener noreferrer";

  • в собранной версии стоит строгая CSP: script-src 'self', connect-src https://api.github.com и никаких сторонних доменов.

Даже если санитайзер что‑то пропустит, отправить токен на чужой сервер скрипт не сможет. В E2E есть отдельный тест, который кладёт в заметку <img onerror>, <script> и javascript:‑ссылку и проверяет, что ничего не выполнилось.

Офлайн

Три слоя:

  • IndexedDB — снимок репозитория, тексты заметок (по sha блоба, поэтому неизменные файлы не перекачиваются) и очередь правок;

  • service worker — кеширует оболочку приложения, в том числе при размещении в подпапке. Поэтому установленное PWA стартует без сети;

  • фоновая загрузка — после синхронизации в фоне докачиваются тексты всех заметок, чтобы работал поиск.

Интерфейс в это время перерисовывается пачками, а не на каждую заметку. На репозитории из тысячи файлов иначе всё тормозит.

Как это тестировать без GitHub

Больше всего времени сэкономило одно решение: я написал FakeGitHub — эмуляцию нужной части GitHub REST API в памяти. Она умеет:

  • блобы, деревья, коммиты и ветки;

  • ответ 422 на не fast‑forward;

  • 409 на пустом репозитории;

  • историю файла.

Эта эмуляция используется в трёх местах:

  1. Юнит‑тесты движка синхронизации: конфликты, гонка при отправке, правки во время отправки, офлайн с перезагрузкой, пустой репозиторий.

  2. E2E‑тесты в Chrome и Firefox. Puppeteer перехватывает запросы к api.github.com и отвечает из той же эмуляции. В Firefox (WebDriver BiDi) пришлось брать тело запроса через fetchPostData(): синхронный postData() там не поддерживается.

  3. Демо‑режим на сайте. Кнопка «Попробовать демо» запускает приложение на той же эмуляции прямо в странице, без токена и без сети.

Плюс отдельный живой тест, который прогоняет те же сценарии на настоящем GitHub в одноразовом репозитории.

Что получилось

  • Заметки — обычные .md файлы, репозиторий из BatNoter подходит как есть.

  • Работает офлайн, ставится как приложение.

  • Поиск (Ctrl+K), картинки, история версий с восстановлением, перетаскивание, тёмная тема, интерфейс на русском, английском и китайском.

  • Нет сервера, нет аналитики, нет аккаунта. Для размещения у себя достаточно распаковать архив на любой статический хостинг.

Демо без регистрации: https://perruer.github.io/notewing/ Код: https://github.com/Perruer/notewing

Чего пока нет: GitLab и Gitea (их просят в issues BatNoter), шифрование заметок. Если бы вам это пригодилось, напишите в комментариях, какой вариант нужнее.

Проект открытый и бесплатный. Если он вам пригодится, поддержать разработку можно на Boosty.

Автор: Perruer

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js