MyBooks

A self-hosted catalog for a DRM-free ebook library. It indexes your files where they already live rather than asking you to upload them again, and it lends them to family with no per-title fees and no loan caps.

Services
6 plus a shared library
Commits
315
Roles
7 tested as a matrix
Developer
1
Role
Sole developer, architecture through QA
Status
On hold
Stack
.NET 9, ASP.NET Core, Angular 19, EF Core, SQL Server, Playwright, Docker
Source
Public on GitHub

The problem

Plenty of people end up with a real library of DRM-free books. Some bought direct from authors or indie stores, some pulled from public-domain archives, a pile of technical PDFs, a few things they wrote themselves. What they don't have is anywhere sensible to keep it. It's a folder, and the folder is not a library.

Most applications that will organize it for you want you to upload everything first, which means paying to store a second copy of files you already have. Sharing a book with your family then runs on whatever loan rules the host decided on. MyBooks does the cataloging without the custody.

What I built

Six ASP.NET Core services behind a reverse proxy, with an Angular front end and a shared common library.

ServiceResponsibility
AuthServiceAuthentication, users, roles, invitations, support impersonation
CatalogServiceBooks, genres, series, tags, age ratings, metadata lookup
FileServiceUploads, downloads, Drive integration, bulk import, reading progress
TenantServiceTenant signup and provisioning, billing plans
EmailServiceTransactional mail
SupportServiceImpersonation and abuse-report audit logs

Services authenticate to each other with short-lived system tokens issued by AuthService and validated against a shared per-service secret. No service trusts a caller just because of where it sits on the network. Multi-tenant data isolation runs through Entity Framework query filters rather than trusting individual queries to remember.

I'd point at the security-adjacent pieces first. JWT auth with BCrypt password hashing, ClamAV malware scanning on every upload, FluentValidation on request models, and HtmlSanitizer on anything a user supplies. There are seven roles. Support staff can impersonate a tenant user to reproduce an issue, and every impersonation gets written to an audit log.

The decision I would defend

The core promise of the application is that your files stay where they already are. That makes third-party storage permissions load-bearing rather than incidental, and it turned out to be the wrong foundation.

The original design assumed the app could read a user's Drive folder on their behalf. That assumption failed in stages.

First Google reclassified drive.readonly as a restricted scope, which requires a paid annual third-party security assessment for any app storing the data server-side. Then folder selection stopped cascading, so under the non-sensitive drive.file scope, picking a folder grants access to the folder object and not the files inside it. That breaks any design built on pointing at a folder and indexing its contents.

Then the Picker behavior changed again and required a second rework of an ingest path I'd already rebuilt once. OneDrive was going to be the second provider and had its own comparable problems, which ended that work before it started.

None of these changes were announced ahead of time. Each one broke functionality that was already built, tested, and working.

There's a workaround, and it's what the code does today. drive.file plus the Picker, where the user explicitly multi-selects files and the resulting grant is per-file and persistent. It needs no restricted scope and asks considerably less of the user's privacy. The cost is that newly added books need another trip through the picker instead of appearing on their own.

I stopped there rather than rebuild the ingest path a third time. Two providers had changed permission behavior without notice, twice in Google's case, and every change landed on the same part of the system. A third rebuild wouldn't have made the fourth change any less likely.

That's a dependency risk decision rather than a technical one. The workaround works fine. I just don't want it as the foundation under a product.

How I tested it

A Playwright suite drives the real Angular client against a running backend. It signs in through the actual login form instead of minting tokens, because the login path is one of the things I want covered rather than assumed.

Four roles get their own authenticated session, saved once in setup and reused. Owner, User, SuperAdmin, and GlobalReviewer. That exists so access control can be tested as a matrix instead of a spot check. There's a dedicated directory for role and guard enforcement across every route. In an application with seven roles and tenant isolation enforced at the query level, that's the thing most likely to break quietly and stay broken.

The OAuth handshake and the Google Picker are cross-origin and can't be driven from a test. So the suite asserts the outbound authorization request instead, including that the requested scope is still drive.file and hasn't regressed to a restricted one. The decision that halted this project is now a decision a test defends.

Tests are tagged rather than split into separate suites. @smoke marks the subset worth running on every push, and @mobile marks the cases that run against a phone viewport. Anything depending on data that already exists in the target environment calls test.skip with a reason instead of failing, so a fresh database gives an honest skip rather than a misleading red.

Selectors prefer the ids already in the templates. Where an id repeats inside an *ngFor or collides across components rendered at the same time, the page object scopes it to a container instead of inventing a test-only attribute.

File upload through the picker isn't covered, for the same cross-origin reason. Book creation gets exercised through the skip-file path.

What I cut

I tried PDF to EPUB conversion and abandoned it. Reflowing a PDF for small screens works well when the source has a real structural hierarchy. Scanned and flattened PDFs produced output I wasn't willing to put in front of a reader, so I cut the feature rather than ship it at partial quality.

The prototype is public if you want to see how far it got. Quality-gated text extraction, OCR fallback for PDFs whose font encoding is broken, and column detection from a density histogram rather than splitting the page down the middle. Its README is mostly a list of what it still can't do. Doing this properly is a product, not a feature.

Storage is pluggable but incomplete. Local disk and Google Drive both work. The S3 path is scaffolded and unfinished, and OneDrive never began, for the reasons above.

Audiobook generation lives outside this application on purpose. Building it into a library app that already knows where all my books are was the obvious move, and I built it as a separate local tool instead. The reasoning is on that page. MyBooks will still learn to catalog and play an .m4b, which is a storage problem and carries none of it.

What I would change

  • The end-to-end suite needs a populated environment. It signs in as four real accounts, so it can't run from a clean checkout the way a unit suite would. That was the right trade for testing access control against the actual guards, but it means the services underneath have no fast tests of their own, and the token exchange between them deserves some.
  • Secrets belonged in environment variables from the first commit. Early commits contain development credentials, since rotated. Adding a secret store later doesn't rewrite the history, so the README says so too.

The repository is public so the code can be read as a work sample. It isn't open source. The README covers running it locally if you'd rather see it move.

Read the source All projects