Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
350 changes: 316 additions & 34 deletions AGENTS.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,9 @@
# directories explicitly (`bun test src server`) so app/'s React Native suites,
# which use the jest preset rather than bun's runner, stay out of the way.
root = "."

# Runs before src/env.ts is imported, which is the only moment DB_PATH and
# LIBRARY_DIR can still be set — envalid freezes `env` at import. Without it
# getLibraryDir() resolves to ./library inside the checkout and upload tests
# write ebooks into the repo. See src/test/env-setup.ts.
preload = ["./src/test/env-setup.ts"]
26 changes: 25 additions & 1 deletion lexicons/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"main": {
"type": "permission-set",
"title": "Full BookHive Functionality",
"detail": "Track books, write reviews and comments, and manage reading lists",
"detail": "Track books, write reviews and comments, manage reading lists, and use your personal ebook library from other apps",
"permissions": [
{
"type": "permission",
Expand All @@ -18,6 +18,30 @@
"resource": "repo",
"collection": ["buzz.bookhive.buzz"],
"action": ["create", "update", "delete"]
},
{
"type": "permission",
"resource": "rpc",
"aud": "*",
"lxm": [
"buzz.bookhive.getPersonalLibrary",
"buzz.bookhive.getPersonalBook",
"buzz.bookhive.getPersonalBookFile",
"buzz.bookhive.getPersonalBookCover",
"buzz.bookhive.listPersonalShelves",
"buzz.bookhive.uploadPersonalBook",
"buzz.bookhive.deletePersonalBook",
"buzz.bookhive.linkPersonalBook",
"buzz.bookhive.unlinkPersonalBook",
"buzz.bookhive.createPersonalShelf",
"buzz.bookhive.updatePersonalShelf",
"buzz.bookhive.deletePersonalShelf",
"buzz.bookhive.addToPersonalShelf",
"buzz.bookhive.removeFromPersonalShelf",
"buzz.bookhive.getSyncProgress",
"buzz.bookhive.putSyncProgress",
"buzz.bookhive.listSyncDocuments"
]
}
]
}
Expand Down
36 changes: 36 additions & 0 deletions lexicons/getPersonalBookCover.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"lexicon": 1,
"id": "buzz.bookhive.getPersonalBookCover",
"defs": {
"main": {
"type": "query",
"description": "Cover image for a book in the authenticated user's personal library. Serves the cover extracted from the uploaded file when there is one; otherwise, if the book is linked to a BookHive catalog entry, responds 302 to the public image proxy for that entry. The XRPC equivalent of GET /opds/books/{hash}/cover.",
"parameters": {
"type": "params",
"required": ["contentHash"],
"properties": {
"contentHash": {
"type": "string",
"description": "Content hash identifying the book"
},
"width": {
"type": "integer",
"description": "Requested width in pixels for the catalog-cover redirect. Ignored for locally stored covers, which are served at the size they were extracted at.",
"minimum": 32,
"maximum": 1024,
"default": 300
}
}
},
"output": {
"encoding": "image/jpeg,image/png,image/gif,image/webp"
},
"errors": [
{
"name": "NotFound",
"description": "No such book, or the book has neither a stored cover nor a linked catalog entry"
}
]
}
}
}
29 changes: 29 additions & 0 deletions lexicons/getPersonalBookFile.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"lexicon": 1,
"id": "buzz.bookhive.getPersonalBookFile",
"defs": {
"main": {
"type": "query",
"description": "Download the raw ebook file for a book in the authenticated user's personal library. Responds with the stored bytes, a strong ETag equal to the content hash, and a `Content-Disposition: attachment` filename. Honours `If-None-Match` with a 304, so an e-reader syncing on a schedule does not re-download every book. The XRPC equivalent of GET /opds/books/{hash}/download.",
"parameters": {
"type": "params",
"required": ["contentHash"],
"properties": {
"contentHash": {
"type": "string",
"description": "Content hash identifying the book, as returned by getPersonalLibrary"
}
}
},
"output": {
"encoding": "application/epub+zip,application/x-mobipocket-ebook,application/x-fictionbook+xml,application/vnd.comicbook+zip,application/octet-stream"
},
"errors": [
{
"name": "NotFound",
"description": "No such book in the caller's library, or its file is missing from disk"
}
]
}
}
}
48 changes: 47 additions & 1 deletion lexicons/getPersonalLibrary.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@
"shelfId": {
"type": "integer",
"description": "Filter by personal shelf ID"
},
"q": {
"type": "string",
"description": "Case-insensitive substring match against title or authors. Mirrors the OPDS search feed.",
"minLength": 1,
"maxLength": 256
},
"sort": {
"type": "string",
"description": "Result ordering. `recent` is newest-added first (the default, matching the library page and the OPDS /all feed); `title` and `author` are ascending alphabetical, matching the OPDS search results feed. Not switched implicitly when `q` is set — pass it explicitly.",
"knownValues": ["recent", "title", "author"],
"default": "recent"
}
}
},
Expand All @@ -45,11 +57,32 @@
"cursor": {
"type": "string",
"description": "Pagination cursor for the next page"
},
"storage": {
"type": "ref",
"ref": "#storageView",
"description": "This user's storage usage against their quota"
}
}
}
}
},
"storageView": {
"type": "object",
"required": ["usedBytes", "quotaBytes"],
"properties": {
"usedBytes": {
"type": "integer",
"description": "Total bytes currently stored for this user",
"minimum": 0
},
"quotaBytes": {
"type": "integer",
"description": "Total bytes this user is allowed to store",
"minimum": 0
}
}
},
"personalBookView": {
"type": "object",
"required": ["contentHash", "title", "format", "mime", "sizeBytes", "createdAt", "updatedAt"],
Expand Down Expand Up @@ -88,7 +121,20 @@
},
"coverUrl": {
"type": "string",
"description": "URL of the book cover image"
"description": "URL of the book cover image. When it points at the public catalog image proxy it needs no authentication; the `/library/covers/...` form is session-authenticated, so a client using service auth should use `hasLocalCover` and getPersonalBookCover instead."
},
"hasLocalCover": {
"type": "boolean",
"description": "Whether a cover extracted from the uploaded file is stored. Fetch it with getPersonalBookCover, which works under any supported authentication."
},
"filename": {
"type": "string",
"description": "Original uploaded file name. A sync client needs this to correlate the book with what is on the device."
},
"description": {
"type": "string",
"description": "Synopsis from the linked BookHive catalog entry",
"maxLength": 5000
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"createdAt": {
"type": "string",
Expand Down
36 changes: 36 additions & 0 deletions lexicons/listPersonalShelves.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"lexicon": 1,
"id": "buzz.bookhive.listPersonalShelves",
"defs": {
"main": {
"type": "query",
"description": "List the authenticated user's personal shelves with per-shelf book counts, plus library-wide totals and storage usage. This is the root call for a catalog client: it carries everything the OPDS root navigation feed renders, in one request. Unpaginated — `personal_shelf` is unique on (userDid, name) and these lists are small.",
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["shelves", "totalBooks"],
"properties": {
"shelves": {
"type": "array",
"items": {
"type": "ref",
"ref": "buzz.bookhive.createPersonalShelf#personalShelfView"
}
},
"totalBooks": {
"type": "integer",
"description": "Books in the library across all shelves and unshelved",
"minimum": 0
},
"storage": {
"type": "ref",
"ref": "buzz.bookhive.getPersonalLibrary#storageView",
"description": "This user's storage usage against their quota"
}
}
}
}
}
}
}
50 changes: 47 additions & 3 deletions lexicons/uploadPersonalBook.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,21 @@
"defs": {
"main": {
"type": "procedure",
"description": "Upload an ebook file to the personal library. Accepts EPUB, PDF, MOBI, FB2, CBZ.",
"description": "Upload an ebook file to the authenticated user's personal library. Accepts EPUB, MOBI/AZW/AZW3, FB2 (including .fb2.zip) and CBZ. The declared Content-Type is advisory only: the format is determined from the file's magic bytes checked against the `filename` extension, and a file that matches neither is rejected. `application/octet-stream` is accepted because mobile document pickers and plain curl uploads routinely send it.",
"parameters": {
"type": "params",
"required": ["filename"],
"properties": {
"filename": {
"type": "string",
"description": "Original file name including extension. Required: the extension is the only thing distinguishing the zip-container formats (.epub / .cbz / .fb2.zip) from each other, and it is the key used to match e-reader sync documents to this file.",
"minLength": 1,
"maxLength": 512
}
}
},
"input": {
"encoding": "*/*"
"encoding": "application/epub+zip,application/x-mobipocket-ebook,application/vnd.amazon.ebook,application/vnd.amazon.mobi8-ebook,application/x-fictionbook+xml,application/vnd.comicbook+zip,application/x-cbz,application/zip,application/octet-stream"
},
"output": {
"encoding": "application/json",
Expand All @@ -17,10 +29,42 @@
"book": {
"type": "ref",
"ref": "buzz.bookhive.getPersonalLibrary#personalBookView"
},
"storageUsedBytes": {
"type": "integer",
"description": "Total bytes stored for this user after the upload",
"minimum": 0
},
"storageQuotaBytes": {
"type": "integer",
"description": "Total bytes this user is allowed to store",
"minimum": 0
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
},
"errors": [
{
"name": "UnsupportedFormat",
"description": "The bytes are not a recognised ebook, or do not match the filename's extension"
},
{
"name": "AlreadyExists",
"description": "A book with this content hash is already in the library"
},
{
"name": "TooLarge",
"description": "The file exceeds the per-file size limit"
},
{
"name": "QuotaExceeded",
"description": "The upload would take the library over its total storage quota"
},
{
"name": "Busy",
"description": "Too many uploads are being processed; retry shortly"
}
]
}
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@atcute/atproto": "4.0.3",
"@atcute/bluesky": "4.0.13",
"@atcute/client": "5.1.1",
"@atcute/crypto": "2.4.2",
"@atcute/identity-resolver": "2.0.1",
"@atcute/identity-resolver-node": "2.0.1",
"@atcute/jetstream": "2.0.1",
Expand Down
38 changes: 36 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ export function createApp({ startTime: serverStartTime, deps }: CreateAppOptions

app.use(timing({ autoEnd: false }));

/**
* Routes that stream a stored ebook file. Kept out of both `compress()` and
* `etag()` — see the notes at each call site.
*/
const BOOK_DOWNLOAD_PREFIXES = [
"/library/books/",
"/opds/books/",
"/xrpc/buzz.bookhive.getPersonalBookFile",
];
const isBookDownloadPath = (path: string) =>
BOOK_DOWNLOAD_PREFIXES.some(
(prefix) => path === prefix || path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
);

if (env.isDevelopment) {
app.use(prettyJSON());
}
Expand Down Expand Up @@ -65,7 +79,16 @@ export function createApp({ startTime: serverStartTime, deps }: CreateAppOptions
await next();
endTime(c, "compress");
});
app.use(compress());
const compressMiddleware = compress();
app.use("*", async (c, next) => {
// Ebook downloads are already-compressed containers (EPUB and CBZ are ZIP,
// MOBI is its own packing), so gzipping them burns CPU for ~nothing. The
// one that would actually match hono's compressible-type regex is FB2 —
// `application/x-fictionbook+xml` hits the `+xml` branch — and compressing
// it drops the Content-Length a client is driving a progress bar from.
if (isBookDownloadPath(c.req.path)) return next();
return compressMiddleware(c, next);
});

app.use(jsxRenderer());

Expand Down Expand Up @@ -122,7 +145,18 @@ export function createApp({ startTime: serverStartTime, deps }: CreateAppOptions
// completes and no byte is ever flushed. That failure is severe and would
// look like "import is broken" rather than "etag is misconfigured", so it is
// worth being order-independent about.
const ETAG_EXCLUDED_PREFIXES = ["/library/books/", "/opds/books/", "/import"];
// The two binary XRPC methods are listed by exact NSID, deliberately not as
// a `/xrpc/` prefix — that would cost the ~35 JSON methods their 304s. Both
// set their own ETag, and hono's etag() skips a response that already has
// one, so this is belt-and-braces: if a future edit drops that header the
// buffering regression above would otherwise come back silently.
const ETAG_EXCLUDED_PREFIXES = [
"/library/books/",
"/opds/books/",
"/import",
"/xrpc/buzz.bookhive.getPersonalBookFile",
"/xrpc/buzz.bookhive.getPersonalBookCover",
];
const isEtagExcluded = (path: string) =>
ETAG_EXCLUDED_PREFIXES.some(
(prefix) =>
Expand Down
Loading