From Readest Fork to a Private E-Book Reader: A Migration Journal

developmentlearningnextjssupabasedocs

Table of Contents

  1. The Starting Point
  2. The Vision
  3. Forking and Stripping Down
  4. Supabase and Auth
  5. Storage on Object Storage
  6. Sharing for a Limited Time
  7. Removing Third-Party Services
  8. Going to Production
  9. Comics: from CBR to CBZ
  10. Gotchas and Lessons Learned
  11. Where We Are Now

1. The Starting Point

I wanted one place for my own books and comics that I could read from any device, without a subscription and without handing my library to a service. The Readest project is close to what I wanted: a good reading engine, a library, highlights and notes, and a sync model. It is open source under the AGPL license.

It is also a lot more than I need. Readest ships as a desktop app, as mobile apps and as a web app, with payments, analytics, a dozen third-party integrations and a hosted backend that belongs to its authors.

The plan, in one sentence: take the reading engine and the library, put it on my own stack, and remove everything else.

The stack I already run for other projects:

TechnologyRole
Next.js 16 (App Router)Web app and API routes
SupabaseAuthentication and Postgres database
Hetzner Object StorageBook files (S3-compatible)
A small Linux serverNext.js behind Nginx, run as a system service

2. The Vision

Three requirements shaped everything:

  1. Authentication. Nobody can open the app without an account, and nobody can create one on their own. I invite people.
  2. Sharing for a limited time. A single book can be given to another signed-in user for a few days. Later, users should be able to share with each other, not only with me.
  3. Cheap and private storage. Book files do not belong on a free cloud drive with unclear terms.
        +------------------+
        |  Supabase Auth   |
        |  (invitations)   |
        +---------+--------+
                  |
   +--------------+---------------+
   |      Next.js web app         |
   |  library, reader, API routes |
   +------+--------------+--------+
          |              |
  +-------+----+   +-----+---------------+
  | Postgres   |   | Object storage (S3) |
  | rows + RLS |   | book files, private |
  +------------+   +---------------------+

The decisions I made up front, with the reason:

QuestionDecisionWhy
Where do the files live?S3-compatible object storagePresigned links give time-limited access without a proxy. Google Drive and similar services have no usable server API for that.
BackendSupabase CloudAuth, Postgres and row-level security I already know.
PlatformsWeb only, as a PWAThe reader needs a DOM, so it does not run natively in React Native. A WebView shell can come later.
SharingBound to an account firstSimple to secure; user-to-user sharing needs no schema change later.

3. Forking and Stripping Down

A copy, not a fork

I did not keep a link to the upstream repository. The code diverges quickly, and patching merge conflicts for a project I change this much would cost more than copying fixes by hand. The new repository starts with the Readest snapshot, keeps the license and copyright notices, and says in its README that it is a modified version.

The workspace

The project became a pnpm workspace:

cognoscimus/
  apps/web/          the Next.js app
  packages/          vendored libraries
                     (foliate-js reading engine, dictionary code, ...)
  supabase/          SQL migrations

The reading engine (foliate-js) used to be a Git submodule. I copied it into packages/ so the repository builds from a plain clone.

What went

Everything that talks to something I do not run:

  • the native shells (Tauri, Swift, Android and iOS code) and their plugins
  • Stripe and in-app purchases, subscription pages
  • analytics and error reporting
  • Cloudflare workers and the Docker setup
  • the hosted backend defaults, including Supabase keys that were baked into the source as defaults

Removing things in a codebase of half a million lines is mostly a loop:

delete a module  ->  run tsc  ->  the errors list the remaining users
                 ->  fix them ->  run the tests  ->  repeat

Over-deleting

Twice I removed something a different feature needed. A text-to-speech client I deleted still exported a type used by the rest of the player. A shared proxy helper turned out to serve the RSS feeds as well. Both times tsc caught it, and I restored the small part that was needed.

The test baseline

After the cleanup, 38 tests failed. Before panicking I ran the same tests on a pristine copy of Readest: the same 38 failed there. They read ZIP archives and fail in this test environment. From then on the rule was simple: no new failures beyond those 38.


4. Supabase and Auth

The schema

The original schema has many tables for features I removed. I rebuilt it as numbered migrations that describe only what the app uses: books, book configurations, notes, files, reading statistics, and the sync tables. Every table has row-level security, so a user sees only their own rows:

ALTER TABLE public.books ENABLE ROW LEVEL SECURITY;

CREATE POLICY select_books ON public.books
  FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
CREATE POLICY insert_books ON public.books
  FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = user_id);

To test this properly I started a throwaway Postgres in Docker, applied the migrations and ran assertions: user A cannot read, write or update user B's rows; the anonymous role sees nothing; the files table cannot be written from the API at all.

