Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
12 changes: 6 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
prefix-key: "v1-rust"
workspaces: "src-tauri"
workspaces: "."

- name: (Ubuntu only) Install dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils

- name: Install node packages
run: bun install
Expand All @@ -58,14 +58,14 @@ jobs:
with:
name: linux-deb-and-appimage
path: |
src-tauri/target/release/bundle/deb/*.deb
src-tauri/target/release/bundle/appimage/*.AppImage
target/release/bundle/deb/*.deb
target/release/bundle/appimage/*.AppImage

- name: (macOS only) Upload build artifacts
if: matrix.platform == 'macos-15'
uses: actions/upload-artifact@v4
with:
name: macos-app-and-dmg
path: |
src-tauri/target/release/bundle/macos/Citadel.app
src-tauri/target/release/bundle/dmg/*.dmg
target/release/bundle/macos/Citadel.app
target/release/bundle/dmg/*.dmg
39 changes: 39 additions & 0 deletions .github/workflows/opds-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: OPDS runtime and headless server

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
test:
strategy:
fail-fast: false
matrix:
platform: [ubuntu-22.04, macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v5
- name: Install pinned Rust toolchain
run: rustup toolchain install
- uses: Swatinem/rust-cache@v2
with:
prefix-key: opds-runtime
workspaces: "."
- name: Install SQLite on Windows
if: runner.os == 'Windows'
shell: pwsh
run: |
vcpkg install sqlite3:x64-windows-static-md
"VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT" >> $env:GITHUB_ENV
"VCPKGRS_TRIPLET=x64-windows-static-md" >> $env:GITHUB_ENV
- name: Install SQLite on Linux
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev pkg-config libssl-dev
- name: Test runtime and headless host without Tauri
run: cargo test --locked -p citadel-opds -p citadel-server
- name: Test validation fixture input checks
run: cargo test --locked -p libcalibre --example create_opds_validation_library
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,13 +159,13 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
prefix-key: "v2-release-rust"
workspaces: "src-tauri"
workspaces: "."

- name: (Ubuntu only) Install dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils

- name: Install node packages
run: bun install
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,7 @@ Citadel can check for app updates and install them on request. Automatic update
This project would not be possible without the north star created by Kovid Goyal, [Calibre](https://github.com/kovidgoyal/calibre). Without his hard work building such an extensive and powerful tool, Citadel would not exist.

Huge thanks to [Kemie Guaida](https://kemielikes.design/), who created an excellent [Calibre redesign Figma prototype](https://old.reddit.com/r/Calibre/comments/udzumn/testing_a_new_interface_for_calibre/), from which Citadel takes inspiration. Thank you, Kemie!

### OPDS

Share the active library with OPDS 1.x readers using [Settings → Sharing](docs/opds-sharing.md), or run the [headless server](docs/headless-server.md) without the desktop application. The catalog supports navigation, search and book downloads.
168 changes: 168 additions & 0 deletions crates/libcalibre/examples/create_opds_validation_library.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
use std::{collections::HashMap, env, fs, path::PathBuf};

use chrono::NaiveDate;
use libcalibre::{util::get_db_path, BookAdd, BookUpdate, Library};

const ACQUIRABLE_BOOKS: usize = 105;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut arguments = env::args_os().skip(1);
let target = arguments
.next()
.map(PathBuf::from)
.ok_or("usage: create_opds_validation_library TARGET SOURCE_EPUB")?;
let source_epub = arguments
.next()
.map(PathBuf::from)
.ok_or("usage: create_opds_validation_library TARGET SOURCE_EPUB")?;
if arguments.next().is_some() {
return Err("usage: create_opds_validation_library TARGET SOURCE_EPUB".into());
}
create_validation_library(target, source_epub)
}

fn create_validation_library(
target: PathBuf,
source_epub: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
if target.exists() && fs::read_dir(&target)?.next().is_some() {
return Err(format!("target is not empty: {}", target.display()).into());
}
if source_epub.extension().and_then(|value| value.to_str()) != Some("epub") {
return Err("SOURCE_EPUB must use the .epub extension".into());
}

let source = fs::File::open(&source_epub)
.map_err(|error| format!("cannot read SOURCE_EPUB {}: {error}", source_epub.display()))?;
if !source.metadata()?.is_file() {
return Err("SOURCE_EPUB must be a regular file".into());
}

fs::create_dir_all(&target)?;
let fixture =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/empty_library/metadata.db");
fs::copy(fixture, target.join("metadata.db"))?;
let database = get_db_path(target.to_str().ok_or("target path is not UTF-8")?)
.ok_or("target is not a Calibre library")?;
let mut library = Library::new(database)?;
let large_source = target.join(".validation-large.txt");
let coverless_source = target.join(".validation-coverless.txt");
fs::write(&large_source, vec![b'L'; 8 * 1024 * 1024])?;
fs::write(&coverless_source, b"Coverless validation fixture\n")?;

for index in 0..=ACQUIRABLE_BOOKS {
let is_fileless = index == ACQUIRABLE_BOOKS;
let title = if index == 0 {
"A & B <C> — 東京".to_string()
} else {
format!("Validation Book {index:03}")
};
let authors = if index == 0 {
vec!["Zoë & Co.".to_string(), "李 小龍".to_string()]
} else {
vec![format!("Author {:03}", index % 61)]
};
let series = (index % 2 == 0).then(|| format!("Series {:02}", index % 7));
let tags = match index % 3 {
0 => vec!["Science Fiction".to_string(), "Tag & <unsafe>".to_string()],
1 => vec!["Mystery".to_string()],
_ => Vec::new(),
};
let book = library.add_book(BookAdd {
title,
author_names: authors,
tags: Some(tags),
series,
series_index: Some(index as f32 / 2.0 + 0.5),
publisher: None,
publication_date: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
rating: None,
comments: None,
identifiers: HashMap::new(),
language: Some(if index % 2 == 0 { "en" } else { "fr" }.to_string()),
file_paths: if is_fileless {
Vec::new()
} else if index == 0 {
vec![large_source.clone(), source_epub.clone()]
} else if index % 2 == 1 {
vec![coverless_source.clone(), source_epub.clone()]
} else {
vec![source_epub.clone()]
},
})?;

library.upsert_book_identifier(
book.id,
"isbn".to_string(),
format!("9780000{index:06}"),
None,
)?;

library.update_book(
book.id,
BookUpdate {
title: None,
author_names: None,
author_ids: None,
description: (index % 4 != 0)
.then(|| "Escaped <summary> & Unicode café 東京".to_string()),
is_read: Some(index % 3 == 0),
tags: None,
series: None,
series_index: None,
language_codes: None,
publisher: (index % 5 != 0).then(|| "Fixture Press".to_string()),
publication_date: None,
rating: None,
comments: None,
identifiers: None,
},
)?;

if index % 3 != 2 {
library.add_book_genres(
book.id,
vec![
if index % 2 == 0 {
"Speculative Fiction"
} else {
"Mystery"
}
.to_string(),
"Fixture Genre".to_string(),
],
)?;
}
}

drop(library);
fs::remove_file(large_source)?;
fs::remove_file(coverless_source)?;
println!("{}", target.display());
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn missing_source_does_not_create_output() {
let directory = tempfile::tempdir().unwrap();
let target = directory.path().join("output");
let missing = directory.path().join("missing.epub");
let error = create_validation_library(target.clone(), missing).unwrap_err();
assert!(error.to_string().contains("cannot read SOURCE_EPUB"));
assert!(!target.exists());
}

#[test]
fn source_directory_does_not_create_output() {
let directory = tempfile::tempdir().unwrap();
let target = directory.path().join("output");
let source = directory.path().join("directory.epub");
fs::create_dir(&source).unwrap();
assert!(create_validation_library(target.clone(), source).is_err());
assert!(!target.exists());
}
}
5 changes: 5 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,8 @@ Modules are deep, and errors are defined out of existence. For more, see
the total match count plus sparse 100-book pages fetched on demand and
invalidated (by generation stamp) after mutations. Authors and the tag
vocabulary are still loaded as full lists.

## OPDS sharing

- [Desktop sharing and reader setup](opds-sharing.md)
- [Headless server and validation fixture](headless-server.md)
11 changes: 10 additions & 1 deletion docs/adr/0005-opds-credentials-are-stored-reversibly.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ Pre-reveal credential files fail to parse and are treated as no credentials; use

Reveal is a read, not a rotation. The reader UI can show and copy the password at any time; "only shown once" disappears from the product.

A same-user attacker who can read the 0600 credential file gets the plaintext — but that attacker can already read every book in the library, and citadel-server has always held this credential in plaintext TOML, so the stack has accepted reversible storage at rest from the start. Hashing would add cost without moving any real boundary. The one boundary that did change is the webview: `clb_query_opds_credential_secret` hands the plaintext to app JS, so a theoretical XSS now reaches the secret where a verifier used to be the ceiling. The app renders no untrusted HTML today; if that changes, this decision must be revisited alongside a Content-Security-Policy. Users may also type their own (possibly reused) password instead of generating one — the plaintext cost of that choice is theirs; the UI nudges toward generation. ADR 0003's machine-local placement is unchanged: the secret still never travels with a library.
A same-user attacker who can read the 0600 credential file gets the plaintext — but that attacker can already read every book in the library, so desktop sharing accepts reversible storage at rest to support password reveal. Hashing would add cost without moving any real boundary. The one boundary that did change is the webview: `clb_query_opds_credential_secret` hands the plaintext to app JS, so a theoretical XSS now reaches the secret where a verifier used to be the ceiling. The app renders no untrusted HTML today; if that changes, this decision must be revisited alongside a Content-Security-Policy. Users may also type their own (possibly reused) password instead of generating one — the plaintext cost of that choice is theirs; the UI nudges toward generation. ADR 0003's machine-local placement is unchanged: the secret still never travels with a library.

Libraries and credential files from nightly builds with verifier-format files need one regeneration after updating.

## Headless hosting

The persistence policy above applies to the desktop host. The headless server
reads a JSON list of `USER:PASSWORD` pairs from `CITADEL_OPDS_USERS` once at
startup and supplies them through the runtime's ephemeral-credential constructor.
It never reads or writes credential files. Every configured user receives the
same read access; restarting is required to change the list. Its optional state
directory defaults to XDG state storage on Unix and currently contains no files.
15 changes: 15 additions & 0 deletions docs/headless-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,18 @@ Citadel serves plain HTTP. For access beyond a trusted local network, place it
behind an externally managed TLS reverse proxy and firewall. Service
supervision, certificates, public-exposure controls, and container or operating
system packages are not provided by this initial server.

## Validation and deployment

Build the host with `cargo build --release -p citadel-server`. This selects the headless package and does not build Tauri. Its configuration uses `sharing.target = "localNetworks"` or `"allInterfaces"`, or the explicit-address table shown above; do not combine those forms.

The Unix process test exercises authentication, catalog navigation, acquisition, and SIGTERM shutdown. The non-Unix Ctrl-C handler requires platform validation; it is not covered by that test. OPDS request limits are described in [Sharing](opds-sharing.md).

To generate a disposable validation library, provide an EPUB you can use for testing:

```sh
cargo run -p libcalibre --example create_opds_validation_library -- \
/absolute/empty/output-directory /absolute/path/to/example.epub
```

The fixture includes 105 downloadable books, multiple pages, Unicode/XML metadata, assigned genres, missing covers, a large file and a fileless book. It is intended for smoke tests, not for an existing library.
25 changes: 25 additions & 0 deletions docs/opds-sharing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Share a library over OPDS

Citadel shares the active Calibre library as a read-only OPDS 1.x catalog while the desktop app runs.

## Turn on sharing

1. Open **Settings → Sharing** with a library open.
2. Choose local networks, or all networks. Sharing on all networks requires reader sign-in.
3. Keep port 8080 unless another service uses it.
4. If using sign-in, generate or set a username and password. You can reveal the saved password later or replace it without restarting sharing.
5. Turn sharing on and copy one of the displayed catalog URLs.

Add the URL and any configured credentials to an OPDS 1.x reader. For example, in KOReader use **File browser → OPDS catalog → Add new**.

The root catalog links to all books, recently modified books, unread books, authors, series, tags and genres. Search is advertised through OpenSearch. Genre browsing uses Citadel's assigned genres, independently of tags; an unassigned collection has an empty genre feed. Facet counts include downloadable books only. Fileless books and unsafe or missing files are excluded from acquisition pages.

Switching libraries stops sharing; start it again for the new library. Quitting Citadel also stops sharing. Clearing sign-in credentials stops a running share; replacing them takes effect on the next request.

## Network access

Citadel serves plain HTTP, including Basic authentication. Use a trusted network or an externally managed TLS reverse proxy and firewall for remote access. The all-networks choice can include VPN and public interfaces. A catalog URL must be reachable from the reader; a firewall or network isolation can prevent connections.

Feed responses have a 30-second deadline. Request bodies are limited to 16 KiB. The service shares 64 request slots across its listeners, retaining slots through response-body completion; blocking work has a separate shared 64-slot limit. Busy requests receive 503 with Retry-After. Book downloads are not cut off by the feed deadline. Failed sign-in attempts use exponential backoff. These limits begin after HTTP headers arrive and do not limit accepted sockets or slow-header connections.

For a host without the desktop application, see [Headless server](headless-server.md).
2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ license = "MIT"
repository = "https://github.com/every-day-things/citadel"
default-run = "citadel-rs"
edition = "2021"
rust-version = "1.77"
rust-version.workspace = true

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

Expand Down
Loading