From Readest Fork to a Private E-Book Reader: A Migration Journal
Table of Contents
- The Starting Point
- The Vision
- Forking and Stripping Down
- Supabase and Auth
- Storage on Object Storage
- Sharing for a Limited Time
- Removing Third-Party Services
- Going to Production
- Comics: from CBR to CBZ
- Gotchas and Lessons Learned
- 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:
| Technology | Role |
|---|---|
| Next.js 16 (App Router) | Web app and API routes |
| Supabase | Authentication and Postgres database |
| Hetzner Object Storage | Book files (S3-compatible) |
| A small Linux server | Next.js behind Nginx, run as a system service |
2. The Vision
Three requirements shaped everything:
- Authentication. Nobody can open the app without an account, and nobody can create one on their own. I invite people.
- 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.
- 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:
| Question | Decision | Why |
|---|---|---|
| Where do the files live? | S3-compatible object storage | Presigned links give time-limited access without a proxy. Google Drive and similar services have no usable server API for that. |
| Backend | Supabase Cloud | Auth, Postgres and row-level security I already know. |
| Platforms | Web only, as a PWA | The reader needs a DOM, so it does not run natively in React Native. A WebView shell can come later. |
| Sharing | Bound to an account first | Simple 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:
- Is the caller the recipient of this grant? If not, answer 404, as if it did not exist.
- Is the grant revoked or expired? Answer 403.
- 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
bsdtarcannot read solid RAR archives, and the 7-Zip from Homebrew has no RAR codec.unarhandles both. unarcannot open files directly on some external volumes, so each archive is copied to the local disk first.- My page count check counted
Thumbs.dbentries 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
| Area | Gotcha | Fix |
|---|---|---|
| Cleanup | Deleting a module breaks users you did not know about | Delete, run tsc, fix, repeat |
| Cleanup | Regex that ends a block at the first ; cuts interfaces | Remove by indentation or markers |
| Tests | 38 tests fail in the environment | Compare with the pristine upstream first |
| Storage | The AWS SDK adds checksums other stores reject | requestChecksumCalculation: 'WHEN_REQUIRED' |
| Storage | Two uploads can pass the quota check together | Reserve first, then sum |
| Sharing | A time limit is useless if the copy can leave | Block sync, upload, export, backup on the client and the server |
| Deploy | next build needs more memory than the server has | Swap and skip the second type check |
| Deploy | pnpm's .bin/next is a shell script | Call next/dist/bin/next |
| Deploy | Client-side redirect left a blank page in production | Redirect with a full page load |
| Browser | One library per browser, not per account | Record the owner, clear on account change |
| Comics | Solid RAR, external volume, capital extensions | unar, local copy, case-insensitive match |
| Comics | getAll() on the whole file store | Key ranges and cursors |
11. Where We Are Now
What's Done
| Part | Status | Key outcome |
|---|---|---|
| Fork and cleanup | Done | Web only, builds, no new test failures |
| Supabase and auth | Done | Invitation only, row-level security tested |
| Object storage | Done | Direct uploads, quota without a race |
| Time-limited sharing | Done | Grants, revocation, local expiry, server checks |
| Third-party services | Removed | One sync target left |
| Production | Done | Running 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.