Invitation only

Sign-ups are switched off in the Supabase dashboard. Users are invited. The app wraps every page in a small gate that sends anyone without a session to the sign-in page:

const blocked = mounted && !user && !isPublicPath(window.location.pathname);

The sign-in page offers email and password, a magic link, and a password reset. The redirect target after signing in is validated so it can only be a path on the site.

An admin without a role system

For the few admin actions I use a pattern from another project: a table of admin emails that nobody can read through the API, and a function that compares it with the email in the caller's token.

CREATE FUNCTION public.is_admin() RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
  SELECT EXISTS (
    SELECT 1 FROM public.admin_emails
    WHERE lower(email) = lower(coalesce(auth.jwt() ->> 'email', ''))
  );
$$;

5. Storage on Object Storage

The browser uploads and downloads book files directly to and from the bucket, using presigned URLs that the server hands out after checking who is asking.

 Browser                Next.js API              Object storage
    |  POST /storage/upload   |                        |
    |------------------------>| check user + quota     |
    |                         | sign a PUT url         |
    |<------------------------| (valid 30 min)         |
    |  PUT file  ------------------------------------->|

Quota without a race

The first version checked the quota, then reserved the space. Two uploads started at once could both pass the check. The fix is to turn the order around: insert the files row first, as a reservation, then sum up all rows, and delete the new row again if the total is over the limit. The sum is a SQL function that only the server may call.

Gotchas with a non-AWS store

  • The AWS SDK now adds CRC32 checksums to every request. Other S3 services reject them or sign them wrongly. Setting requestChecksumCalculation: 'WHEN_REQUIRED' fixes it.
  • Use path-style addressing (forcePathStyle: true).
  • A browser upload to the bucket needs a CORS rule for the app's origin. I wrote a small script that sets it, so it is repeatable.
  • A file name from the client becomes part of the object key, so it is checked against path traversal before it is used.

6. Sharing for a Limited Time

The original app had anonymous share links. That does not fit a private reader with logins, so I replaced the feature with grants: one row per owner, recipient and book.

CREATE TABLE public.book_grants (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  owner_id   uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE,
  grantee_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE,
  book_hash  text NOT NULL,
  expires_at timestamptz NOT NULL,
  revoked_at timestamptz NULL,
  UNIQUE (owner_id, grantee_id, book_hash),
  CHECK (owner_id <> grantee_id)
);

All writes go through the server, which checks ownership. The recipient can read only grants that are not revoked and not expired. Sharing the same book with the same person again simply renews the row.

Checking access on the server

The download route is where the rules live:

  1. Is the caller the recipient of this grant? If not, answer 404, as if it did not exist.
  2. Is the grant revoked or expired? Answer 403.
  3. Find the owner's file and sign a download link valid for five minutes.

What the recipient keeps

A time limit means nothing if the recipient can copy the book away, so a shared book is treated differently on the device:

  • it is tagged as shared and never synced or uploaded
  • download, upload, share and export are hidden, and the code behind them refuses as well
  • it is left out of backups and pack files
  • when the library loads, books whose grant has ended are removed; the expiry is checked locally, so it also works offline. Online, grants that were revoked early are removed too

One limit stays: a copy that is already on the device can be read until the end. Taking it back offline is not possible.

A second line of defense

During testing, entries for a shared book showed up in the recipient's own cloud records. I found out how it happened only partly, so I added a rule on the server instead: when records are synced, drop everything that belongs to a book the user only holds through a grant. It no longer matters whether a client tags the book correctly.


7. Removing Third-Party Services

At the end I went through the integrations one by one: Pagebound, a local network sender, file sync with WebDAV, Google Drive, OneDrive, iCloud and S3, and remains of Readwise, Hardcover and KOSync. I removed them all. If I need one later I can rebuild it on its own.

The hard part was the sync layer. It had an abstraction over several providers. I replaced it with a file that says only whether Cognoscimus Cloud sync is on. The settings page that listed the providers became a single switch. Along the way I also removed WordLens, a reading aid whose word lists were downloaded from a server that no longer exists, and the service worker I did not need.

A trap while scripting the cleanup: a regular expression that ended a declaration at the first ; cut interfaces in half. Remove blocks by their indentation or by fixed markers, and run the type checker after each step.


8. Going to Production

The app runs as a Node server behind Nginx, with HTTPS from Let's Encrypt. The repository only holds a generic deploy.sh (pull, install, build); anything specific to my server lives in my private notes.

A few things I had to find out:

The build needs a lot of memory. On my laptop the production build used 5.1 GB at its peak. The server has 4 GB. Fixes: add swap, and skip the second TypeScript pass of next build on the server (pnpm lint already type-checks before every push).

The service did not start. With pnpm, node_modules/.bin/next is a shell script, not JavaScript. The unit must call node_modules/next/dist/bin/next.

A blank page after redirecting to sign-in. In production, a client-side route change from a protected page to /auth left an empty page until a manual refresh. In development it worked. The gate now redirects with a full page load.

A build without the right environment. The env file belonged to the wrong user, so the build ran without any values. Variables that start with NEXT_PUBLIC_ are baked in at build time, so such a build is wrong, not just incomplete.

Two browsers, one library. The library lives in the browser, not in the account. When a second account signed in on the same browser, the first account's books were pushed to the second account's cloud. The app now records whose library it holds and clears it when a different user signs in.


9. Comics: from CBR to CBZ

My comic collection is 932 .cbr files, which are RAR archives. A browser can only open ZIP, and adding a RAR decoder to the page brings licensing trouble. The better path is converting them once. The pages are JPG files that are already compressed, so they can be repacked without any recompression.

A small script does it: it copies each archive to a temporary folder, extracts it, writes a ZIP with the pages stored as they are, checks that the page count matches and that the ZIP is intact, and only then keeps the result. It never touches the source folder and can be restarted.

Things that did not work on the first try:

  • macOS bsdtar cannot read solid RAR archives, and the 7-Zip from Homebrew has no RAR codec. unar handles both.
  • unar cannot open files directly on some external volumes, so each archive is copied to the local disk first.
  • My page count check counted Thumbs.db entries as missing pages. They are left out on purpose, so they must not be counted.
  • Two comics failed to open with "No supported image files in archive". Their pages were named 001.JPG, in capitals, and the comic loader compared extensions case-sensitively. A one-line fix, plus a test.

The tab that used 12 GB

After uploading the comics, the browser tab crashed ("Error code: 5") a few seconds after opening any comic. The task manager showed 7 to 12 GB for a 48 MB book, before turning a single page. A private window, with an empty storage, used 500 MB.

The cause was a function from the original code that removes a folder from the browser's file store. It listed the folder by reading the whole store at once:

const request = store.getAll();   // every stored book, in memory

The reader called it while opening a book to clean up an old cache folder. With a few dozen books stored locally, that was gigabytes. The fix deletes by key range, and the directory listing walks the store with a cursor:

store.delete(fp);
store.delete(IDBKeyRange.bound(prefix, `${prefix}￿`));

The test uses a fake storage that throws as soon as anything asks for the whole store. Lesson: a bug that appears only with real data can hide for years, and a private window is a good way to tell state from code.


10. Gotchas and Lessons Learned

AreaGotchaFix
CleanupDeleting a module breaks users you did not know aboutDelete, run tsc, fix, repeat
CleanupRegex that ends a block at the first ; cuts interfacesRemove by indentation or markers
Tests38 tests fail in the environmentCompare with the pristine upstream first
StorageThe AWS SDK adds checksums other stores rejectrequestChecksumCalculation: 'WHEN_REQUIRED'
StorageTwo uploads can pass the quota check togetherReserve first, then sum
SharingA time limit is useless if the copy can leaveBlock sync, upload, export, backup on the client and the server
Deploynext build needs more memory than the server hasSwap and skip the second type check
Deploypnpm's .bin/next is a shell scriptCall next/dist/bin/next
DeployClient-side redirect left a blank page in productionRedirect with a full page load
BrowserOne library per browser, not per accountRecord the owner, clear on account change
ComicsSolid RAR, external volume, capital extensionsunar, local copy, case-insensitive match
ComicsgetAll() on the whole file storeKey ranges and cursors

11. Where We Are Now

What's Done

PartStatusKey outcome
Fork and cleanupDoneWeb only, builds, no new test failures
Supabase and authDoneInvitation only, row-level security tested
Object storageDoneDirect uploads, quota without a race
Time-limited sharingDoneGrants, revocation, local expiry, server checks
Third-party servicesRemovedOne sync target left
ProductionDoneRunning behind HTTPS, comics converted

What's Next

  • Sharing between users without me as the owner (the schema allows it)
  • Maybe an offline mode for reading on trips
  • Rebuild only the integrations I actually miss, one at a time

Current Project Structure

cognoscimus/
  apps/web/
    src/
      app/              pages and API routes (grants, library, reader)
      libs/             grants, storage, local ownership
      services/         library, sync, books
    scripts/            storage CORS setup
  packages/             foliate-js and other vendored code
  supabase/
    migrations/         0001 core ... 0007 book_grants
  deploy.sh             pull, install, build

The source is on GitHub, and the project page has the feature list.