feat: scaffold audioblume audiobook backend foundation

Cargo workspace with the entities/core/data/api/cli crate split:

- entities: content-addressing (Sha256), ISBN-13, ids, domain types, DTOs
- core: dedup upload orchestration, access-decision gating, Argon2id auth,
  catalog browse, and the data-access port traits
- data: sqlx (Postgres, compile-time-checked, offline cache), aws-sdk-s3
  (MinIO), epub parsing; implements the core ports
- api: Axum /v1 (auth, upload, catalog, gated download, cover)
- cli: operator commands (migrate, user, copyright, work, blob, session)

Three-layer catalog (blob -> edition -> work) with content-addressed
deduplication, copyright gating (public-domain open; in-copyright/unknown
gated by ownership), and bookstore-style metadata visibility. Forward-compatible
schema for later translation, narration, voice marketplace, and sales phases.

Includes deployment assets (systemd, firewalld, nginx, sysusers, config
template), Postgres + MinIO bootstrap, and an idempotent deploy.sh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 15:13:08 +03:00
commit 4f49a1d74b
93 changed files with 11419 additions and 0 deletions

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
/target
**/*.rs.bk
*.pdb
# Rendered config (never commit secrets); templates live in asset/config/
/asset/config/*.toml
!/asset/config/*.toml.tmpl
# Local environment overrides
.env
.env.local
# Editor / OS cruft
.DS_Store
*.swp
# Editor-local, host-specific config (not shared)
/.zed/
/.vscode/
/.idea/

View File

@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT w.id, w.canonical_title, w.canonical_author,\n w.copyright_status::text AS \"copyright_status!\", w.copyright_note,\n w.created_at, w.updated_at\n FROM works w\n JOIN editions e ON e.work_id = w.id\n WHERE e.isbn13 = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "canonical_title",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "canonical_author",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "copyright_status!",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "copyright_note",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "updated_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true,
null,
true,
false,
false
]
},
"hash": "0d7cb819ec99d32eb17fdcf1729629125e53794940954896d9e0175c528a5ce8"
}

View File

@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO sessions (user_id, token_hash, expires_at)\n VALUES ($1, $2, $3)\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Bytea",
"Timestamptz"
]
},
"nullable": [
false
]
},
"hash": "118c889c761a9ac9c11120d9bc4cfa37bfc474e6fde09ee7bd57e8578d429882"
}

View File

@@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, email, display_name, password_hash, is_admin, status, created_at, updated_at\n FROM users\n WHERE id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "display_name",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "password_hash",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "updated_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
false
]
},
"hash": "20f76717cd92d00946d0de700e2157d492103f9afb20f74c7a981ef2598aa4fd"
}

View File

@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, user_id\n FROM sessions\n WHERE token_hash = $1 AND expires_at > $2 AND revoked_at IS NULL\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Bytea",
"Timestamptz"
]
},
"nullable": [
false,
false
]
},
"hash": "3097facd033b5ddb225920dbb6ee52d1075d64c1a079b239f46c1c21b07c54f6"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "31e56f05bdfc4728d59767a351596e693556925abd37ff44cc48e13a93c11743"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO entitlements (user_id, edition_id, source)\n VALUES ($1, $2, $3::text::entitlement_source)\n ON CONFLICT (user_id, edition_id) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "3321bd4b774f9580c24254f274a9a29b565935e69474edc8f7757f5e0985a984"
}

View File

@@ -0,0 +1,95 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, work_id, blob_sha256, title, author, language, isbn13, publisher,\n cover_blob_sha256, copyright_status::text AS \"copyright_status!\",\n extraction_status, extraction_error, created_at\n FROM editions\n ORDER BY created_at DESC\n OFFSET $1 LIMIT $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "work_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "blob_sha256",
"type_info": "Bytea"
},
{
"ordinal": 3,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "author",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "language",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "isbn13",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "publisher",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "cover_blob_sha256",
"type_info": "Bytea"
},
{
"ordinal": 9,
"name": "copyright_status!",
"type_info": "Text"
},
{
"ordinal": 10,
"name": "extraction_status",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "extraction_error",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
true,
true,
null,
false,
true,
false
]
},
"hash": "44f58fa2a3bab3d4a7397bcb3c4f4b065eb0245f8e821dc188a6c2e2681eca8d"
}

View File

@@ -0,0 +1,66 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT w.id, w.canonical_title, w.canonical_author,\n w.copyright_status::text AS \"copyright_status!\", w.copyright_note,\n w.created_at, w.updated_at,\n similarity(w.canonical_title, $1) AS \"score!\"\n FROM works w\n WHERE similarity(w.canonical_title, $1) >= $2\n ORDER BY similarity(w.canonical_title, $1) DESC\n LIMIT $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "canonical_title",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "canonical_author",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "copyright_status!",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "copyright_note",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "score!",
"type_info": "Float4"
}
],
"parameters": {
"Left": [
"Text",
"Float4",
"Int8"
]
},
"nullable": [
false,
false,
true,
null,
true,
false,
false,
null
]
},
"hash": "47c66d2f7e8b5609dc81888bfef3ea08a080296b7be3cb44c211872f4bed4cc6"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE works\n SET copyright_status = $2::text::copyright_status, copyright_note = $3, updated_at = now()\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "5c6b6376e1f50a2dd31eb67caf0d846ce2aa59f4ca96be319430ab39d93ac561"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT exists(SELECT 1 FROM works WHERE id = $1) AS \"e!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "e!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "7e42f52375a14e4f4791892d53b039f089f9d9927cba7c36a6f9b8dd75978ec2"
}

View File

@@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, email, display_name, password_hash, is_admin, status, created_at, updated_at\n FROM users\n WHERE lower(email) = lower($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "display_name",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "password_hash",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "updated_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
false
]
},
"hash": "876243393dd600f9f0e1ecf08548a59b1815df4cab406e99207c017269617b4b"
}

View File

@@ -0,0 +1,105 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO editions\n (work_id, blob_sha256, title, author, language, isbn13, publisher,\n cover_blob_sha256, metadata_json, copyright_status, extraction_status,\n extraction_error)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::text::copyright_status, $11, $12)\n RETURNING id, work_id, blob_sha256, title, author, language, isbn13, publisher,\n cover_blob_sha256, copyright_status::text AS \"copyright_status!\",\n extraction_status, extraction_error, created_at\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "work_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "blob_sha256",
"type_info": "Bytea"
},
{
"ordinal": 3,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "author",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "language",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "isbn13",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "publisher",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "cover_blob_sha256",
"type_info": "Bytea"
},
{
"ordinal": 9,
"name": "copyright_status!",
"type_info": "Text"
},
{
"ordinal": 10,
"name": "extraction_status",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "extraction_error",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Bytea",
"Text",
"Text",
"Text",
"Text",
"Text",
"Bytea",
"Jsonb",
"Text",
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
true,
true,
null,
false,
true,
false
]
},
"hash": "928a6049f0f238ba4c4336f1eb3dd79644f1cbb9a4992366778787b04829c507"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE editions SET work_id = $2 WHERE work_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": []
},
"hash": "9a2f03d9ac91cd735f7eb03e9086b2dbfb23a3d276685d8379c2336e5293d2c2"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET status = $2, updated_at = now() WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "a77f205a696a2dc35a49b4ea0e98aa47d477d752f1b8f32071d8295a37ca3504"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE sessions SET last_seen_at = $2 WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Timestamptz"
]
},
"nullable": []
},
"hash": "adf06711fd97cdca457765f091a2b3c1741b37c42484b0f3d691113330babd51"
}

View File

@@ -0,0 +1,94 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, work_id, blob_sha256, title, author, language, isbn13, publisher,\n cover_blob_sha256, copyright_status::text AS \"copyright_status!\",\n extraction_status, extraction_error, created_at\n FROM editions\n WHERE blob_sha256 = $1\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "work_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "blob_sha256",
"type_info": "Bytea"
},
{
"ordinal": 3,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "author",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "language",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "isbn13",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "publisher",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "cover_blob_sha256",
"type_info": "Bytea"
},
{
"ordinal": 9,
"name": "copyright_status!",
"type_info": "Text"
},
{
"ordinal": 10,
"name": "extraction_status",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "extraction_error",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Bytea"
]
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
true,
true,
null,
false,
true,
false
]
},
"hash": "b3498c0e3e18b3b553175d669c01b3e69cea60e4f544b4c6b4e14a8cb9de4f01"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO blobs (sha256, kind, byte_size, storage_key, storage_bucket)\n VALUES ($1, $2::text::blob_kind, $3, $4, $5)\n ON CONFLICT (sha256) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bytea",
"Text",
"Int8",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "b7f0353801ef188e342924a276f3fc3b9b5ff38a7eabf71e91abff0a4795136b"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bytea"
]
},
"nullable": []
},
"hash": "bd7734ce47a17f177f9dde9c52cf8bb691d11356dfde263c1a2b3cfdabd89745"
}

View File

@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT sha256, kind::text AS \"kind!\", byte_size, storage_key, storage_bucket, created_at\n FROM blobs\n WHERE sha256 = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "sha256",
"type_info": "Bytea"
},
{
"ordinal": 1,
"name": "kind!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "byte_size",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "storage_key",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "storage_bucket",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Bytea"
]
},
"nullable": [
false,
null,
false,
false,
false,
false
]
},
"hash": "be9c103674af1a805d9e6a038eebc75f0591e84bb66b4ece2fb71779cadfbf21"
}

View File

@@ -0,0 +1,60 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO works (canonical_title, canonical_author, copyright_status)\n VALUES ($1, $2, $3::text::copyright_status)\n RETURNING id, canonical_title, canonical_author,\n copyright_status::text AS \"copyright_status!\", copyright_note,\n created_at, updated_at\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "canonical_title",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "canonical_author",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "copyright_status!",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "copyright_note",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "updated_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
false,
false,
true,
null,
true,
false,
false
]
},
"hash": "c5f0fff4ac332027b6cced89e13f7654846d4521a796e04a4ea61ea17fc5a683"
}

View File

@@ -0,0 +1,94 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, work_id, blob_sha256, title, author, language, isbn13, publisher,\n cover_blob_sha256, copyright_status::text AS \"copyright_status!\",\n extraction_status, extraction_error, created_at\n FROM editions\n WHERE work_id = $1\n ORDER BY created_at\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "work_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "blob_sha256",
"type_info": "Bytea"
},
{
"ordinal": 3,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "author",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "language",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "isbn13",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "publisher",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "cover_blob_sha256",
"type_info": "Bytea"
},
{
"ordinal": 9,
"name": "copyright_status!",
"type_info": "Text"
},
{
"ordinal": 10,
"name": "extraction_status",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "extraction_error",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
true,
true,
null,
false,
true,
false
]
},
"hash": "ccde30b99b3117fc96bff0cb30408b9aff254fb556408977d1c79f321ed00853"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO work_merges (merged_work_id, surviving_work_id, merged_by, reason)\n VALUES ($1, $2, $3, $4)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "f140e8fcdbc353f4fb2d9f02c51b60cd7e76ee41352e647d205df9b1badccd22"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT source::text AS \"source!\"\n FROM entitlements\n WHERE user_id = $1 AND edition_id = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "source!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "f2383d53d45c6d1ac53dc34b23c5821deb02b236d8dc402ea9f2726b840f71a8"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM works WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "f2df3d324a5e89c6e6299e1021db8ef02fcf30bf7fb12b4ca05484c8283aa213"
}

View File

@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, canonical_title, canonical_author,\n copyright_status::text AS \"copyright_status!\", copyright_note,\n created_at, updated_at\n FROM works\n WHERE id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "canonical_title",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "canonical_author",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "copyright_status!",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "copyright_note",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "updated_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
true,
null,
true,
false,
false
]
},
"hash": "f4521f50ca1b956309e45f46859bf17fdd4c53cd3d60a8d0c57789882d131a0a"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) AS \"count!\" FROM editions",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "f6ad2caadb1fc2127cb99326fa1dff00efee3e61730351e151940155cc8717dd"
}

View File

@@ -0,0 +1,67 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO users (email, display_name, password_hash, is_admin)\n VALUES ($1, $2, $3, $4)\n RETURNING id, email, display_name, password_hash, is_admin, status, created_at, updated_at\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "display_name",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "password_hash",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "updated_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Bool"
]
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
false
]
},
"hash": "f9ee4612fe3b3d8c9480f55e1d46a10a5aa919589aa79eab38622c68da12b85a"
}

View File

@@ -0,0 +1,94 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, work_id, blob_sha256, title, author, language, isbn13, publisher,\n cover_blob_sha256, copyright_status::text AS \"copyright_status!\",\n extraction_status, extraction_error, created_at\n FROM editions\n WHERE id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "work_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "blob_sha256",
"type_info": "Bytea"
},
{
"ordinal": 3,
"name": "title",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "author",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "language",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "isbn13",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "publisher",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "cover_blob_sha256",
"type_info": "Bytea"
},
{
"ordinal": 9,
"name": "copyright_status!",
"type_info": "Text"
},
{
"ordinal": 10,
"name": "extraction_status",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "extraction_error",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
true,
true,
null,
false,
true,
false
]
},
"hash": "fc845092accc2a55a4971a04c271939beb5ae58f8a7e5eed0af442e9f66656e5"
}

4239
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

75
Cargo.toml Normal file
View File

@@ -0,0 +1,75 @@
[workspace]
resolver = "3"
members = ["crates/*"]
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
license = "GPL-3.0-or-later"
authors = ["Rob Thijssen <rob@example>"]
[workspace.dependencies]
# Internal crates
audioblume-entities = { path = "crates/audioblume-entities", version = "=0.1.0" }
audioblume-core = { path = "crates/audioblume-core", version = "=0.1.0" }
audioblume-data = { path = "crates/audioblume-data", version = "=0.1.0" }
# Async runtime
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
futures = "0.3"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Web
axum = { version = "0.8", features = ["macros"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "limit"] }
# Data access
sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio",
"tls-rustls",
"postgres",
"macros",
"migrate",
"uuid",
"chrono",
"json",
] }
aws-config = "1"
aws-sdk-s3 = "1"
aws-credential-types = "1"
aws-smithy-types = "1"
# EPUB parsing
epub = "2"
# Crypto / hashing
argon2 = "0.5"
sha2 = "0.10"
rand = "0.8"
hex = "0.4"
base64 = "0.22"
# Config
figment = { version = "0.10", features = ["toml", "env"] }
# CLI
clap = { version = "4", features = ["derive", "env"] }
# Errors / logging
thiserror = "2"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
# Misc
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
bytes = "1"
tempfile = "3"
url = "2"

View File

@@ -0,0 +1,36 @@
# audioblume-api / audioblume-cli configuration template.
#
# deploy.sh renders this with values from manifest.yml and secrets from `pass`, writing the
# result to /etc/audioblume/config.toml on the target. The rendered file is never committed.
# {{PLACEHOLDERS}} are substituted at deploy time. Env vars (AUDIOBLUME_*, nested with __)
# override any value here.
bind = "{{BIND}}"
max_upload_bytes = 209715200 # 200 MiB
log_format = "{{LOG_FORMAT}}"
[database]
# Connection is mTLS and passwordless: no password field. The role is resolved from the host
# client certificate's CN via pg_ident on the server.
host = "{{DB_HOST}}"
port = {{DB_PORT}}
database = "{{DB_NAME}}"
user = "{{DB_USER}}"
sslmode = "verify-full"
ssl_root_cert = "/etc/pki/ca-trust/source/anchors/root-internal.pem"
ssl_client_cert = "/etc/pki/tls/misc/{{HOST_FQDN}}.pem"
ssl_client_key = "/etc/pki/tls/private/{{HOST_FQDN}}.pem"
max_connections = {{DB_MAX_CONNECTIONS}}
[storage]
endpoint = "{{MINIO_ENDPOINT}}"
region = "{{MINIO_REGION}}"
access_key = "{{MINIO_ACCESS_KEY}}"
secret_key = "{{MINIO_SECRET_KEY}}"
bucket = "{{MINIO_BUCKET}}"
create_bucket = {{CREATE_BUCKET}}
[catalog]
session_ttl_seconds = {{SESSION_TTL_SECONDS}}
fuzzy_match_threshold = {{FUZZY_MATCH_THRESHOLD}}
download_url_ttl_seconds = {{DOWNLOAD_URL_TTL_SECONDS}}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>audioblume-api</short>
<description>audioblume REST/JSON API. Only required when the API is reached from another
host; when nginx is co-located and the API binds loopback, no opening is needed.</description>
<port protocol="tcp" port="8080"/>
</service>

53
asset/manifest.yml Normal file
View File

@@ -0,0 +1,53 @@
app: audioblume
environments:
prod:
components:
api:
hosts: [oolon.hanzalova.internal]
config:
bind: 127.0.0.1:8080
log_format: json
database:
host: magrathea.kosherinata.internal
port: 5432
database: audioblume
user: audioblume_rw
sslmode: verify-full
max_connections: 16
storage:
endpoint: https://caveman.kosherinata.internal:9000
region: us-east-1
bucket: audioblume-blobs
# The bucket and the app's scoped credentials are provisioned out-of-band by
# asset/minio/bootstrap.sh, so the app does not self-create the bucket.
create_bucket: false
catalog:
session_ttl_seconds: 2592000
fuzzy_match_threshold: 0.6
download_url_ttl_seconds: 300
dev:
components:
api:
hosts: [quadbrat.hanzalova.internal]
config:
bind: 127.0.0.1:8080
log_format: json
database:
host: magrathea.kosherinata.internal
port: 5432
database: audioblume_dev
user: audioblume_rw
sslmode: verify-full
max_connections: 8
storage:
# Dev runs MinIO as a local container on the dev host; the app creates its own
# bucket on startup, so no separate MinIO bootstrap is needed for dev.
endpoint: http://localhost:9000
region: us-east-1
bucket: audioblume-blobs-dev
create_bucket: true
catalog:
session_ttl_seconds: 2592000
fuzzy_match_threshold: 0.6
download_url_ttl_seconds: 300

View File

@@ -0,0 +1,15 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket", "s3:GetBucketLocation"],
"Resource": ["arn:aws:s3:::{{BUCKET}}"]
},
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": ["arn:aws:s3:::{{BUCKET}}/*"]
}
]
}

108
asset/minio/bootstrap.sh Executable file
View File

@@ -0,0 +1,108 @@
#!/usr/bin/env bash
#
# bootstrap.sh — provision the MinIO bucket and a bucket-scoped service account for audioblume.
#
# Run once (idempotently) against the production MinIO before the first deploy:
#
# ./asset/minio/bootstrap.sh
# ./asset/minio/bootstrap.sh --endpoint https://caveman.kosherinata.internal:9000 --bucket audioblume-blobs
#
# What it does:
# 1. Creates the bucket if absent.
# 2. Creates a least-privilege policy scoped to that bucket (read/write objects, list bucket).
# 3. Creates a dedicated service account (access key) for the app and attaches the policy.
#
# Admin credentials (to perform the provisioning) are read from, in order of precedence:
# - env MINIO_ADMIN_USER / MINIO_ADMIN_PASS
# - pass: audioblume/minio_admin_user, audioblume/minio_admin_pass
#
# The app's own credentials are read from pass (audioblume/minio_access_key,
# audioblume/minio_secret_key); if absent they are generated and stored there, so that
# deploy.sh can render them into the app config. Errors are never suppressed.
set -euo pipefail
ENDPOINT="https://caveman.kosherinata.internal:9000"
BUCKET="audioblume-blobs"
POLICY="audioblume-rw"
SECRET_PREFIX="audioblume"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
while [[ $# -gt 0 ]]; do
case "$1" in
--endpoint) ENDPOINT="$2"; shift 2 ;;
--bucket) BUCKET="$2"; shift 2 ;;
--policy) POLICY="$2"; shift 2 ;;
*) die "unknown argument: $1" ;;
esac
done
command -v mc >/dev/null || die "the MinIO client 'mc' is required"
command -v pass >/dev/null || die "'pass' is required for secret storage"
# --- admin credentials -------------------------------------------------------------------
ADMIN_USER="${MINIO_ADMIN_USER:-$(pass show "${SECRET_PREFIX}/minio_admin_user" 2>/dev/null || true)}"
ADMIN_PASS="${MINIO_ADMIN_PASS:-$(pass show "${SECRET_PREFIX}/minio_admin_pass" 2>/dev/null || true)}"
[[ -n "${ADMIN_USER}" && -n "${ADMIN_PASS}" ]] \
|| die "admin credentials not found (set MINIO_ADMIN_USER/PASS or store in pass)"
ALIAS="audioblume-bootstrap"
info "configuring mc alias for ${ENDPOINT}"
mc alias set "${ALIAS}" "${ENDPOINT}" "${ADMIN_USER}" "${ADMIN_PASS}" >/dev/null
# --- bucket ------------------------------------------------------------------------------
info "ensuring bucket ${BUCKET}"
mc mb --ignore-existing "${ALIAS}/${BUCKET}"
# --- policy ------------------------------------------------------------------------------
policy_file="$(mktemp)"
trap 'rm -f "${policy_file}"' EXIT
sed -e "s|{{BUCKET}}|${BUCKET}|g" "${SCRIPT_DIR}/audioblume-policy.json.tmpl" > "${policy_file}"
if mc admin policy info "${ALIAS}" "${POLICY}" >/dev/null 2>&1; then
info "policy ${POLICY} already exists; leaving as-is (remove it manually to re-apply the template)"
else
info "creating policy ${POLICY}"
mc admin policy create "${ALIAS}" "${POLICY}" "${policy_file}"
fi
# --- app service account -----------------------------------------------------------------
APP_ACCESS="$(pass show "${SECRET_PREFIX}/minio_access_key" 2>/dev/null || true)"
APP_SECRET="$(pass show "${SECRET_PREFIX}/minio_secret_key" 2>/dev/null || true)"
if [[ -z "${APP_ACCESS}" ]]; then
APP_ACCESS="audioblume-app"
info "generating app access key: ${APP_ACCESS}"
printf '%s\n' "${APP_ACCESS}" | pass insert -m -f "${SECRET_PREFIX}/minio_access_key" >/dev/null
fi
if [[ -z "${APP_SECRET}" ]]; then
APP_SECRET="$(openssl rand -base64 30 | tr -d '/+=' | head -c 40)"
info "generating app secret key (stored in pass: ${SECRET_PREFIX}/minio_secret_key)"
printf '%s\n' "${APP_SECRET}" | pass insert -m -f "${SECRET_PREFIX}/minio_secret_key" >/dev/null
fi
if mc admin user info "${ALIAS}" "${APP_ACCESS}" >/dev/null 2>&1; then
info "service account ${APP_ACCESS} already exists"
else
info "creating service account ${APP_ACCESS}"
mc admin user add "${ALIAS}" "${APP_ACCESS}" "${APP_SECRET}"
fi
# Attach the policy only if not already attached.
if mc admin user info "${ALIAS}" "${APP_ACCESS}" | grep -qw "${POLICY}"; then
info "policy ${POLICY} already attached to ${APP_ACCESS}"
else
info "attaching policy ${POLICY} to ${APP_ACCESS}"
mc admin policy attach "${ALIAS}" "${POLICY}" --user "${APP_ACCESS}"
fi
info "MinIO bootstrap complete for bucket ${BUCKET} on ${ENDPOINT}"
info "app credentials are in pass: ${SECRET_PREFIX}/minio_access_key, ${SECRET_PREFIX}/minio_secret_key"

View File

@@ -0,0 +1,38 @@
# Reverse proxy for audioblume. TLS terminates here; the API binds loopback on the same host.
# Public DNS is unproxied (no Cloudflare origin-pull). Adjust server_name / cert paths per site.
upstream audioblume_api {
server 127.0.0.1:8080;
keepalive 16;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name audioblume.example.com;
# Host certificate issued by the internal step-ca (renewed continuously).
ssl_certificate /etc/pki/tls/misc/HOSTFQDN.pem;
ssl_certificate_key /etc/pki/tls/private/HOSTFQDN.pem;
# Prefer post-quantum key exchange where the client supports it; classical fallback for
# external peers that do not.
ssl_protocols TLSv1.3;
ssl_ecdh_curve X25519MLKEM768:X25519:secp256r1;
# Uploads can be large; let them stream through to the API rather than buffering in nginx.
client_max_body_size 200m;
proxy_request_buffering off;
location / {
proxy_pass http://audioblume_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_read_timeout 120s;
}
}

33
asset/sql/bootstrap.sql Normal file
View File

@@ -0,0 +1,33 @@
-- Idempotent role and database bootstrap for audioblume.
--
-- Run once on the PRIMARY Postgres server (magrathea); replication carries the roles to the
-- standby (frankie). Roles authenticate via mTLS client certificates (cert CN -> role mapped
-- in pg_ident.conf.d/), so they have NOLOGIN passwords and rely on the `cert` auth method.
--
-- psql -h magrathea.kosherinata.internal -d postgres -f bootstrap.sql
--
-- The cert-CN -> role mapping (pg_ident.conf.d/<host-fqdn>.conf) and the matching pg_hba
-- entry are installed by deploy.sh on both servers.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'audioblume_rw') THEN
CREATE ROLE audioblume_rw LOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'audioblume_ro') THEN
CREATE ROLE audioblume_ro LOGIN;
END IF;
END
$$;
-- Databases (CREATE DATABASE cannot run inside the DO block / a transaction).
SELECT 'CREATE DATABASE audioblume OWNER audioblume_rw'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'audioblume')\gexec
SELECT 'CREATE DATABASE audioblume_dev OWNER audioblume_rw'
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'audioblume_dev')\gexec
-- Grant the read-only role connect + usage. Table-level SELECT grants are applied after
-- migrations run (a post-migrate step in deploy.sh), since the tables do not exist yet here.
GRANT CONNECT ON DATABASE audioblume TO audioblume_ro, audioblume_rw;
GRANT CONNECT ON DATABASE audioblume_dev TO audioblume_ro, audioblume_rw;

View File

@@ -0,0 +1,7 @@
[Unit]
Description=Restart audioblume-api on host certificate rotation
[Service]
Type=oneshot
# try-restart is a no-op if the service is not currently running.
ExecStart=/bin/systemctl try-restart audioblume-api.service

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Watch host certificate for audioblume-api
[Path]
# The host certificate is renewed continuously (24h expiry); apply it with a restart.
# deploy.sh substitutes {{HOST_FQDN}} with the target host's fully-qualified name, since
# certs are named for the FQDN and systemd has no FQDN specifier (%H is the short name).
PathChanged=/etc/pki/tls/misc/{{HOST_FQDN}}.pem
Unit=audioblume-api-cert-reload.service
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,40 @@
[Unit]
Description=audioblume REST/JSON API
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=audioblume
Group=audioblume
ExecStart=/usr/local/bin/audioblume-api --config /etc/audioblume/config.toml
Restart=on-failure
RestartSec=2
# Host certificates rotate every 24h. The daemon does not hot-reload its TLS/DB state, so
# cert rotation is applied by a restart driven by audioblume-api-cert.path → -cert-reload.
# A stray SIGHUP is caught and ignored by the binary rather than terminating it.
# Hardening — relax individual knobs only if a feature genuinely requires it.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
# Writable state (upload spool + any local state).
ReadWritePaths=/var/lib/audioblume
# Network families the service needs.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,2 @@
#Type Name ID GECOS Home directory Shell
u audioblume - "audioblume service account" /var/lib/audioblume /usr/sbin/nologin

View File

@@ -0,0 +1,30 @@
[package]
name = "audioblume-api"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
description = "REST/JSON API daemon for audioblume."
[[bin]]
name = "audioblume-api"
path = "src/main.rs"
[dependencies]
audioblume-entities.workspace = true
audioblume-core.workspace = true
audioblume-data.workspace = true
axum.workspace = true
tokio.workspace = true
tower.workspace = true
tower-http.workspace = true
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
figment.workspace = true
serde.workspace = true
serde_json.workspace = true
clap.workspace = true
bytes.workspace = true
uuid.workspace = true

View File

@@ -0,0 +1,174 @@
//! Layered configuration: built-in defaults, then a TOML file, then `AUDIOBLUME_*` env vars.
use std::path::PathBuf;
use figment::Figment;
use figment::providers::{Env, Format, Serialized, Toml};
use serde::{Deserialize, Serialize};
use audioblume_core::CoreConfig;
use audioblume_data::{PgSettings, S3Settings};
/// Top-level application configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
/// TCP address to bind, e.g. `127.0.0.1:8080`.
pub bind: String,
/// Maximum accepted upload size in bytes.
pub max_upload_bytes: usize,
/// `auto` (pretty on a TTY, JSON under journald), `json`, or `pretty`.
pub log_format: String,
/// Database settings.
pub database: DbConfig,
/// Object-store settings.
pub storage: StorageConfig,
/// Catalog / session tuning.
pub catalog: CatalogConfig,
}
/// Database connection settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DbConfig {
/// Host.
pub host: String,
/// Port.
pub port: u16,
/// Database name.
pub database: String,
/// Role / username.
pub user: String,
/// Development-only password (omit in production; mTLS is used instead).
#[serde(default)]
pub password: Option<String>,
/// `disable` | `require` | `verify-ca` | `verify-full`.
pub sslmode: String,
/// Root CA bundle path.
#[serde(default)]
pub ssl_root_cert: Option<PathBuf>,
/// Client certificate path (mTLS).
#[serde(default)]
pub ssl_client_cert: Option<PathBuf>,
/// Client key path (mTLS).
#[serde(default)]
pub ssl_client_key: Option<PathBuf>,
/// Connection pool size.
pub max_connections: u32,
}
/// Object-store (MinIO / S3) settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageConfig {
/// Endpoint URL.
pub endpoint: String,
/// Region label.
pub region: String,
/// Access key.
pub access_key: String,
/// Secret key.
pub secret_key: String,
/// Bucket holding all blobs.
pub bucket: String,
/// Whether to create the bucket on startup if missing (true for dev, false in prod).
#[serde(default)]
pub create_bucket: bool,
}
/// Catalog and session tuning knobs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CatalogConfig {
/// Session lifetime in seconds.
pub session_ttl_seconds: i64,
/// Trigram similarity threshold for work clustering.
pub fuzzy_match_threshold: f32,
/// TTL applied to presigned download URLs.
pub download_url_ttl_seconds: u64,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
bind: "127.0.0.1:8080".to_string(),
max_upload_bytes: 200 * 1024 * 1024,
log_format: "auto".to_string(),
database: DbConfig {
host: "localhost".to_string(),
port: 5432,
database: "audioblume".to_string(),
user: "postgres".to_string(),
password: None,
sslmode: "disable".to_string(),
ssl_root_cert: None,
ssl_client_cert: None,
ssl_client_key: None,
max_connections: 10,
},
storage: StorageConfig {
endpoint: "http://localhost:9000".to_string(),
region: "us-east-1".to_string(),
access_key: "minioadmin".to_string(),
secret_key: "minioadmin".to_string(),
bucket: "audioblume-blobs".to_string(),
create_bucket: false,
},
catalog: CatalogConfig {
session_ttl_seconds: 60 * 60 * 24 * 30,
fuzzy_match_threshold: 0.6,
download_url_ttl_seconds: 300,
},
}
}
}
impl AppConfig {
/// Load configuration from defaults, an optional TOML file, then `AUDIOBLUME_*` env vars
/// (nested keys separated by `__`, e.g. `AUDIOBLUME_DATABASE__HOST`).
pub fn load(path: Option<&std::path::Path>) -> anyhow::Result<Self> {
let mut fig = Figment::from(Serialized::defaults(AppConfig::default()));
if let Some(p) = path {
fig = fig.merge(Toml::file(p));
}
fig = fig.merge(Env::prefixed("AUDIOBLUME_").split("__"));
Ok(fig.extract()?)
}
/// Project to the data crate's Postgres settings.
#[must_use]
pub fn pg_settings(&self) -> PgSettings {
let d = &self.database;
PgSettings {
host: d.host.clone(),
port: d.port,
database: d.database.clone(),
user: d.user.clone(),
password: d.password.clone(),
sslmode: d.sslmode.clone(),
ssl_root_cert: d.ssl_root_cert.clone(),
ssl_client_cert: d.ssl_client_cert.clone(),
ssl_client_key: d.ssl_client_key.clone(),
max_connections: d.max_connections,
}
}
/// Project to the data crate's object-store settings.
#[must_use]
pub fn s3_settings(&self) -> S3Settings {
let s = &self.storage;
S3Settings {
endpoint: s.endpoint.clone(),
region: s.region.clone(),
access_key: s.access_key.clone(),
secret_key: s.secret_key.clone(),
}
}
/// Project to the core configuration.
#[must_use]
pub fn core_config(&self) -> CoreConfig {
CoreConfig {
session_ttl_seconds: self.catalog.session_ttl_seconds,
fuzzy_match_threshold: self.catalog.fuzzy_match_threshold,
blob_bucket: self.storage.bucket.clone(),
download_url_ttl_seconds: self.catalog.download_url_ttl_seconds,
}
}
}

View File

@@ -0,0 +1,65 @@
//! HTTP error type that maps [`CoreError`] onto status codes and JSON bodies.
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
use audioblume_core::CoreError;
/// An HTTP error carrying a status code and a client-facing message.
pub struct ApiError {
status: StatusCode,
message: String,
}
impl ApiError {
/// Construct an error with an explicit status and message.
#[must_use]
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self {
status,
message: message.into(),
}
}
/// A 400 Bad Request with the given message.
#[must_use]
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, message)
}
/// A 401 Unauthorized with the given message.
#[must_use]
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::new(StatusCode::UNAUTHORIZED, message)
}
}
impl From<CoreError> for ApiError {
fn from(err: CoreError) -> Self {
let status = match &err {
CoreError::NotFound => StatusCode::NOT_FOUND,
CoreError::Unauthorized => StatusCode::UNAUTHORIZED,
CoreError::Forbidden => StatusCode::FORBIDDEN,
CoreError::Conflict(_) => StatusCode::CONFLICT,
CoreError::Validation(_) => StatusCode::BAD_REQUEST,
CoreError::Storage(_)
| CoreError::Repo(_)
| CoreError::Epub(_)
| CoreError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
// Internal failures are logged in full but reported generically to the client.
if status == StatusCode::INTERNAL_SERVER_ERROR {
tracing::error!(error = %err, "internal error serving request");
return Self::new(status, "internal error");
}
Self::new(status, err.to_string())
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.status, Json(json!({ "error": self.message }))).into_response()
}
}

View File

@@ -0,0 +1,76 @@
//! Authentication extractors.
use axum::extract::FromRequestParts;
use axum::http::header::AUTHORIZATION;
use axum::http::request::Parts;
use audioblume_core::auth;
use audioblume_entities::ids::UserId;
use crate::error::ApiError;
use crate::state::AppState;
/// Pull a `Bearer` token out of the `Authorization` header.
fn bearer_token(parts: &Parts) -> Option<String> {
parts
.headers
.get(AUTHORIZATION)?
.to_str()
.ok()?
.strip_prefix("Bearer ")
.map(str::to_string)
}
/// An authenticated user. Rejects with 401 if no valid token is present.
pub struct AuthUser(pub UserId);
impl FromRequestParts<AppState> for AuthUser {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let token = bearer_token(parts)
.ok_or_else(|| ApiError::unauthorized("missing or malformed bearer token"))?;
let user = auth::authenticate(&state.ctx, &token).await?;
Ok(AuthUser(user))
}
}
/// An optionally-authenticated caller. Absent header → anonymous (`None`); a present token
/// must be valid, otherwise the request is rejected with 401.
pub struct MaybeUser(pub Option<UserId>);
impl FromRequestParts<AppState> for MaybeUser {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
match bearer_token(parts) {
None => Ok(MaybeUser(None)),
Some(token) => {
let user = auth::authenticate(&state.ctx, &token).await?;
Ok(MaybeUser(Some(user)))
}
}
}
}
/// The raw bearer token (required), used by logout to revoke the presented session.
pub struct BearerToken(pub String);
impl FromRequestParts<AppState> for BearerToken {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
_state: &AppState,
) -> Result<Self, Self::Rejection> {
let token = bearer_token(parts)
.ok_or_else(|| ApiError::unauthorized("missing or malformed bearer token"))?;
Ok(BearerToken(token))
}
}

View File

@@ -0,0 +1,185 @@
//! Request handlers. Each delegates straight to the core logic — no business rules here.
use axum::Json;
use axum::body::Bytes;
use axum::extract::{Path, Query, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Redirect, Response};
use uuid::Uuid;
use audioblume_core::{access, auth, catalog, crypto, upload};
use audioblume_entities::ids::{EditionId, WorkId};
use audioblume_entities::{LoginRequest, PageReq, SignupRequest};
use crate::error::ApiError;
use crate::extract::{AuthUser, BearerToken, MaybeUser};
use crate::state::AppState;
/// Liveness probe.
pub async fn health() -> impl IntoResponse {
(StatusCode::OK, "ok")
}
/// `POST /v1/auth/signup`
pub async fn signup(
State(s): State<AppState>,
Json(req): Json<SignupRequest>,
) -> Result<Response, ApiError> {
let user = auth::signup(&s.ctx, req).await?;
Ok((StatusCode::CREATED, Json(user.to_public())).into_response())
}
/// `POST /v1/auth/login`
pub async fn login(
State(s): State<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Response, ApiError> {
let resp = auth::login(&s.ctx, req).await?;
Ok(Json(resp).into_response())
}
/// `POST /v1/auth/logout`
pub async fn logout(
State(s): State<AppState>,
BearerToken(token): BearerToken,
) -> Result<Response, ApiError> {
auth::logout(&s.ctx, &token).await?;
Ok(StatusCode::NO_CONTENT.into_response())
}
/// `GET /v1/me`
pub async fn me(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<Response, ApiError> {
let user = s
.ctx
.users
.find_by_id(uid)
.await?
.ok_or(audioblume_core::CoreError::NotFound)?;
Ok(Json(user.to_public()).into_response())
}
/// `POST /v1/editions` — upload an EPUB.
pub async fn upload_edition(
State(s): State<AppState>,
AuthUser(uid): AuthUser,
body: Bytes,
) -> Result<Response, ApiError> {
if body.len() < 4 || &body[0..4] != b"PK\x03\x04" {
return Err(ApiError::bad_request("body is not a ZIP/EPUB file"));
}
let sha = crypto::content_sha256(&body);
let resp = upload::ingest_epub(&s.ctx, uid, sha, body.to_vec()).await?;
Ok((StatusCode::CREATED, Json(resp)).into_response())
}
/// `GET /v1/catalog`
pub async fn list_catalog(
State(s): State<AppState>,
MaybeUser(user): MaybeUser,
Query(page): Query<PageReq>,
) -> Result<Response, ApiError> {
let result = catalog::browse(&s.ctx, user, page).await?;
Ok(Json(result).into_response())
}
/// `GET /v1/works/{id}`
pub async fn work_detail(
State(s): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Response, ApiError> {
let result = catalog::work_detail(&s.ctx, WorkId::from(id)).await?;
Ok(Json(result).into_response())
}
/// `GET /v1/editions/{id}`
pub async fn edition_detail(
State(s): State<AppState>,
MaybeUser(user): MaybeUser,
Path(id): Path<Uuid>,
) -> Result<Response, ApiError> {
let result = catalog::edition_detail(&s.ctx, user, EditionId::from(id)).await?;
Ok(Json(result).into_response())
}
/// `GET /v1/editions/{id}/cover` — public; covers are browsable for everyone.
pub async fn edition_cover(
State(s): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Response, ApiError> {
let edition = s
.ctx
.catalog
.get_edition(EditionId::from(id))
.await?
.ok_or(audioblume_core::CoreError::NotFound)?;
let sha = edition
.cover_blob_sha256
.ok_or(audioblume_core::CoreError::NotFound)?;
let blob = s
.ctx
.blobs
.get(&sha)
.await?
.ok_or(audioblume_core::CoreError::NotFound)?;
let bytes = s
.ctx
.blob_store
.get(&blob.storage_key, &blob.storage_bucket)
.await?;
let mime = sniff_image_mime(&bytes);
Ok(([(header::CONTENT_TYPE, mime)], bytes).into_response())
}
/// `GET /v1/editions/{id}/download` — gated by ownership / copyright; redirects to a
/// short-lived presigned object-store URL on success.
pub async fn download_edition(
State(s): State<AppState>,
MaybeUser(user): MaybeUser,
Path(id): Path<Uuid>,
) -> Result<Response, ApiError> {
let edition_id = EditionId::from(id);
let decision = access::can_access(&s.ctx, user, edition_id).await?;
if !decision.allowed {
return Err(ApiError::new(
StatusCode::FORBIDDEN,
"you do not have access to this title",
));
}
let edition = s
.ctx
.catalog
.get_edition(edition_id)
.await?
.ok_or(audioblume_core::CoreError::NotFound)?;
let blob = s
.ctx
.blobs
.get(&edition.blob_sha256)
.await?
.ok_or(audioblume_core::CoreError::NotFound)?;
let url = s
.ctx
.blob_store
.presign_get(
&blob.storage_key,
&blob.storage_bucket,
s.ctx.config.download_url_ttl_seconds,
)
.await?;
Ok(Redirect::temporary(&url).into_response())
}
/// Best-effort image MIME sniff from magic bytes; falls back to a generic type.
fn sniff_image_mime(bytes: &[u8]) -> &'static str {
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
"image/jpeg"
} else if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
"image/png"
} else if bytes.starts_with(b"GIF8") {
"image/gif"
} else if bytes.starts_with(b"RIFF") && bytes.len() > 11 && &bytes[8..12] == b"WEBP" {
"image/webp"
} else {
"application/octet-stream"
}
}

View File

@@ -0,0 +1,131 @@
//! The audioblume REST/JSON API daemon.
#![forbid(unsafe_code)]
mod config;
mod error;
mod extract;
mod handlers;
mod router;
mod state;
use std::path::PathBuf;
use clap::Parser;
use tokio::net::TcpListener;
use tracing_subscriber::EnvFilter;
use audioblume_data::{DataStack, S3BlobStore};
use crate::config::AppConfig;
use crate::router::router;
use crate::state::AppState;
/// Command-line arguments.
#[derive(Debug, Parser)]
#[command(
name = "audioblume-api",
version,
about = "audioblume REST/JSON API daemon"
)]
struct Cli {
/// Path to the configuration TOML file.
#[arg(long, env = "AUDIOBLUME_CONFIG")]
config: Option<PathBuf>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let config = AppConfig::load(cli.config.as_deref())?;
init_tracing(&config.log_format);
let stack = DataStack::connect(
&config.pg_settings(),
&config.s3_settings(),
config.core_config(),
)
.await?;
if config.storage.create_bucket {
let store = S3BlobStore::new(&config.s3_settings());
if let Err(e) = store.ensure_bucket(&config.storage.bucket).await {
tracing::warn!(error = %e, bucket = %config.storage.bucket, "could not ensure bucket exists");
}
}
let state = AppState {
ctx: stack.ctx.clone(),
max_upload_bytes: config.max_upload_bytes,
};
install_sighup_handler();
let listener = TcpListener::bind(&config.bind).await?;
tracing::info!(bind = %config.bind, "audioblume-api listening");
axum::serve(listener, router(state))
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
/// Override the default SIGHUP action (terminate). Host certificates are applied by a unit
/// restart, not an in-process reload, so a stray SIGHUP is logged and ignored rather than
/// killing the daemon.
fn install_sighup_handler() {
#[cfg(unix)]
tokio::spawn(async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()) {
Ok(mut hup) => {
while hup.recv().await.is_some() {
tracing::info!("received SIGHUP; ignoring (restart to apply cert changes)");
}
}
Err(e) => tracing::error!(error = %e, "failed to install SIGHUP handler"),
}
});
}
/// Initialize structured logging: JSON under journald (or when `log_format = "json"`),
/// pretty otherwise.
fn init_tracing(log_format: &str) {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,audioblume_api=debug"));
let use_json = log_format == "json"
|| (log_format == "auto" && std::env::var_os("JOURNAL_STREAM").is_some());
let builder = tracing_subscriber::fmt().with_env_filter(filter);
if use_json {
builder.json().init();
} else {
builder.init();
}
}
/// Resolve when SIGINT (Ctrl-C) or SIGTERM is received, so axum can drain in-flight requests.
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut sig) => {
sig.recv().await;
}
Err(e) => tracing::error!(error = %e, "failed to install SIGTERM handler"),
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => {},
() = terminate => {},
}
tracing::info!("shutdown signal received, draining");
}

View File

@@ -0,0 +1,36 @@
//! Route table.
use axum::Router;
use axum::extract::DefaultBodyLimit;
use axum::routing::{get, post};
use tower_http::trace::TraceLayer;
use crate::handlers;
use crate::state::AppState;
/// Build the application router. The upload route gets a larger body limit; everything else
/// keeps axum's small default.
pub fn router(state: AppState) -> Router {
let max_upload = state.max_upload_bytes;
Router::new()
.route("/health", get(handlers::health))
.route("/v1/auth/signup", post(handlers::signup))
.route("/v1/auth/login", post(handlers::login))
.route("/v1/auth/logout", post(handlers::logout))
.route("/v1/me", get(handlers::me))
.route(
"/v1/editions",
post(handlers::upload_edition).layer(DefaultBodyLimit::max(max_upload)),
)
.route("/v1/catalog", get(handlers::list_catalog))
.route("/v1/works/{id}", get(handlers::work_detail))
.route("/v1/editions/{id}", get(handlers::edition_detail))
.route("/v1/editions/{id}/cover", get(handlers::edition_cover))
.route(
"/v1/editions/{id}/download",
get(handlers::download_edition),
)
.layer(TraceLayer::new_for_http())
.with_state(state)
}

View File

@@ -0,0 +1,12 @@
//! Shared application state handed to every handler.
use audioblume_core::Ctx;
/// Cheaply-cloneable handler state (the inner [`Ctx`] is all `Arc`s).
#[derive(Clone)]
pub struct AppState {
/// The wired-up business-logic context.
pub ctx: Ctx,
/// Maximum accepted upload size in bytes.
pub max_upload_bytes: usize,
}

View File

@@ -0,0 +1,25 @@
[package]
name = "audioblume-cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
description = "Operator / admin CLI for audioblume."
[[bin]]
name = "audioblume-cli"
path = "src/main.rs"
[dependencies]
audioblume-entities.workspace = true
audioblume-core.workspace = true
audioblume-data.workspace = true
clap.workspace = true
anyhow.workspace = true
tokio.workspace = true
figment.workspace = true
serde.workspace = true
sqlx.workspace = true
uuid.workspace = true
chrono.workspace = true

View File

@@ -0,0 +1,163 @@
//! Configuration loading for the CLI.
//!
//! Reads the same `config.toml` / `AUDIOBLUME_*` environment as the API, but only the keys
//! the CLI needs (database, storage, catalog); unknown keys are ignored.
use std::path::PathBuf;
use figment::Figment;
use figment::providers::{Env, Format, Serialized, Toml};
use serde::{Deserialize, Serialize};
use audioblume_core::CoreConfig;
use audioblume_data::{PgSettings, S3Settings};
/// The subset of application configuration the CLI consumes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliConfig {
/// Database settings.
pub database: DbConfig,
/// Object-store settings.
pub storage: StorageConfig,
/// Catalog / session tuning.
pub catalog: CatalogConfig,
}
/// Database connection settings (mirror of the API's).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DbConfig {
/// Host.
pub host: String,
/// Port.
pub port: u16,
/// Database name.
pub database: String,
/// Role / username.
pub user: String,
/// Development-only password.
#[serde(default)]
pub password: Option<String>,
/// SSL mode.
pub sslmode: String,
/// Root CA bundle path.
#[serde(default)]
pub ssl_root_cert: Option<PathBuf>,
/// Client certificate path.
#[serde(default)]
pub ssl_client_cert: Option<PathBuf>,
/// Client key path.
#[serde(default)]
pub ssl_client_key: Option<PathBuf>,
/// Pool size.
pub max_connections: u32,
}
/// Object-store settings (mirror of the API's).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageConfig {
/// Endpoint URL.
pub endpoint: String,
/// Region label.
pub region: String,
/// Access key.
pub access_key: String,
/// Secret key.
pub secret_key: String,
/// Bucket holding all blobs.
pub bucket: String,
}
/// Catalog and session tuning.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CatalogConfig {
/// Session lifetime in seconds.
pub session_ttl_seconds: i64,
/// Trigram similarity threshold.
pub fuzzy_match_threshold: f32,
/// Presigned download URL TTL.
pub download_url_ttl_seconds: u64,
}
impl Default for CliConfig {
fn default() -> Self {
Self {
database: DbConfig {
host: "localhost".to_string(),
port: 5432,
database: "audioblume".to_string(),
user: "postgres".to_string(),
password: None,
sslmode: "disable".to_string(),
ssl_root_cert: None,
ssl_client_cert: None,
ssl_client_key: None,
max_connections: 5,
},
storage: StorageConfig {
endpoint: "http://localhost:9000".to_string(),
region: "us-east-1".to_string(),
access_key: "minioadmin".to_string(),
secret_key: "minioadmin".to_string(),
bucket: "audioblume-blobs".to_string(),
},
catalog: CatalogConfig {
session_ttl_seconds: 60 * 60 * 24 * 30,
fuzzy_match_threshold: 0.6,
download_url_ttl_seconds: 300,
},
}
}
}
impl CliConfig {
/// Load from defaults, optional TOML file, then `AUDIOBLUME_*` env vars.
pub fn load(path: Option<&std::path::Path>) -> anyhow::Result<Self> {
let mut fig = Figment::from(Serialized::defaults(CliConfig::default()));
if let Some(p) = path {
fig = fig.merge(Toml::file(p));
}
fig = fig.merge(Env::prefixed("AUDIOBLUME_").split("__"));
Ok(fig.extract()?)
}
/// Project to Postgres settings.
#[must_use]
pub fn pg_settings(&self) -> PgSettings {
let d = &self.database;
PgSettings {
host: d.host.clone(),
port: d.port,
database: d.database.clone(),
user: d.user.clone(),
password: d.password.clone(),
sslmode: d.sslmode.clone(),
ssl_root_cert: d.ssl_root_cert.clone(),
ssl_client_cert: d.ssl_client_cert.clone(),
ssl_client_key: d.ssl_client_key.clone(),
max_connections: d.max_connections,
}
}
/// Project to object-store settings.
#[must_use]
pub fn s3_settings(&self) -> S3Settings {
let s = &self.storage;
S3Settings {
endpoint: s.endpoint.clone(),
region: s.region.clone(),
access_key: s.access_key.clone(),
secret_key: s.secret_key.clone(),
}
}
/// Project to the core configuration.
#[must_use]
pub fn core_config(&self) -> CoreConfig {
CoreConfig {
session_ttl_seconds: self.catalog.session_ttl_seconds,
fuzzy_match_threshold: self.catalog.fuzzy_match_threshold,
blob_bucket: self.storage.bucket.clone(),
download_url_ttl_seconds: self.catalog.download_url_ttl_seconds,
}
}
}

View File

@@ -0,0 +1,398 @@
//! The audioblume operator / admin CLI.
#![forbid(unsafe_code)]
mod config;
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand, ValueEnum};
use sqlx::Row;
use uuid::Uuid;
use audioblume_core::{Ctx, crypto};
use audioblume_data::DataStack;
use audioblume_entities::ids::{EditionId, WorkId};
use audioblume_entities::{CopyrightStatus, Isbn13};
use crate::config::CliConfig;
#[derive(Debug, Parser)]
#[command(
name = "audioblume-cli",
version,
about = "audioblume operator / admin CLI"
)]
struct Cli {
/// Path to the configuration TOML file.
#[arg(long, env = "AUDIOBLUME_CONFIG", global = true)]
config: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Database maintenance.
#[command(subcommand)]
Db(DbCmd),
/// User account administration.
#[command(subcommand)]
User(UserCmd),
/// Copyright administration.
#[command(subcommand)]
Copyright(CopyrightCmd),
/// Work (logical title) administration.
#[command(subcommand)]
Work(WorkCmd),
/// Inspect an edition.
Edition {
/// Edition id.
#[arg(long)]
id: Uuid,
},
/// Blob / object-store administration.
#[command(subcommand)]
Blob(BlobCmd),
/// Session administration.
#[command(subcommand)]
Session(SessionCmd),
}
#[derive(Debug, Subcommand)]
enum DbCmd {
/// Run pending migrations.
Migrate,
}
#[derive(Debug, Subcommand)]
enum UserCmd {
/// Create a user account.
Create {
/// Email address.
#[arg(long)]
email: String,
/// Display name.
#[arg(long)]
name: Option<String>,
/// Password; if omitted, a random one is generated and printed.
#[arg(long)]
password: Option<String>,
/// Grant operator privileges.
#[arg(long)]
admin: bool,
},
/// Disable a user account.
Disable {
/// Email address.
#[arg(long)]
email: String,
},
}
#[derive(Debug, Subcommand)]
enum CopyrightCmd {
/// Set the copyright status of a work.
Set {
/// Work selector: a UUID, an ISBN-13, or a title to fuzzy-match.
#[arg(long)]
work: String,
/// New status.
#[arg(long, value_enum)]
status: CopyrightArg,
/// Optional note recording the rationale.
#[arg(long)]
note: Option<String>,
},
}
#[derive(Debug, Subcommand)]
enum WorkCmd {
/// Merge one work into another, re-pointing its editions.
Merge {
/// Work to absorb (will be deleted).
#[arg(long)]
src: Uuid,
/// Surviving work.
#[arg(long)]
dst: Uuid,
/// Reason for the merge.
#[arg(long)]
reason: Option<String>,
},
/// List works, newest first.
List {
/// Maximum rows.
#[arg(long, default_value_t = 50)]
limit: i64,
},
}
#[derive(Debug, Subcommand)]
enum BlobCmd {
/// Verify that DB blob records have matching objects in the store.
Verify {
/// Restrict to a single content address (hex SHA-256).
#[arg(long)]
sha: Option<String>,
},
}
#[derive(Debug, Subcommand)]
enum SessionCmd {
/// Revoke all active sessions for a user.
Revoke {
/// Email address.
#[arg(long)]
email: String,
},
}
#[derive(Debug, Clone, ValueEnum)]
enum CopyrightArg {
Unknown,
PublicDomain,
InCopyright,
LicensedForSale,
}
impl From<CopyrightArg> for CopyrightStatus {
fn from(a: CopyrightArg) -> Self {
match a {
CopyrightArg::Unknown => CopyrightStatus::Unknown,
CopyrightArg::PublicDomain => CopyrightStatus::PublicDomain,
CopyrightArg::InCopyright => CopyrightStatus::InCopyright,
CopyrightArg::LicensedForSale => CopyrightStatus::LicensedForSale,
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let config = CliConfig::load(cli.config.as_deref())?;
let stack = DataStack::connect(
&config.pg_settings(),
&config.s3_settings(),
config.core_config(),
)
.await
.context("connecting to backend")?;
match cli.command {
Command::Db(DbCmd::Migrate) => {
stack.migrate().await.context("running migrations")?;
println!("migrations applied");
}
Command::User(cmd) => user_cmd(&stack.ctx, cmd).await?,
Command::Copyright(CopyrightCmd::Set { work, status, note }) => {
let work_id = resolve_work(&stack.ctx, &work).await?;
stack
.ctx
.catalog
.set_copyright(work_id, status.into(), note.as_deref())
.await?;
println!("copyright status updated for work {work_id}");
}
Command::Work(cmd) => work_cmd(&stack, cmd).await?,
Command::Edition { id } => edition_show(&stack.ctx, EditionId::from(id)).await?,
Command::Blob(BlobCmd::Verify { sha }) => blob_verify(&stack, sha.as_deref()).await?,
Command::Session(SessionCmd::Revoke { email }) => {
let user = stack
.ctx
.users
.find_by_email(&email)
.await?
.with_context(|| format!("no user with email {email}"))?;
let n = stack.ctx.sessions.revoke_for_user(user.id).await?;
println!("revoked {n} session(s) for {email}");
}
}
Ok(())
}
async fn user_cmd(ctx: &Ctx, cmd: UserCmd) -> Result<()> {
match cmd {
UserCmd::Create {
email,
name,
password,
admin,
} => {
let generated = password.is_none();
let password = password.unwrap_or_else(|| crypto::generate_token().into_inner());
let hash = crypto::hash_password(&password)?;
let user = ctx
.users
.create(&email, name.as_deref(), &hash, admin)
.await?;
println!("created user {} ({})", user.email, user.id);
if admin {
println!(" role: admin");
}
if generated {
println!(" generated password: {password}");
}
}
UserCmd::Disable { email } => {
let user = ctx
.users
.find_by_email(&email)
.await?
.with_context(|| format!("no user with email {email}"))?;
ctx.users.set_status(user.id, "disabled").await?;
println!("disabled {email}");
}
}
Ok(())
}
async fn work_cmd(stack: &DataStack, cmd: WorkCmd) -> Result<()> {
match cmd {
WorkCmd::Merge { src, dst, reason } => {
stack
.ctx
.catalog
.merge_works(
WorkId::from(src),
WorkId::from(dst),
None,
reason.as_deref(),
)
.await?;
println!("merged work {src} into {dst}");
}
WorkCmd::List { limit } => {
let rows = sqlx::query(
r#"
SELECT id, canonical_title, canonical_author,
copyright_status::text AS cs, created_at
FROM works
ORDER BY created_at DESC
LIMIT $1
"#,
)
.bind(limit)
.fetch_all(&stack.pool)
.await?;
println!("{:<38} {:<16} title", "id", "copyright");
for r in rows {
let id: Uuid = r.get("id");
let title: String = r.get("canonical_title");
let author: Option<String> = r.get("canonical_author");
let cs: String = r.get("cs");
let author = author.unwrap_or_else(|| "".to_string());
println!("{id:<38} {cs:<16} {title}{author}");
}
}
}
Ok(())
}
async fn edition_show(ctx: &Ctx, id: EditionId) -> Result<()> {
let edition = ctx
.catalog
.get_edition(id)
.await?
.with_context(|| format!("no edition {id}"))?;
let work = ctx.catalog.get_work(edition.work_id).await?;
println!("edition {}", edition.id);
println!("work {}", edition.work_id);
if let Some(w) = &work {
println!(" title {}", w.canonical_title);
println!(" work copyright {:?}", w.copyright_status);
}
println!("blob {}", edition.blob_sha256);
println!("title {:?}", edition.title);
println!("author {:?}", edition.author);
println!("language {:?}", edition.language);
println!(
"isbn13 {:?}",
edition.isbn13.map(|i| i.as_str().to_string())
);
println!(
"cover {:?}",
edition.cover_blob_sha256.map(|s| s.to_hex())
);
println!("edition copyright {:?}", edition.copyright_status);
println!(
"extraction {} {:?}",
edition.extraction_status, edition.extraction_error
);
Ok(())
}
async fn blob_verify(stack: &DataStack, sha: Option<&str>) -> Result<()> {
let rows = if let Some(hex) = sha {
let bytes = hex_to_bytes(hex)?;
sqlx::query(
"SELECT sha256, kind::text AS kind, storage_key, storage_bucket FROM blobs WHERE sha256 = $1",
)
.bind(bytes)
.fetch_all(&stack.pool)
.await?
} else {
sqlx::query("SELECT sha256, kind::text AS kind, storage_key, storage_bucket FROM blobs")
.fetch_all(&stack.pool)
.await?
};
let mut missing = 0;
let total = rows.len();
for r in rows {
let sha_bytes: Vec<u8> = r.get("sha256");
let key: String = r.get("storage_key");
let bucket: String = r.get("storage_bucket");
let present = stack.ctx.blob_store.exists(&key, &bucket).await?;
if !present {
missing += 1;
println!("MISSING {} ({key})", hex_encode(&sha_bytes));
}
}
println!("checked {total} blob(s), {missing} missing from the object store");
if missing > 0 {
bail!("{missing} blob(s) missing");
}
Ok(())
}
/// Resolve a work selector (UUID, ISBN-13, or title) to a [`WorkId`].
async fn resolve_work(ctx: &Ctx, selector: &str) -> Result<WorkId> {
if let Ok(uuid) = selector.parse::<Uuid>() {
return Ok(WorkId::from(uuid));
}
if let Ok(isbn) = selector.parse::<Isbn13>() {
let works = ctx.catalog.find_works_by_isbn(&isbn).await?;
match works.as_slice() {
[single] => return Ok(single.id),
[] => bail!("no work has an edition with ISBN {isbn}"),
many => bail!("ISBN {isbn} matches {} works; specify a UUID", many.len()),
}
}
let matches = ctx
.catalog
.fuzzy_match_works(selector, None, ctx.config.fuzzy_match_threshold, 5)
.await?;
match matches.as_slice() {
[(work, _), ..] => Ok(work.id),
[] => bail!("no work matches title {selector:?}; specify a UUID"),
}
}
fn hex_to_bytes(hex: &str) -> Result<Vec<u8>> {
if hex.len() % 2 != 0 {
bail!("hex string has odd length");
}
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(Into::into))
.collect()
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}

View File

@@ -0,0 +1,23 @@
[package]
name = "audioblume-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
description = "Business logic and data-access ports for audioblume. No direct I/O."
[dependencies]
audioblume-entities.workspace = true
async-trait.workspace = true
thiserror.workspace = true
tracing.workspace = true
chrono.workspace = true
serde_json.workspace = true
argon2.workspace = true
sha2.workspace = true
rand.workspace = true
base64.workspace = true
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -0,0 +1,73 @@
//! The single access-decision chokepoint for reading edition content.
use audioblume_entities::ids::{EditionId, UserId};
use audioblume_entities::{
AccessDecision, AccessReason, CopyrightStatus, Edition, EntitlementSource, Work,
};
use crate::context::Ctx;
use crate::error::CoreError;
/// The effective copyright status for an edition: the per-edition override unless it is
/// `Unknown`, in which case the parent work's status applies.
#[must_use]
pub fn effective_status(edition: &Edition, work: &Work) -> CopyrightStatus {
match edition.copyright_status {
CopyrightStatus::Unknown => work.copyright_status,
other => other,
}
}
/// Decide whether `user` (or an anonymous caller, `None`) may read the content of `edition`.
///
/// Content is readable when the effective copyright status is public domain, or when the
/// user holds any entitlement to the edition. `Unknown`, `InCopyright`, and `LicensedForSale`
/// all require an entitlement.
pub async fn can_access(
ctx: &Ctx,
user: Option<UserId>,
edition_id: EditionId,
) -> Result<AccessDecision, CoreError> {
let edition = ctx
.catalog
.get_edition(edition_id)
.await?
.ok_or(CoreError::NotFound)?;
let work = ctx
.catalog
.get_work(edition.work_id)
.await?
.ok_or_else(|| CoreError::Internal("edition references a missing work".to_string()))?;
decide(ctx, user, &edition, &work).await
}
/// Decide access given already-loaded `edition` and `work`, avoiding a redundant fetch when
/// the caller already holds them (e.g. catalog listing, edition detail).
pub async fn decide(
ctx: &Ctx,
user: Option<UserId>,
edition: &Edition,
work: &Work,
) -> Result<AccessDecision, CoreError> {
if effective_status(edition, work).is_publicly_readable() {
return Ok(AccessDecision::allow(AccessReason::PublicDomain));
}
let Some(user) = user else {
return Ok(AccessDecision::deny());
};
match ctx
.entitlements
.entitlement_source(user, edition.id)
.await?
{
Some(EntitlementSource::Upload) => Ok(AccessDecision::allow(AccessReason::OwnedViaUpload)),
Some(EntitlementSource::Purchase) => {
Ok(AccessDecision::allow(AccessReason::OwnedViaPurchase))
}
Some(EntitlementSource::Grant) => Ok(AccessDecision::allow(AccessReason::GrantedByAdmin)),
None => Ok(AccessDecision::deny()),
}
}

View File

@@ -0,0 +1,110 @@
//! Account creation, login, logout, and request authentication.
use chrono::{Duration, Utc};
use audioblume_entities::ids::UserId;
use audioblume_entities::{LoginRequest, LoginResponse, SignupRequest, User};
use crate::context::Ctx;
use crate::crypto;
use crate::error::CoreError;
const MIN_PASSWORD_LEN: usize = 8;
/// Basic structural email validation: one `@`, non-empty local and domain parts, a `.` in
/// the domain. Full RFC validation is deliberately out of scope.
fn validate_email(email: &str) -> Result<(), CoreError> {
let trimmed = email.trim();
let parts: Vec<&str> = trimmed.split('@').collect();
let valid = parts.len() == 2
&& !parts[0].is_empty()
&& parts[1].contains('.')
&& !parts[1].starts_with('.')
&& !parts[1].ends_with('.');
if valid {
Ok(())
} else {
Err(CoreError::Validation(
audioblume_entities::EntityError::InvalidEmail,
))
}
}
/// Create a new account. Validates the email and password policy, hashes the password with
/// Argon2id, and persists the user. Returns the created [`User`].
pub async fn signup(ctx: &Ctx, req: SignupRequest) -> Result<User, CoreError> {
validate_email(&req.email)?;
if req.password.len() < MIN_PASSWORD_LEN {
return Err(CoreError::Validation(
audioblume_entities::EntityError::InvalidPassword("must be at least 8 characters"),
));
}
if ctx.users.find_by_email(req.email.trim()).await?.is_some() {
return Err(CoreError::Conflict("email already registered".to_string()));
}
let hash = crypto::hash_password(&req.password)?;
let display = req
.display_name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
ctx.users
.create(req.email.trim(), display, &hash, false)
.await
}
/// Authenticate with email + password and issue a session token. The raw token is returned
/// once in the [`LoginResponse`]; only its hash is stored.
pub async fn login(ctx: &Ctx, req: LoginRequest) -> Result<LoginResponse, CoreError> {
let user = ctx
.users
.find_by_email(req.email.trim())
.await?
.ok_or(CoreError::Unauthorized)?;
// Always run the verify to keep timing roughly constant whether or not the user exists
// is a nice-to-have; here we simply verify against the real hash.
if !crypto::verify_password(&req.password, &user.password_hash)? {
return Err(CoreError::Unauthorized);
}
if !user.is_active() {
return Err(CoreError::Forbidden);
}
let token = crypto::generate_token();
let token_hash = crypto::hash_token(token.as_str());
let expires_at = Utc::now() + Duration::seconds(ctx.config.session_ttl_seconds);
ctx.sessions
.create(user.id, &token_hash, expires_at)
.await?;
Ok(LoginResponse {
token,
expires_at,
user: user.to_public(),
})
}
/// Revoke the session identified by the given raw token. Idempotent.
pub async fn logout(ctx: &Ctx, raw_token: &str) -> Result<(), CoreError> {
let token_hash = crypto::hash_token(raw_token);
ctx.sessions.revoke_by_token_hash(&token_hash).await
}
/// Resolve a raw bearer token to the authenticated user id, touching the session's
/// last-seen timestamp. Returns [`CoreError::Unauthorized`] if the token is unknown,
/// expired, or revoked.
pub async fn authenticate(ctx: &Ctx, raw_token: &str) -> Result<UserId, CoreError> {
let token_hash = crypto::hash_token(raw_token);
let now = Utc::now();
let (session_id, user_id) = ctx
.sessions
.find_active(&token_hash, now)
.await?
.ok_or(CoreError::Unauthorized)?;
// Best-effort touch; a failure here should not deny an otherwise valid request.
if let Err(e) = ctx.sessions.touch(session_id, now).await {
tracing::warn!(error = %e, "failed to touch session");
}
Ok(user_id)
}

View File

@@ -0,0 +1,84 @@
//! Catalog browsing and detail views (bookstore-style: metadata visible to all).
use audioblume_entities::ids::{EditionId, UserId, WorkId};
use audioblume_entities::{CatalogItem, EditionDetail, Page, PageReq, WorkDetail};
use crate::access::{decide, effective_status};
use crate::context::Ctx;
use crate::error::CoreError;
/// A page of catalog listings. Metadata is returned to everyone; each item carries the
/// caller's access decision so the client knows whether the content is unlocked.
pub async fn browse(
ctx: &Ctx,
user: Option<UserId>,
page: PageReq,
) -> Result<Page<CatalogItem>, CoreError> {
let (editions, total) = ctx.catalog.list_catalog(page).await?;
let mut items = Vec::with_capacity(editions.len());
for edition in editions {
let work = ctx
.catalog
.get_work(edition.work_id)
.await?
.ok_or_else(|| CoreError::Internal("edition references a missing work".to_string()))?;
let access = decide(ctx, user, &edition, &work).await?;
items.push(CatalogItem {
edition_id: edition.id,
work_id: edition.work_id,
title: edition
.title
.clone()
.or_else(|| Some(work.canonical_title.clone())),
author: edition
.author
.clone()
.or_else(|| work.canonical_author.clone()),
language: edition.language.clone(),
has_cover: edition.cover_blob_sha256.is_some(),
copyright_status: effective_status(&edition, &work),
access,
});
}
Ok(Page {
items,
total,
offset: page.offset,
limit: page.limit,
})
}
/// Detailed view of a single edition, including the caller's access decision.
pub async fn edition_detail(
ctx: &Ctx,
user: Option<UserId>,
edition_id: EditionId,
) -> Result<EditionDetail, CoreError> {
let edition = ctx
.catalog
.get_edition(edition_id)
.await?
.ok_or(CoreError::NotFound)?;
let work = ctx
.catalog
.get_work(edition.work_id)
.await?
.ok_or_else(|| CoreError::Internal("edition references a missing work".to_string()))?;
let access = decide(ctx, user, &edition, &work).await?;
Ok(EditionDetail {
edition,
work,
access,
})
}
/// Detailed view of a work and all its editions.
pub async fn work_detail(ctx: &Ctx, work_id: WorkId) -> Result<WorkDetail, CoreError> {
let work = ctx
.catalog
.get_work(work_id)
.await?
.ok_or(CoreError::NotFound)?;
let editions = ctx.catalog.list_editions_for_work(work_id).await?;
Ok(WorkDetail { work, editions })
}

View File

@@ -0,0 +1,55 @@
//! The dependency-injection context that bundles the port implementations.
//!
//! Binaries construct a [`Ctx`] once at startup from the `data` adapters and pass `&Ctx` to
//! the business-logic functions. Tests construct one from in-memory fakes.
use std::sync::Arc;
use crate::ports::{
BlobRepo, BlobStore, CatalogRepo, EntitlementRepo, EpubExtractor, SessionRepo, UserRepo,
};
/// Non-secret tuning knobs for core logic.
#[derive(Debug, Clone)]
pub struct CoreConfig {
/// How long a freshly issued session is valid, in seconds.
pub session_ttl_seconds: i64,
/// Trigram similarity threshold (0.01.0) for clustering an edition into an existing work.
pub fuzzy_match_threshold: f32,
/// The object-store bucket that holds all blobs.
pub blob_bucket: String,
/// TTL applied to presigned download URLs, in seconds.
pub download_url_ttl_seconds: u64,
}
impl Default for CoreConfig {
fn default() -> Self {
Self {
session_ttl_seconds: 60 * 60 * 24 * 30, // 30 days
fuzzy_match_threshold: 0.6,
blob_bucket: "audioblume-blobs".to_string(),
download_url_ttl_seconds: 300,
}
}
}
/// The wired-up set of ports plus configuration. Cheaply cloneable (everything is `Arc`).
#[derive(Clone)]
pub struct Ctx {
/// User account persistence.
pub users: Arc<dyn UserRepo>,
/// Session persistence.
pub sessions: Arc<dyn SessionRepo>,
/// Blob record persistence.
pub blobs: Arc<dyn BlobRepo>,
/// Object store.
pub blob_store: Arc<dyn BlobStore>,
/// Catalog persistence and queries.
pub catalog: Arc<dyn CatalogRepo>,
/// Entitlement persistence.
pub entitlements: Arc<dyn EntitlementRepo>,
/// EPUB extractor.
pub epub: Arc<dyn EpubExtractor>,
/// Tuning knobs.
pub config: CoreConfig,
}

View File

@@ -0,0 +1,85 @@
//! Password hashing and opaque session-token generation.
//!
//! These are CPU-pure helpers (no I/O), so they live in core. Passwords are hashed with
//! Argon2id; session tokens are high-entropy random values of which only the SHA-256 hash
//! is ever persisted.
use argon2::Argon2;
use argon2::password_hash::rand_core::OsRng as ArgonOsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use rand::RngCore;
use sha2::{Digest, Sha256 as Sha256Hasher};
use audioblume_entities::SessionToken;
use crate::error::CoreError;
/// Hash a plaintext password into an Argon2id PHC string (algorithm, params, and salt
/// embedded). The returned string is what gets stored in `users.password_hash`.
pub fn hash_password(password: &str) -> Result<String, CoreError> {
let salt = SaltString::generate(&mut ArgonOsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| CoreError::Internal(format!("password hashing failed: {e}")))
}
/// Verify a plaintext password against a stored PHC hash. Returns `Ok(false)` on mismatch,
/// `Err` only if the stored hash is malformed.
pub fn verify_password(password: &str, phc: &str) -> Result<bool, CoreError> {
let parsed = PasswordHash::new(phc)
.map_err(|e| CoreError::Internal(format!("stored password hash is malformed: {e}")))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok())
}
/// Generate a fresh opaque session token: 32 random bytes, URL-safe base64 (no padding).
#[must_use]
pub fn generate_token() -> SessionToken {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
SessionToken(URL_SAFE_NO_PAD.encode(bytes))
}
/// The SHA-256 hash of a token's bytes, used as the persisted lookup key. The same function
/// is applied at login (store) and authentication (lookup).
#[must_use]
pub fn hash_token(token: &str) -> Vec<u8> {
let mut hasher = Sha256Hasher::new();
hasher.update(token.as_bytes());
hasher.finalize().to_vec()
}
/// The SHA-256 content address of an arbitrary byte slice. Used to content-address blobs
/// (covers, and to cross-check the digest the API computed while spooling an upload).
#[must_use]
pub fn content_sha256(bytes: &[u8]) -> audioblume_entities::Sha256 {
let mut hasher = Sha256Hasher::new();
hasher.update(bytes);
let out = hasher.finalize();
audioblume_entities::Sha256::from_slice(&out).expect("SHA-256 output is always 32 bytes")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_round_trip() {
let phc = hash_password("correct horse battery staple").unwrap();
assert!(verify_password("correct horse battery staple", &phc).unwrap());
assert!(!verify_password("wrong password", &phc).unwrap());
}
#[test]
fn tokens_are_unique_and_hash_stably() {
let a = generate_token();
let b = generate_token();
assert_ne!(a.as_str(), b.as_str());
assert_eq!(hash_token(a.as_str()), hash_token(a.as_str()));
assert_eq!(hash_token(a.as_str()).len(), 32);
}
}

View File

@@ -0,0 +1,42 @@
//! The error type returned by business-logic functions and port traits.
use audioblume_entities::EntityError;
use thiserror::Error;
/// Errors produced by core logic and the data-access ports it depends on.
///
/// The `data` crate maps its sqlx/S3/epub failures into these variants; the API maps these
/// variants onto HTTP status codes.
#[derive(Debug, Error)]
pub enum CoreError {
/// A requested resource does not exist.
#[error("not found")]
NotFound,
/// Authentication is required or the provided credentials are invalid.
#[error("unauthorized")]
Unauthorized,
/// The caller is authenticated but not permitted to perform the action.
#[error("forbidden")]
Forbidden,
/// The operation conflicts with existing state.
#[error("conflict: {0}")]
Conflict(String),
/// A domain value failed validation.
#[error(transparent)]
Validation(#[from] EntityError),
/// The object store failed.
#[error("storage error: {0}")]
Storage(String),
/// The database / repository failed.
#[error("repository error: {0}")]
Repo(String),
/// EPUB parsing failed.
#[error("epub error: {0}")]
Epub(String),
/// An unexpected internal error.
#[error("internal error: {0}")]
Internal(String),
}
/// Convenience alias for results in this crate.
pub type CoreResult<T> = Result<T, CoreError>;

View File

@@ -0,0 +1,24 @@
//! Business logic and data-access ports for audioblume.
//!
//! This crate consumes [`audioblume_entities`] and defines the port traits in [`ports`] that
//! the `data` crate implements. It performs no direct I/O — all persistence and object-store
//! access goes through the ports, which keeps the logic unit-testable and the storage layer
//! swappable.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod access;
pub mod auth;
pub mod catalog;
pub mod context;
pub mod crypto;
pub mod error;
pub mod ports;
pub mod upload;
pub use context::{CoreConfig, Ctx};
pub use error::{CoreError, CoreResult};
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,229 @@
//! Port traits: the seams that the `data` crate implements.
//!
//! Core logic depends only on these abstractions, never on sqlx, S3, or reqwest directly.
//! This keeps the business logic unit-testable against in-memory fakes and lets the storage
//! and persistence implementations evolve independently.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use audioblume_entities::ids::{EditionId, SessionId, UserId, WorkId};
use audioblume_entities::{
Blob, BlobKind, CopyrightStatus, Edition, EditionMetadata, EntitlementSource, Isbn13, PageReq,
Sha256, User, Work,
};
use crate::error::CoreError;
/// A new edition to be created, assembled by the upload orchestration.
#[derive(Debug, Clone)]
pub struct NewEdition {
/// The work this edition clusters under.
pub work_id: WorkId,
/// The content address of the EPUB blob.
pub blob_sha256: Sha256,
/// Extracted metadata for the edition.
pub metadata: EditionMetadata,
/// The content address of the extracted cover blob, if any.
pub cover_blob_sha256: Option<Sha256>,
/// The initial copyright status (`Unknown` for fresh uploads).
pub copyright_status: CopyrightStatus,
/// `pending`, `ok`, or `failed`.
pub extraction_status: String,
/// Detail when extraction failed.
pub extraction_error: Option<String>,
}
/// A cover image extracted from an EPUB.
#[derive(Debug, Clone)]
pub struct ExtractedCover {
/// The raw image bytes.
pub bytes: Vec<u8>,
/// The image MIME type, e.g. `image/jpeg`.
pub mime: String,
}
/// The result of parsing an EPUB.
#[derive(Debug, Clone)]
pub struct ExtractedEpub {
/// Bibliographic metadata.
pub metadata: EditionMetadata,
/// The cover image, if the EPUB declared one.
pub cover: Option<ExtractedCover>,
}
/// Object store for content-addressed blobs (MinIO / S3).
#[async_trait]
pub trait BlobStore: Send + Sync {
/// Whether an object exists at `key` in `bucket`.
async fn exists(&self, key: &str, bucket: &str) -> Result<bool, CoreError>;
/// Store `bytes` at `key` in `bucket`. Overwriting an identical content-addressed object
/// is harmless, but callers skip the write when the blob already exists.
async fn put(
&self,
key: &str,
bucket: &str,
kind: BlobKind,
bytes: Vec<u8>,
) -> Result<(), CoreError>;
/// Fetch the full object bytes. Used for serving covers inline.
async fn get(&self, key: &str, bucket: &str) -> Result<Vec<u8>, CoreError>;
/// Produce a short-lived presigned GET URL for the object, used to redirect download
/// requests straight to the object store.
async fn presign_get(
&self,
key: &str,
bucket: &str,
ttl_seconds: u64,
) -> Result<String, CoreError>;
}
/// Persistence for user accounts.
#[async_trait]
pub trait UserRepo: Send + Sync {
/// Create a user with the given Argon2id PHC hash.
async fn create(
&self,
email: &str,
display_name: Option<&str>,
password_hash: &str,
is_admin: bool,
) -> Result<User, CoreError>;
/// Look up a user by (case-insensitive) email.
async fn find_by_email(&self, email: &str) -> Result<Option<User>, CoreError>;
/// Look up a user by id.
async fn find_by_id(&self, id: UserId) -> Result<Option<User>, CoreError>;
/// Set a user's status to `active` or `disabled`.
async fn set_status(&self, id: UserId, status: &str) -> Result<(), CoreError>;
}
/// Persistence for sessions. Only the SHA-256 hash of a token is ever stored.
#[async_trait]
pub trait SessionRepo: Send + Sync {
/// Create a session for `user` expiring at `expires_at`, keyed by the token hash.
async fn create(
&self,
user: UserId,
token_hash: &[u8],
expires_at: DateTime<Utc>,
) -> Result<SessionId, CoreError>;
/// Find a live (unexpired, unrevoked) session by token hash, returning its id and user.
async fn find_active(
&self,
token_hash: &[u8],
now: DateTime<Utc>,
) -> Result<Option<(SessionId, UserId)>, CoreError>;
/// Update the `last_seen_at` timestamp of a session.
async fn touch(&self, id: SessionId, now: DateTime<Utc>) -> Result<(), CoreError>;
/// Revoke a single session by its token hash. No error if it does not exist.
async fn revoke_by_token_hash(&self, token_hash: &[u8]) -> Result<(), CoreError>;
/// Revoke every active session for a user; returns the number revoked.
async fn revoke_for_user(&self, user: UserId) -> Result<u64, CoreError>;
}
/// Persistence for content-addressed blob records.
#[async_trait]
pub trait BlobRepo: Send + Sync {
/// Insert the blob record if absent. Returns `true` if it was newly inserted, `false` if
/// a record with the same content address already existed (a dedup hit).
async fn upsert(&self, blob: &Blob) -> Result<bool, CoreError>;
/// Fetch a blob record by content address.
async fn get(&self, sha: &Sha256) -> Result<Option<Blob>, CoreError>;
}
/// Persistence and queries for the catalog (works and editions).
#[async_trait]
pub trait CatalogRepo: Send + Sync {
/// Find an existing edition backed by the given blob, if any.
async fn find_edition_by_blob(&self, sha: &Sha256) -> Result<Option<Edition>, CoreError>;
/// Create a new edition.
async fn create_edition(&self, edition: &NewEdition) -> Result<Edition, CoreError>;
/// Find works that already have an edition with the given ISBN-13.
async fn find_works_by_isbn(&self, isbn: &Isbn13) -> Result<Vec<Work>, CoreError>;
/// Trigram-fuzzy match works by title (and optionally author), returning `(work, score)`
/// pairs above `threshold`, best first.
async fn fuzzy_match_works(
&self,
title: &str,
author: Option<&str>,
threshold: f32,
limit: i64,
) -> Result<Vec<(Work, f32)>, CoreError>;
/// Create a new work.
async fn create_work(
&self,
title: &str,
author: Option<&str>,
status: CopyrightStatus,
) -> Result<Work, CoreError>;
/// Fetch an edition by id.
async fn get_edition(&self, id: EditionId) -> Result<Option<Edition>, CoreError>;
/// Fetch a work by id.
async fn get_work(&self, id: WorkId) -> Result<Option<Work>, CoreError>;
/// List the editions clustered under a work.
async fn list_editions_for_work(&self, id: WorkId) -> Result<Vec<Edition>, CoreError>;
/// A page of catalog editions (newest first) plus the total count.
async fn list_catalog(&self, page: PageReq) -> Result<(Vec<Edition>, i64), CoreError>;
/// Set a work's copyright status and optional note.
async fn set_copyright(
&self,
work: WorkId,
status: CopyrightStatus,
note: Option<&str>,
) -> Result<(), CoreError>;
/// Re-point every edition of `src` onto `dst`, record the merge, and delete `src`.
async fn merge_works(
&self,
src: WorkId,
dst: WorkId,
by: Option<UserId>,
reason: Option<&str>,
) -> Result<(), CoreError>;
}
/// Persistence for entitlements (a user's right to access an edition).
#[async_trait]
pub trait EntitlementRepo: Send + Sync {
/// Grant an entitlement idempotently — a duplicate `(user, edition)` is a no-op.
async fn grant(
&self,
user: UserId,
edition: EditionId,
source: EntitlementSource,
) -> Result<(), CoreError>;
/// The source of a user's entitlement to an edition, or `None` if they hold none.
async fn entitlement_source(
&self,
user: UserId,
edition: EditionId,
) -> Result<Option<EntitlementSource>, CoreError>;
}
/// Synchronous EPUB metadata + cover extractor. Implementations run under
/// `tokio::task::spawn_blocking` at the call site since parsing is CPU-bound.
pub trait EpubExtractor: Send + Sync {
/// Parse the EPUB bytes into metadata and an optional cover image.
fn extract(&self, bytes: &[u8]) -> Result<ExtractedEpub, CoreError>;
}

View File

@@ -0,0 +1,533 @@
//! Unit tests for the dedup orchestration and access decision, exercised against in-memory
//! fakes of the port traits.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use audioblume_entities::ids::{EditionId, SessionId, UserId, WorkId};
use audioblume_entities::{
Blob, BlobKind, CopyrightStatus, Edition, EditionMetadata, EntitlementSource, Isbn13, PageReq,
Sha256, User, Work,
};
use crate::context::{CoreConfig, Ctx};
use crate::error::CoreError;
use crate::ports::{
BlobRepo, BlobStore, CatalogRepo, EntitlementRepo, EpubExtractor, ExtractedEpub, NewEdition,
SessionRepo, UserRepo,
};
// --- Fakes -----------------------------------------------------------------------------
#[derive(Default)]
struct FakeUsers(Mutex<HashMap<UserId, User>>);
#[async_trait]
impl UserRepo for FakeUsers {
async fn create(
&self,
email: &str,
display_name: Option<&str>,
password_hash: &str,
is_admin: bool,
) -> Result<User, CoreError> {
let now = Utc::now();
let user = User {
id: UserId::new(),
email: email.to_string(),
display_name: display_name.map(str::to_string),
password_hash: password_hash.to_string(),
is_admin,
status: "active".to_string(),
created_at: now,
updated_at: now,
};
self.0.lock().unwrap().insert(user.id, user.clone());
Ok(user)
}
async fn find_by_email(&self, email: &str) -> Result<Option<User>, CoreError> {
Ok(self
.0
.lock()
.unwrap()
.values()
.find(|u| u.email.eq_ignore_ascii_case(email))
.cloned())
}
async fn find_by_id(&self, id: UserId) -> Result<Option<User>, CoreError> {
Ok(self.0.lock().unwrap().get(&id).cloned())
}
async fn set_status(&self, id: UserId, status: &str) -> Result<(), CoreError> {
if let Some(u) = self.0.lock().unwrap().get_mut(&id) {
u.status = status.to_string();
}
Ok(())
}
}
/// `(user, token_hash, expires_at, revoked)`.
type SessionRow = (UserId, Vec<u8>, DateTime<Utc>, bool);
#[derive(Default)]
struct FakeSessions(Mutex<HashMap<SessionId, SessionRow>>);
#[async_trait]
impl SessionRepo for FakeSessions {
async fn create(
&self,
user: UserId,
token_hash: &[u8],
expires_at: DateTime<Utc>,
) -> Result<SessionId, CoreError> {
let id = SessionId::new();
self.0
.lock()
.unwrap()
.insert(id, (user, token_hash.to_vec(), expires_at, false));
Ok(id)
}
async fn find_active(
&self,
token_hash: &[u8],
now: DateTime<Utc>,
) -> Result<Option<(SessionId, UserId)>, CoreError> {
Ok(self
.0
.lock()
.unwrap()
.iter()
.find(|(_, (_, h, exp, revoked))| h == token_hash && *exp > now && !revoked)
.map(|(id, (user, _, _, _))| (*id, *user)))
}
async fn touch(&self, _id: SessionId, _now: DateTime<Utc>) -> Result<(), CoreError> {
Ok(())
}
async fn revoke_by_token_hash(&self, token_hash: &[u8]) -> Result<(), CoreError> {
for v in self.0.lock().unwrap().values_mut() {
if v.1 == token_hash {
v.3 = true;
}
}
Ok(())
}
async fn revoke_for_user(&self, user: UserId) -> Result<u64, CoreError> {
let mut n = 0;
for v in self.0.lock().unwrap().values_mut() {
if v.0 == user && !v.3 {
v.3 = true;
n += 1;
}
}
Ok(n)
}
}
#[derive(Default)]
struct FakeBlobs(Mutex<HashMap<Sha256, Blob>>);
#[async_trait]
impl BlobRepo for FakeBlobs {
async fn upsert(&self, blob: &Blob) -> Result<bool, CoreError> {
use std::collections::hash_map::Entry;
let mut map = self.0.lock().unwrap();
match map.entry(blob.sha256) {
Entry::Vacant(e) => {
e.insert(blob.clone());
Ok(true)
}
Entry::Occupied(_) => Ok(false),
}
}
async fn get(&self, sha: &Sha256) -> Result<Option<Blob>, CoreError> {
Ok(self.0.lock().unwrap().get(sha).cloned())
}
}
#[derive(Default)]
struct FakeBlobStore {
objects: Mutex<HashMap<String, Vec<u8>>>,
}
#[async_trait]
impl BlobStore for FakeBlobStore {
async fn exists(&self, key: &str, _bucket: &str) -> Result<bool, CoreError> {
Ok(self.objects.lock().unwrap().contains_key(key))
}
async fn put(
&self,
key: &str,
_bucket: &str,
_kind: BlobKind,
bytes: Vec<u8>,
) -> Result<(), CoreError> {
self.objects.lock().unwrap().insert(key.to_string(), bytes);
Ok(())
}
async fn get(&self, key: &str, _bucket: &str) -> Result<Vec<u8>, CoreError> {
self.objects
.lock()
.unwrap()
.get(key)
.cloned()
.ok_or(CoreError::NotFound)
}
async fn presign_get(
&self,
key: &str,
_bucket: &str,
_ttl_seconds: u64,
) -> Result<String, CoreError> {
Ok(format!("memory://{key}"))
}
}
#[derive(Default)]
struct FakeCatalog {
works: Mutex<HashMap<WorkId, Work>>,
editions: Mutex<HashMap<EditionId, Edition>>,
}
#[async_trait]
impl CatalogRepo for FakeCatalog {
async fn find_edition_by_blob(&self, sha: &Sha256) -> Result<Option<Edition>, CoreError> {
Ok(self
.editions
.lock()
.unwrap()
.values()
.find(|e| e.blob_sha256 == *sha)
.cloned())
}
async fn create_edition(&self, e: &NewEdition) -> Result<Edition, CoreError> {
let edition = Edition {
id: EditionId::new(),
work_id: e.work_id,
blob_sha256: e.blob_sha256,
title: e.metadata.title.clone(),
author: e.metadata.author.clone(),
language: e.metadata.language.clone(),
isbn13: e.metadata.isbn13.clone(),
publisher: e.metadata.publisher.clone(),
cover_blob_sha256: e.cover_blob_sha256,
copyright_status: e.copyright_status,
extraction_status: e.extraction_status.clone(),
extraction_error: e.extraction_error.clone(),
created_at: Utc::now(),
};
self.editions
.lock()
.unwrap()
.insert(edition.id, edition.clone());
Ok(edition)
}
async fn find_works_by_isbn(&self, isbn: &Isbn13) -> Result<Vec<Work>, CoreError> {
let works = self.works.lock().unwrap();
let editions = self.editions.lock().unwrap();
Ok(editions
.values()
.filter(|e| e.isbn13.as_ref() == Some(isbn))
.filter_map(|e| works.get(&e.work_id).cloned())
.collect())
}
async fn fuzzy_match_works(
&self,
title: &str,
_author: Option<&str>,
threshold: f32,
_limit: i64,
) -> Result<Vec<(Work, f32)>, CoreError> {
Ok(self
.works
.lock()
.unwrap()
.values()
.filter(|w| w.canonical_title.eq_ignore_ascii_case(title) && 1.0 >= threshold)
.map(|w| (w.clone(), 1.0))
.collect())
}
async fn create_work(
&self,
title: &str,
author: Option<&str>,
status: CopyrightStatus,
) -> Result<Work, CoreError> {
let now = Utc::now();
let work = Work {
id: WorkId::new(),
canonical_title: title.to_string(),
canonical_author: author.map(str::to_string),
copyright_status: status,
copyright_note: None,
created_at: now,
updated_at: now,
};
self.works.lock().unwrap().insert(work.id, work.clone());
Ok(work)
}
async fn get_edition(&self, id: EditionId) -> Result<Option<Edition>, CoreError> {
Ok(self.editions.lock().unwrap().get(&id).cloned())
}
async fn get_work(&self, id: WorkId) -> Result<Option<Work>, CoreError> {
Ok(self.works.lock().unwrap().get(&id).cloned())
}
async fn list_editions_for_work(&self, id: WorkId) -> Result<Vec<Edition>, CoreError> {
Ok(self
.editions
.lock()
.unwrap()
.values()
.filter(|e| e.work_id == id)
.cloned()
.collect())
}
async fn list_catalog(&self, page: PageReq) -> Result<(Vec<Edition>, i64), CoreError> {
let editions = self.editions.lock().unwrap();
let total = editions.len() as i64;
let items = editions
.values()
.skip(page.offset as usize)
.take(page.limit as usize)
.cloned()
.collect();
Ok((items, total))
}
async fn set_copyright(
&self,
work: WorkId,
status: CopyrightStatus,
note: Option<&str>,
) -> Result<(), CoreError> {
if let Some(w) = self.works.lock().unwrap().get_mut(&work) {
w.copyright_status = status;
w.copyright_note = note.map(str::to_string);
}
Ok(())
}
async fn merge_works(
&self,
src: WorkId,
dst: WorkId,
_by: Option<UserId>,
_reason: Option<&str>,
) -> Result<(), CoreError> {
for e in self.editions.lock().unwrap().values_mut() {
if e.work_id == src {
e.work_id = dst;
}
}
self.works.lock().unwrap().remove(&src);
Ok(())
}
}
#[derive(Default)]
struct FakeEntitlements(Mutex<HashMap<(UserId, EditionId), EntitlementSource>>);
#[async_trait]
impl EntitlementRepo for FakeEntitlements {
async fn grant(
&self,
user: UserId,
edition: EditionId,
source: EntitlementSource,
) -> Result<(), CoreError> {
self.0
.lock()
.unwrap()
.entry((user, edition))
.or_insert(source);
Ok(())
}
async fn entitlement_source(
&self,
user: UserId,
edition: EditionId,
) -> Result<Option<EntitlementSource>, CoreError> {
Ok(self.0.lock().unwrap().get(&(user, edition)).copied())
}
}
struct FakeExtractor {
metadata: EditionMetadata,
}
impl EpubExtractor for FakeExtractor {
fn extract(&self, _bytes: &[u8]) -> Result<ExtractedEpub, CoreError> {
Ok(ExtractedEpub {
metadata: self.metadata.clone(),
cover: None,
})
}
}
// --- Test stack ------------------------------------------------------------------------
struct Stack {
ctx: Ctx,
blobs: Arc<FakeBlobs>,
blob_store: Arc<FakeBlobStore>,
catalog: Arc<FakeCatalog>,
entitlements: Arc<FakeEntitlements>,
}
fn stack_with_metadata(metadata: EditionMetadata) -> Stack {
let users = Arc::new(FakeUsers::default());
let sessions = Arc::new(FakeSessions::default());
let blobs = Arc::new(FakeBlobs::default());
let blob_store = Arc::new(FakeBlobStore::default());
let catalog = Arc::new(FakeCatalog::default());
let entitlements = Arc::new(FakeEntitlements::default());
let epub = Arc::new(FakeExtractor { metadata });
let ctx = Ctx {
users,
sessions,
blobs: blobs.clone(),
blob_store: blob_store.clone(),
catalog: catalog.clone(),
entitlements: entitlements.clone(),
epub,
config: CoreConfig::default(),
};
Stack {
ctx,
blobs,
blob_store,
catalog,
entitlements,
}
}
// --- Tests -----------------------------------------------------------------------------
#[tokio::test]
async fn identical_reupload_is_deduplicated() {
let meta = EditionMetadata {
title: Some("Pride and Prejudice".to_string()),
author: Some("Jane Austen".to_string()),
..Default::default()
};
let stack = stack_with_metadata(meta);
let sha = Sha256([0x11; 32]);
let bytes = b"PK\x03\x04 fake epub".to_vec();
let alice = UserId::new();
let bob = UserId::new();
let first = crate::upload::ingest_epub(&stack.ctx, alice, sha, bytes.clone())
.await
.unwrap();
assert!(
!first.deduplicated,
"first upload should not be a dedup hit"
);
let second = crate::upload::ingest_epub(&stack.ctx, bob, sha, bytes.clone())
.await
.unwrap();
assert!(second.deduplicated, "second identical upload should dedup");
assert_eq!(first.edition_id, second.edition_id, "same edition");
// Exactly one blob record, one stored object, one edition; two entitlements.
assert_eq!(stack.blobs.0.lock().unwrap().len(), 1);
assert_eq!(stack.blob_store.objects.lock().unwrap().len(), 1);
assert_eq!(stack.catalog.editions.lock().unwrap().len(), 1);
assert_eq!(stack.entitlements.0.lock().unwrap().len(), 2);
}
#[tokio::test]
async fn public_domain_is_readable_by_non_owner() {
let stack = stack_with_metadata(EditionMetadata::default());
let work = stack
.catalog
.create_work(
"Frankenstein",
Some("Mary Shelley"),
CopyrightStatus::PublicDomain,
)
.await
.unwrap();
let edition = stack
.catalog
.create_edition(&NewEdition {
work_id: work.id,
blob_sha256: Sha256([0x22; 32]),
metadata: EditionMetadata::default(),
cover_blob_sha256: None,
copyright_status: CopyrightStatus::Unknown,
extraction_status: "ok".to_string(),
extraction_error: None,
})
.await
.unwrap();
let stranger = UserId::new();
let decision = crate::access::can_access(&stack.ctx, Some(stranger), edition.id)
.await
.unwrap();
assert!(decision.allowed);
assert_eq!(
decision.reason,
audioblume_entities::AccessReason::PublicDomain
);
// Anonymous callers can read public-domain content too.
let anon = crate::access::can_access(&stack.ctx, None, edition.id)
.await
.unwrap();
assert!(anon.allowed);
}
#[tokio::test]
async fn in_copyright_requires_ownership() {
let stack = stack_with_metadata(EditionMetadata::default());
let work = stack
.catalog
.create_work(
"Modern Novel",
Some("A. Writer"),
CopyrightStatus::InCopyright,
)
.await
.unwrap();
let edition = stack
.catalog
.create_edition(&NewEdition {
work_id: work.id,
blob_sha256: Sha256([0x33; 32]),
metadata: EditionMetadata::default(),
cover_blob_sha256: None,
copyright_status: CopyrightStatus::Unknown,
extraction_status: "ok".to_string(),
extraction_error: None,
})
.await
.unwrap();
let owner = UserId::new();
let stranger = UserId::new();
// Stranger is denied.
let denied = crate::access::can_access(&stack.ctx, Some(stranger), edition.id)
.await
.unwrap();
assert!(!denied.allowed);
assert_eq!(denied.reason, audioblume_entities::AccessReason::Denied);
// Owner (uploaded the file) is allowed.
stack
.entitlements
.grant(owner, edition.id, EntitlementSource::Upload)
.await
.unwrap();
let allowed = crate::access::can_access(&stack.ctx, Some(owner), edition.id)
.await
.unwrap();
assert!(allowed.allowed);
assert_eq!(
allowed.reason,
audioblume_entities::AccessReason::OwnedViaUpload
);
}

View File

@@ -0,0 +1,172 @@
//! EPUB ingestion: content-addressed deduplication, metadata extraction, and clustering.
use chrono::Utc;
use audioblume_entities::ids::UserId;
use audioblume_entities::{
Blob, BlobKind, CopyrightStatus, EditionMetadata, EntitlementSource, Sha256, UploadResponse,
Work,
};
use crate::access::effective_status;
use crate::context::Ctx;
use crate::crypto;
use crate::error::CoreError;
use crate::ports::{ExtractedEpub, NewEdition};
/// Ingest an uploaded EPUB on behalf of `user`.
///
/// `sha` is the content address the API computed while streaming the body to a spool file,
/// and `bytes` is the full file content. The flow:
///
/// 1. Upsert the blob record; store the bytes in the object store only if absent.
/// 2. If an edition already backs this blob, this is a deduplicated re-upload — just grant
/// the user an `upload` entitlement and return.
/// 3. Otherwise extract metadata, store any cover as its own content-addressed blob, cluster
/// the new edition into a work, create the edition, and grant the entitlement. Extraction
/// failure is non-fatal: the blob is kept and a `failed`-status edition is created so the
/// bytes are never lost and an operator can retry.
pub async fn ingest_epub(
ctx: &Ctx,
user: UserId,
sha: Sha256,
bytes: Vec<u8>,
) -> Result<UploadResponse, CoreError> {
let bucket = ctx.config.blob_bucket.clone();
let key = sha.storage_key(BlobKind::Epub.key_prefix());
// 1. Record the blob (dedup anchor) and ensure the object exists.
let blob = Blob {
sha256: sha,
kind: BlobKind::Epub,
byte_size: bytes.len() as i64,
storage_key: key.clone(),
storage_bucket: bucket.clone(),
created_at: Utc::now(),
};
let _newly_inserted = ctx.blobs.upsert(&blob).await?;
if !ctx.blob_store.exists(&key, &bucket).await? {
ctx.blob_store
.put(&key, &bucket, BlobKind::Epub, bytes.clone())
.await?;
}
// 2. Dedup hit: the blob already backs an edition.
if let Some(edition) = ctx.catalog.find_edition_by_blob(&sha).await? {
ctx.entitlements
.grant(user, edition.id, EntitlementSource::Upload)
.await?;
let work = ctx
.catalog
.get_work(edition.work_id)
.await?
.ok_or_else(|| CoreError::Internal("edition references a missing work".to_string()))?;
return Ok(UploadResponse {
edition_id: edition.id,
work_id: edition.work_id,
blob_sha256: sha,
deduplicated: true,
copyright_status: effective_status(&edition, &work),
});
}
// 3. New edition. Extract metadata (already off the request thread at the call site).
let (metadata, cover_sha, extraction_status, extraction_error) = match ctx.epub.extract(&bytes)
{
Ok(ExtractedEpub { metadata, cover }) => {
let cover_sha = if let Some(cover) = cover {
Some(store_cover(ctx, &bucket, cover.bytes).await?)
} else {
None
};
(metadata, cover_sha, "ok".to_string(), None)
}
Err(e) => {
tracing::warn!(error = %e, %sha, "epub extraction failed; cataloging anyway");
(
EditionMetadata::default(),
None,
"failed".to_string(),
Some(e.to_string()),
)
}
};
let work = cluster_into_work(ctx, &metadata).await?;
let new_edition = NewEdition {
work_id: work.id,
blob_sha256: sha,
metadata,
cover_blob_sha256: cover_sha,
copyright_status: CopyrightStatus::Unknown,
extraction_status,
extraction_error,
};
let edition = ctx.catalog.create_edition(&new_edition).await?;
ctx.entitlements
.grant(user, edition.id, EntitlementSource::Upload)
.await?;
Ok(UploadResponse {
edition_id: edition.id,
work_id: work.id,
blob_sha256: sha,
deduplicated: false,
copyright_status: effective_status(&edition, &work),
})
}
/// Store a cover image as its own content-addressed blob and return its digest.
async fn store_cover(ctx: &Ctx, bucket: &str, bytes: Vec<u8>) -> Result<Sha256, CoreError> {
let sha = crypto::content_sha256(&bytes);
let key = sha.storage_key(BlobKind::Cover.key_prefix());
let blob = Blob {
sha256: sha,
kind: BlobKind::Cover,
byte_size: bytes.len() as i64,
storage_key: key.clone(),
storage_bucket: bucket.to_string(),
created_at: Utc::now(),
};
ctx.blobs.upsert(&blob).await?;
if !ctx.blob_store.exists(&key, bucket).await? {
ctx.blob_store
.put(&key, bucket, BlobKind::Cover, bytes)
.await?;
}
Ok(sha)
}
/// Cluster an edition into a work: exact ISBN match first, then trigram title/author match
/// above the configured threshold, otherwise a fresh work.
async fn cluster_into_work(ctx: &Ctx, metadata: &EditionMetadata) -> Result<Work, CoreError> {
if let Some(isbn) = &metadata.isbn13
&& let Some(work) = ctx
.catalog
.find_works_by_isbn(isbn)
.await?
.into_iter()
.next()
{
return Ok(work);
}
let title = metadata.title.as_deref().unwrap_or("Untitled");
let matches = ctx
.catalog
.fuzzy_match_works(
title,
metadata.author.as_deref(),
ctx.config.fuzzy_match_threshold,
1,
)
.await?;
if let Some((work, _score)) = matches.into_iter().next() {
return Ok(work);
}
ctx.catalog
.create_work(title, metadata.author.as_deref(), CopyrightStatus::Unknown)
.await
}

View File

@@ -0,0 +1,25 @@
[package]
name = "audioblume-data"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
description = "Data-access adapters for audioblume: Postgres, MinIO/S3, and EPUB parsing."
[dependencies]
audioblume-entities.workspace = true
audioblume-core.workspace = true
async-trait.workspace = true
sqlx.workspace = true
aws-config.workspace = true
aws-sdk-s3.workspace = true
aws-credential-types.workspace = true
aws-smithy-types.workspace = true
epub.workspace = true
tokio.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
thiserror.workspace = true
tracing.workspace = true

View File

@@ -0,0 +1,28 @@
-- Extensions and shared enum types.
--
-- gen_random_uuid() is built into PostgreSQL core (>= 13), so no pgcrypto is required.
-- pg_trgm powers fuzzy work clustering. Email case-insensitivity is handled with a
-- lower(email) unique index rather than citext, which keeps sqlx's compile-time type
-- checking happy.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TYPE copyright_status AS ENUM (
'unknown',
'public_domain',
'in_copyright',
'licensed_for_sale'
);
CREATE TYPE entitlement_source AS ENUM (
'upload',
'purchase',
'grant'
);
CREATE TYPE blob_kind AS ENUM (
'epub',
'cover',
'audio_m4b',
'audio_hls_segment'
);

View File

@@ -0,0 +1,29 @@
-- User accounts and sessions.
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL,
display_name text,
password_hash text NOT NULL, -- Argon2id PHC string
is_admin boolean NOT NULL DEFAULT false,
status text NOT NULL DEFAULT 'active', -- active | disabled
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Case-insensitive uniqueness on email.
CREATE UNIQUE INDEX users_email_lower_uq ON users (lower(email));
-- Only the SHA-256 hash of an opaque token is stored, never the token itself.
CREATE TABLE sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash bytea NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz
);
CREATE INDEX sessions_user_idx ON sessions(user_id);
CREATE INDEX sessions_active_idx ON sessions(expires_at) WHERE revoked_at IS NULL;

View File

@@ -0,0 +1,74 @@
-- The catalog: content-addressed blobs, logical works, editions, and entitlements.
-- Physical, content-addressed bytes. The unique sha256 primary key is the dedup anchor:
-- a byte-identical re-upload conflicts here and the object-store write is skipped.
CREATE TABLE blobs (
sha256 bytea PRIMARY KEY,
kind blob_kind NOT NULL DEFAULT 'epub',
byte_size bigint NOT NULL,
storage_key text NOT NULL,
storage_bucket text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- A logical title that clusters one or more editions.
CREATE TABLE works (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
canonical_title text NOT NULL,
canonical_author text,
copyright_status copyright_status NOT NULL DEFAULT 'unknown',
copyright_note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX works_title_trgm ON works USING gin (canonical_title gin_trgm_ops);
CREATE INDEX works_author_trgm ON works USING gin (canonical_author gin_trgm_ops);
-- A specific EPUB file plus its extracted metadata, belonging to exactly one work.
CREATE TABLE editions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
work_id uuid NOT NULL REFERENCES works(id) ON DELETE RESTRICT,
blob_sha256 bytea NOT NULL REFERENCES blobs(sha256) ON DELETE RESTRICT,
title text,
author text,
language text,
isbn13 text,
publisher text,
cover_blob_sha256 bytea REFERENCES blobs(sha256),
metadata_json jsonb NOT NULL DEFAULT '{}',
copyright_status copyright_status NOT NULL DEFAULT 'unknown',
extraction_status text NOT NULL DEFAULT 'pending', -- pending | ok | failed
extraction_error text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX editions_work_idx ON editions(work_id);
CREATE INDEX editions_blob_idx ON editions(blob_sha256);
-- The same file should not appear twice as separate editions of the same work.
CREATE UNIQUE INDEX editions_blob_work_uq ON editions(blob_sha256, work_id);
-- ISBN is a clustering signal, not globally unique (reprints share ISBNs): index, don't constrain.
CREATE INDEX editions_isbn_idx ON editions(isbn13) WHERE isbn13 IS NOT NULL;
-- Who may access what, and why. Every uploader of a given file gets a row here, even when
-- the blob and edition already existed (a dedup hit).
CREATE TABLE entitlements (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
edition_id uuid NOT NULL REFERENCES editions(id) ON DELETE CASCADE,
source entitlement_source NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX entitlements_user_edition_uq ON entitlements(user_id, edition_id);
CREATE INDEX entitlements_edition_idx ON entitlements(edition_id);
-- Audit trail for operator work merges.
CREATE TABLE work_merges (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
merged_work_id uuid NOT NULL,
surviving_work_id uuid NOT NULL REFERENCES works(id),
merged_by uuid REFERENCES users(id),
reason text,
created_at timestamptz NOT NULL DEFAULT now()
);

View File

@@ -0,0 +1,95 @@
-- Forward-compatible tables for later phases (translation, narration, the voice-contributor
-- marketplace, and sales). These are MODELED now so the schema does not need a destructive
-- migration when those phases land; no foundation code writes to them. Every produced
-- artifact (audio, consent documents, translated EPUBs) reuses the content-addressed `blobs`
-- table, so deduplication applies uniformly.
-- A narration voice. Synthetic stock voices have no contributor; cloned voices reference the
-- consenting contributor.
CREATE TABLE voices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
contributor_user_id uuid REFERENCES users(id),
display_name text NOT NULL,
kind text NOT NULL DEFAULT 'synthetic', -- synthetic | cloned
language text,
model_ref text,
is_published boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
-- A contributor's consent to synthesis of their voice, and its compensation scope.
CREATE TABLE voice_consents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
voice_id uuid NOT NULL REFERENCES voices(id) ON DELETE CASCADE,
contributor_user_id uuid NOT NULL REFERENCES users(id),
scope text NOT NULL, -- commercial | noncommercial | ...
consented_at timestamptz NOT NULL DEFAULT now(),
revoked_at timestamptz,
document_blob_sha256 bytea REFERENCES blobs(sha256)
);
-- A translation of an edition into another language (later: worker via LLM endpoints).
CREATE TABLE translations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
edition_id uuid NOT NULL REFERENCES editions(id) ON DELETE CASCADE,
target_language text NOT NULL,
status text NOT NULL DEFAULT 'pending',
output_blob_sha256 bytea REFERENCES blobs(sha256),
model_ref text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- A TTS rendering of an edition in a voice.
CREATE TABLE narrations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
edition_id uuid NOT NULL REFERENCES editions(id) ON DELETE CASCADE,
voice_id uuid REFERENCES voices(id),
language text,
source_translation_id uuid REFERENCES translations(id),
status text NOT NULL DEFAULT 'pending',
m4b_blob_sha256 bytea REFERENCES blobs(sha256),
created_at timestamptz NOT NULL DEFAULT now()
);
-- Per-chapter audio units, the granularity of HLS streaming.
CREATE TABLE narration_segments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
narration_id uuid NOT NULL REFERENCES narrations(id) ON DELETE CASCADE,
ordinal int NOT NULL,
blob_sha256 bytea REFERENCES blobs(sha256),
duration_ms int,
UNIQUE (narration_id, ordinal)
);
-- A price for a sellable item (an edition or a narration). Polymorphic; app-enforced.
CREATE TABLE prices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
sellable_kind text NOT NULL, -- edition | narration
sellable_id uuid NOT NULL,
currency char(3) NOT NULL DEFAULT 'USD',
amount_minor bigint NOT NULL,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
-- A purchase event. A completed sale will create an entitlement with source = 'purchase'.
CREATE TABLE sales (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
buyer_user_id uuid NOT NULL REFERENCES users(id),
price_id uuid NOT NULL REFERENCES prices(id),
amount_minor bigint NOT NULL,
status text NOT NULL DEFAULT 'recorded', -- recorded | refunded
created_at timestamptz NOT NULL DEFAULT now()
);
-- Contributor compensation accrued from sales of works narrated in their voice.
CREATE TABLE payouts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
contributor_user_id uuid NOT NULL REFERENCES users(id),
voice_id uuid REFERENCES voices(id),
sale_id uuid REFERENCES sales(id),
amount_minor bigint NOT NULL,
currency char(3) NOT NULL DEFAULT 'USD',
status text NOT NULL DEFAULT 'accrued', -- accrued | paid
created_at timestamptz NOT NULL DEFAULT now()
);

View File

@@ -0,0 +1,56 @@
//! Conversions between database representations and domain types, and error mapping.
use audioblume_core::CoreError;
use audioblume_entities::{BlobKind, CopyrightStatus, EntitlementSource, Sha256};
/// Map a sqlx error into the core error type. A missing row becomes [`CoreError::NotFound`];
/// everything else is a repository failure.
pub fn repo_err(e: sqlx::Error) -> CoreError {
match e {
sqlx::Error::RowNotFound => CoreError::NotFound,
other => CoreError::Repo(other.to_string()),
}
}
/// Parse a Postgres `copyright_status` label, defaulting to `Unknown` for anything
/// unrecognized (forward-compatible if labels are added).
#[must_use]
pub fn parse_copyright(s: &str) -> CopyrightStatus {
match s {
"public_domain" => CopyrightStatus::PublicDomain,
"in_copyright" => CopyrightStatus::InCopyright,
"licensed_for_sale" => CopyrightStatus::LicensedForSale,
_ => CopyrightStatus::Unknown,
}
}
/// Parse a Postgres `entitlement_source` label.
#[must_use]
pub fn parse_entitlement_source(s: &str) -> EntitlementSource {
match s {
"purchase" => EntitlementSource::Purchase,
"grant" => EntitlementSource::Grant,
_ => EntitlementSource::Upload,
}
}
/// Parse a Postgres `blob_kind` label, defaulting to `Epub`.
#[must_use]
pub fn parse_blob_kind(s: &str) -> BlobKind {
match s {
"cover" => BlobKind::Cover,
"audio_m4b" => BlobKind::AudioM4b,
"audio_hls_segment" => BlobKind::AudioHlsSegment,
_ => BlobKind::Epub,
}
}
/// Convert a raw `bytea` digest into a [`Sha256`], failing if it is not 32 bytes.
pub fn sha_from_bytes(bytes: &[u8]) -> Result<Sha256, CoreError> {
Sha256::from_slice(bytes).ok_or_else(|| {
CoreError::Repo(format!(
"stored digest is {} bytes, expected 32",
bytes.len()
))
})
}

View File

@@ -0,0 +1,78 @@
//! EPUB metadata + cover extraction, implemented with the `epub` crate.
use std::io::Cursor;
use std::panic::AssertUnwindSafe;
use epub::doc::EpubDoc;
use audioblume_core::CoreError;
use audioblume_core::ports::{EpubExtractor, ExtractedCover, ExtractedEpub};
use audioblume_entities::{EditionMetadata, Isbn13};
/// EPUB extractor backed by the `epub` crate. Parsing runs inside `catch_unwind` so a
/// malformed file that makes the parser panic surfaces as a recoverable error rather than
/// crashing the request thread.
pub struct EpubExtractorImpl;
impl EpubExtractor for EpubExtractorImpl {
fn extract(&self, bytes: &[u8]) -> Result<ExtractedEpub, CoreError> {
let owned = bytes.to_vec();
let result = std::panic::catch_unwind(AssertUnwindSafe(move || extract_inner(owned)));
match result {
Ok(inner) => inner,
Err(_) => Err(CoreError::Epub("panic while parsing EPUB".to_string())),
}
}
}
fn extract_inner(bytes: Vec<u8>) -> Result<ExtractedEpub, CoreError> {
let mut doc =
EpubDoc::from_reader(Cursor::new(bytes)).map_err(|e| CoreError::Epub(e.to_string()))?;
let title = doc.mdata("title").map(|m| m.value.clone());
let author = doc.mdata("creator").map(|m| m.value.clone());
let language = doc.mdata("language").map(|m| m.value.clone());
let publisher = doc.mdata("publisher").map(|m| m.value.clone());
// Identifiers may be bare ISBNs or `urn:isbn:` URNs; take the first that parses.
let isbn13 = doc
.metadata
.iter()
.filter(|m| m.property == "identifier")
.find_map(|m| {
let cleaned = m
.value
.trim()
.trim_start_matches("urn:isbn:")
.trim_start_matches("URN:ISBN:");
cleaned.parse::<Isbn13>().ok()
});
// Retain the full metadata as a property -> [values] JSON object for forward compatibility.
let mut map = serde_json::Map::new();
for item in &doc.metadata {
let entry = map
.entry(item.property.clone())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
if let serde_json::Value::Array(arr) = entry {
arr.push(serde_json::Value::String(item.value.clone()));
}
}
let raw_opf = serde_json::Value::Object(map);
let cover = doc
.get_cover()
.map(|(data, mime)| ExtractedCover { bytes: data, mime });
Ok(ExtractedEpub {
metadata: EditionMetadata {
title,
author,
language,
isbn13,
publisher,
raw_opf,
},
cover,
})
}

View File

@@ -0,0 +1,18 @@
//! Data-access adapters for audioblume.
//!
//! This crate implements the port traits declared in [`audioblume_core::ports`] using sqlx
//! (Postgres), the AWS SDK (MinIO / S3-compatible object storage), and the `epub` crate. It
//! owns all persistence and external-service I/O; the binaries wire a [`stack::DataStack`]
//! and hand its [`audioblume_core::Ctx`] to the business logic.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod convert;
mod epub;
mod pg;
mod s3;
mod stack;
pub use s3::{S3BlobStore, S3Settings};
pub use stack::{DataStack, PgSettings, connect_pool};

View File

@@ -0,0 +1,71 @@
//! Postgres-backed [`BlobRepo`].
use async_trait::async_trait;
use sqlx::PgPool;
use audioblume_core::CoreError;
use audioblume_core::ports::BlobRepo;
use audioblume_entities::{Blob, Sha256};
use crate::convert::{parse_blob_kind, repo_err, sha_from_bytes};
/// Blob-record persistence over a [`PgPool`].
pub struct PgBlobRepo {
pool: PgPool,
}
impl PgBlobRepo {
/// Construct a new repository over the given pool.
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl BlobRepo for PgBlobRepo {
async fn upsert(&self, blob: &Blob) -> Result<bool, CoreError> {
let result = sqlx::query!(
r#"
INSERT INTO blobs (sha256, kind, byte_size, storage_key, storage_bucket)
VALUES ($1, $2::text::blob_kind, $3, $4, $5)
ON CONFLICT (sha256) DO NOTHING
"#,
&blob.sha256.as_bytes()[..],
blob.kind.as_db_str(),
blob.byte_size,
blob.storage_key,
blob.storage_bucket,
)
.execute(&self.pool)
.await
.map_err(repo_err)?;
Ok(result.rows_affected() == 1)
}
async fn get(&self, sha: &Sha256) -> Result<Option<Blob>, CoreError> {
let rec = sqlx::query!(
r#"
SELECT sha256, kind::text AS "kind!", byte_size, storage_key, storage_bucket, created_at
FROM blobs
WHERE sha256 = $1
"#,
&sha.as_bytes()[..],
)
.fetch_optional(&self.pool)
.await
.map_err(repo_err)?;
rec.map(|r| {
Ok(Blob {
sha256: sha_from_bytes(&r.sha256)?,
kind: parse_blob_kind(&r.kind),
byte_size: r.byte_size,
storage_key: r.storage_key,
storage_bucket: r.storage_bucket,
created_at: r.created_at,
})
})
.transpose()
}
}

View File

@@ -0,0 +1,416 @@
//! Postgres-backed [`CatalogRepo`]: works, editions, and clustering.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use audioblume_core::CoreError;
use audioblume_core::ports::{CatalogRepo, NewEdition};
use audioblume_entities::ids::{EditionId, UserId, WorkId};
use audioblume_entities::{CopyrightStatus, Edition, Isbn13, PageReq, Sha256, Work};
use crate::convert::{parse_copyright, repo_err, sha_from_bytes};
/// Catalog persistence over a [`PgPool`].
pub struct PgCatalogRepo {
pool: PgPool,
}
impl PgCatalogRepo {
/// Construct a new repository over the given pool.
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
/// A row shape shared by the edition queries (columns in this exact order).
struct EditionRow {
id: Uuid,
work_id: Uuid,
blob_sha256: Vec<u8>,
title: Option<String>,
author: Option<String>,
language: Option<String>,
isbn13: Option<String>,
publisher: Option<String>,
cover_blob_sha256: Option<Vec<u8>>,
copyright_status: String,
extraction_status: String,
extraction_error: Option<String>,
created_at: DateTime<Utc>,
}
impl EditionRow {
fn into_edition(self) -> Result<Edition, CoreError> {
let cover = match self.cover_blob_sha256 {
Some(bytes) => Some(sha_from_bytes(&bytes)?),
None => None,
};
Ok(Edition {
id: self.id.into(),
work_id: self.work_id.into(),
blob_sha256: sha_from_bytes(&self.blob_sha256)?,
title: self.title,
author: self.author,
language: self.language,
isbn13: self.isbn13.and_then(|s| s.parse().ok()),
publisher: self.publisher,
cover_blob_sha256: cover,
copyright_status: parse_copyright(&self.copyright_status),
extraction_status: self.extraction_status,
extraction_error: self.extraction_error,
created_at: self.created_at,
})
}
}
/// A row shape shared by the work queries (columns in this exact order).
struct WorkRow {
id: Uuid,
canonical_title: String,
canonical_author: Option<String>,
copyright_status: String,
copyright_note: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl WorkRow {
fn into_work(self) -> Work {
Work {
id: self.id.into(),
canonical_title: self.canonical_title,
canonical_author: self.canonical_author,
copyright_status: parse_copyright(&self.copyright_status),
copyright_note: self.copyright_note,
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}
#[async_trait]
impl CatalogRepo for PgCatalogRepo {
async fn find_edition_by_blob(&self, sha: &Sha256) -> Result<Option<Edition>, CoreError> {
let row = sqlx::query_as!(
EditionRow,
r#"
SELECT id, work_id, blob_sha256, title, author, language, isbn13, publisher,
cover_blob_sha256, copyright_status::text AS "copyright_status!",
extraction_status, extraction_error, created_at
FROM editions
WHERE blob_sha256 = $1
LIMIT 1
"#,
&sha.as_bytes()[..],
)
.fetch_optional(&self.pool)
.await
.map_err(repo_err)?;
row.map(EditionRow::into_edition).transpose()
}
async fn create_edition(&self, new: &NewEdition) -> Result<Edition, CoreError> {
let cover: Option<&[u8]> = new.cover_blob_sha256.as_ref().map(|s| &s.as_bytes()[..]);
let isbn = new.metadata.isbn13.as_ref().map(Isbn13::as_str);
let row = sqlx::query_as!(
EditionRow,
r#"
INSERT INTO editions
(work_id, blob_sha256, title, author, language, isbn13, publisher,
cover_blob_sha256, metadata_json, copyright_status, extraction_status,
extraction_error)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::text::copyright_status, $11, $12)
RETURNING id, work_id, blob_sha256, title, author, language, isbn13, publisher,
cover_blob_sha256, copyright_status::text AS "copyright_status!",
extraction_status, extraction_error, created_at
"#,
new.work_id.as_uuid(),
&new.blob_sha256.as_bytes()[..],
new.metadata.title.as_deref(),
new.metadata.author.as_deref(),
new.metadata.language.as_deref(),
isbn,
new.metadata.publisher.as_deref(),
cover,
new.metadata.raw_opf,
new.copyright_status.as_db_str(),
new.extraction_status,
new.extraction_error.as_deref(),
)
.fetch_one(&self.pool)
.await
.map_err(repo_err)?;
row.into_edition()
}
async fn find_works_by_isbn(&self, isbn: &Isbn13) -> Result<Vec<Work>, CoreError> {
let rows = sqlx::query_as!(
WorkRow,
r#"
SELECT DISTINCT w.id, w.canonical_title, w.canonical_author,
w.copyright_status::text AS "copyright_status!", w.copyright_note,
w.created_at, w.updated_at
FROM works w
JOIN editions e ON e.work_id = w.id
WHERE e.isbn13 = $1
"#,
isbn.as_str(),
)
.fetch_all(&self.pool)
.await
.map_err(repo_err)?;
Ok(rows.into_iter().map(WorkRow::into_work).collect())
}
async fn fuzzy_match_works(
&self,
title: &str,
_author: Option<&str>,
threshold: f32,
limit: i64,
) -> Result<Vec<(Work, f32)>, CoreError> {
let rows = sqlx::query!(
r#"
SELECT w.id, w.canonical_title, w.canonical_author,
w.copyright_status::text AS "copyright_status!", w.copyright_note,
w.created_at, w.updated_at,
similarity(w.canonical_title, $1) AS "score!"
FROM works w
WHERE similarity(w.canonical_title, $1) >= $2
ORDER BY similarity(w.canonical_title, $1) DESC
LIMIT $3
"#,
title,
threshold,
limit,
)
.fetch_all(&self.pool)
.await
.map_err(repo_err)?;
Ok(rows
.into_iter()
.map(|r| {
let work = Work {
id: r.id.into(),
canonical_title: r.canonical_title,
canonical_author: r.canonical_author,
copyright_status: parse_copyright(&r.copyright_status),
copyright_note: r.copyright_note,
created_at: r.created_at,
updated_at: r.updated_at,
};
(work, r.score)
})
.collect())
}
async fn create_work(
&self,
title: &str,
author: Option<&str>,
status: CopyrightStatus,
) -> Result<Work, CoreError> {
let row = sqlx::query_as!(
WorkRow,
r#"
INSERT INTO works (canonical_title, canonical_author, copyright_status)
VALUES ($1, $2, $3::text::copyright_status)
RETURNING id, canonical_title, canonical_author,
copyright_status::text AS "copyright_status!", copyright_note,
created_at, updated_at
"#,
title,
author,
status.as_db_str(),
)
.fetch_one(&self.pool)
.await
.map_err(repo_err)?;
Ok(row.into_work())
}
async fn get_edition(&self, id: EditionId) -> Result<Option<Edition>, CoreError> {
let row = sqlx::query_as!(
EditionRow,
r#"
SELECT id, work_id, blob_sha256, title, author, language, isbn13, publisher,
cover_blob_sha256, copyright_status::text AS "copyright_status!",
extraction_status, extraction_error, created_at
FROM editions
WHERE id = $1
"#,
id.as_uuid(),
)
.fetch_optional(&self.pool)
.await
.map_err(repo_err)?;
row.map(EditionRow::into_edition).transpose()
}
async fn get_work(&self, id: WorkId) -> Result<Option<Work>, CoreError> {
let row = sqlx::query_as!(
WorkRow,
r#"
SELECT id, canonical_title, canonical_author,
copyright_status::text AS "copyright_status!", copyright_note,
created_at, updated_at
FROM works
WHERE id = $1
"#,
id.as_uuid(),
)
.fetch_optional(&self.pool)
.await
.map_err(repo_err)?;
Ok(row.map(WorkRow::into_work))
}
async fn list_editions_for_work(&self, id: WorkId) -> Result<Vec<Edition>, CoreError> {
let rows = sqlx::query_as!(
EditionRow,
r#"
SELECT id, work_id, blob_sha256, title, author, language, isbn13, publisher,
cover_blob_sha256, copyright_status::text AS "copyright_status!",
extraction_status, extraction_error, created_at
FROM editions
WHERE work_id = $1
ORDER BY created_at
"#,
id.as_uuid(),
)
.fetch_all(&self.pool)
.await
.map_err(repo_err)?;
rows.into_iter().map(EditionRow::into_edition).collect()
}
async fn list_catalog(&self, page: PageReq) -> Result<(Vec<Edition>, i64), CoreError> {
let limit = page.limit.clamp(1, 200);
let offset = page.offset.max(0);
let rows = sqlx::query_as!(
EditionRow,
r#"
SELECT id, work_id, blob_sha256, title, author, language, isbn13, publisher,
cover_blob_sha256, copyright_status::text AS "copyright_status!",
extraction_status, extraction_error, created_at
FROM editions
ORDER BY created_at DESC
OFFSET $1 LIMIT $2
"#,
offset,
limit,
)
.fetch_all(&self.pool)
.await
.map_err(repo_err)?;
let total = sqlx::query_scalar!(r#"SELECT count(*) AS "count!" FROM editions"#)
.fetch_one(&self.pool)
.await
.map_err(repo_err)?;
let editions = rows
.into_iter()
.map(EditionRow::into_edition)
.collect::<Result<Vec<_>, _>>()?;
Ok((editions, total))
}
async fn set_copyright(
&self,
work: WorkId,
status: CopyrightStatus,
note: Option<&str>,
) -> Result<(), CoreError> {
let affected = sqlx::query!(
r#"
UPDATE works
SET copyright_status = $2::text::copyright_status, copyright_note = $3, updated_at = now()
WHERE id = $1
"#,
work.as_uuid(),
status.as_db_str(),
note,
)
.execute(&self.pool)
.await
.map_err(repo_err)?
.rows_affected();
if affected == 0 {
return Err(CoreError::NotFound);
}
Ok(())
}
async fn merge_works(
&self,
src: WorkId,
dst: WorkId,
by: Option<UserId>,
reason: Option<&str>,
) -> Result<(), CoreError> {
if src == dst {
return Err(CoreError::Conflict(
"source and destination work are the same".to_string(),
));
}
let mut tx = self.pool.begin().await.map_err(repo_err)?;
// Both works must exist.
let dst_exists = sqlx::query_scalar!(
r#"SELECT exists(SELECT 1 FROM works WHERE id = $1) AS "e!""#,
dst.as_uuid()
)
.fetch_one(&mut *tx)
.await
.map_err(repo_err)?;
if !dst_exists {
return Err(CoreError::NotFound);
}
sqlx::query!(
r#"
INSERT INTO work_merges (merged_work_id, surviving_work_id, merged_by, reason)
VALUES ($1, $2, $3, $4)
"#,
src.as_uuid(),
dst.as_uuid(),
by.map(|u| u.as_uuid()),
reason,
)
.execute(&mut *tx)
.await
.map_err(repo_err)?;
sqlx::query!(
"UPDATE editions SET work_id = $2 WHERE work_id = $1",
src.as_uuid(),
dst.as_uuid(),
)
.execute(&mut *tx)
.await
.map_err(|e| match e {
sqlx::Error::Database(db) if db.is_unique_violation() => CoreError::Conflict(
"destination work already has an edition for one of the source's files".to_string(),
),
other => repo_err(other),
})?;
let deleted = sqlx::query!("DELETE FROM works WHERE id = $1", src.as_uuid())
.execute(&mut *tx)
.await
.map_err(repo_err)?
.rows_affected();
if deleted == 0 {
return Err(CoreError::NotFound);
}
tx.commit().await.map_err(repo_err)?;
Ok(())
}
}

View File

@@ -0,0 +1,74 @@
//! Postgres-backed [`EntitlementRepo`].
use async_trait::async_trait;
use sqlx::PgPool;
use audioblume_core::CoreError;
use audioblume_core::ports::EntitlementRepo;
use audioblume_entities::EntitlementSource;
use audioblume_entities::ids::{EditionId, UserId};
use crate::convert::{parse_entitlement_source, repo_err};
/// Entitlement persistence over a [`PgPool`].
pub struct PgEntitlementRepo {
pool: PgPool,
}
impl PgEntitlementRepo {
/// Construct a new repository over the given pool.
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl EntitlementRepo for PgEntitlementRepo {
async fn grant(
&self,
user: UserId,
edition: EditionId,
source: EntitlementSource,
) -> Result<(), CoreError> {
let source_label = match source {
EntitlementSource::Upload => "upload",
EntitlementSource::Purchase => "purchase",
EntitlementSource::Grant => "grant",
};
sqlx::query!(
r#"
INSERT INTO entitlements (user_id, edition_id, source)
VALUES ($1, $2, $3::text::entitlement_source)
ON CONFLICT (user_id, edition_id) DO NOTHING
"#,
user.as_uuid(),
edition.as_uuid(),
source_label,
)
.execute(&self.pool)
.await
.map_err(repo_err)?;
Ok(())
}
async fn entitlement_source(
&self,
user: UserId,
edition: EditionId,
) -> Result<Option<EntitlementSource>, CoreError> {
let rec = sqlx::query!(
r#"
SELECT source::text AS "source!"
FROM entitlements
WHERE user_id = $1 AND edition_id = $2
"#,
user.as_uuid(),
edition.as_uuid(),
)
.fetch_optional(&self.pool)
.await
.map_err(repo_err)?;
Ok(rec.map(|r| parse_entitlement_source(&r.source)))
}
}

View File

@@ -0,0 +1,13 @@
//! Postgres adapter implementations of the core repository ports.
mod blobs;
mod catalog;
mod entitlements;
mod sessions;
mod users;
pub use blobs::PgBlobRepo;
pub use catalog::PgCatalogRepo;
pub use entitlements::PgEntitlementRepo;
pub use sessions::PgSessionRepo;
pub use users::PgUserRepo;

View File

@@ -0,0 +1,103 @@
//! Postgres-backed [`SessionRepo`].
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use audioblume_core::CoreError;
use audioblume_core::ports::SessionRepo;
use audioblume_entities::ids::{SessionId, UserId};
use crate::convert::repo_err;
/// Session persistence over a [`PgPool`].
pub struct PgSessionRepo {
pool: PgPool,
}
impl PgSessionRepo {
/// Construct a new repository over the given pool.
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl SessionRepo for PgSessionRepo {
async fn create(
&self,
user: UserId,
token_hash: &[u8],
expires_at: DateTime<Utc>,
) -> Result<SessionId, CoreError> {
let rec = sqlx::query!(
r#"
INSERT INTO sessions (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
RETURNING id
"#,
user.as_uuid(),
token_hash,
expires_at,
)
.fetch_one(&self.pool)
.await
.map_err(repo_err)?;
Ok(rec.id.into())
}
async fn find_active(
&self,
token_hash: &[u8],
now: DateTime<Utc>,
) -> Result<Option<(SessionId, UserId)>, CoreError> {
let rec = sqlx::query!(
r#"
SELECT id, user_id
FROM sessions
WHERE token_hash = $1 AND expires_at > $2 AND revoked_at IS NULL
"#,
token_hash,
now,
)
.fetch_optional(&self.pool)
.await
.map_err(repo_err)?;
Ok(rec.map(|r| (r.id.into(), r.user_id.into())))
}
async fn touch(&self, id: SessionId, now: DateTime<Utc>) -> Result<(), CoreError> {
sqlx::query!(
"UPDATE sessions SET last_seen_at = $2 WHERE id = $1",
id.as_uuid(),
now,
)
.execute(&self.pool)
.await
.map_err(repo_err)?;
Ok(())
}
async fn revoke_by_token_hash(&self, token_hash: &[u8]) -> Result<(), CoreError> {
sqlx::query!(
"UPDATE sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL",
token_hash,
)
.execute(&self.pool)
.await
.map_err(repo_err)?;
Ok(())
}
async fn revoke_for_user(&self, user: UserId) -> Result<u64, CoreError> {
let result = sqlx::query!(
"UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL",
user.as_uuid(),
)
.execute(&self.pool)
.await
.map_err(repo_err)?;
Ok(result.rows_affected())
}
}

View File

@@ -0,0 +1,128 @@
//! Postgres-backed [`UserRepo`].
use async_trait::async_trait;
use sqlx::PgPool;
use audioblume_core::CoreError;
use audioblume_core::ports::UserRepo;
use audioblume_entities::User;
use audioblume_entities::ids::UserId;
use crate::convert::repo_err;
/// User account persistence over a [`PgPool`].
pub struct PgUserRepo {
pool: PgPool,
}
impl PgUserRepo {
/// Construct a new repository over the given pool.
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl UserRepo for PgUserRepo {
async fn create(
&self,
email: &str,
display_name: Option<&str>,
password_hash: &str,
is_admin: bool,
) -> Result<User, CoreError> {
let rec = sqlx::query!(
r#"
INSERT INTO users (email, display_name, password_hash, is_admin)
VALUES ($1, $2, $3, $4)
RETURNING id, email, display_name, password_hash, is_admin, status, created_at, updated_at
"#,
email,
display_name,
password_hash,
is_admin,
)
.fetch_one(&self.pool)
.await
.map_err(|e| match e {
sqlx::Error::Database(db) if db.is_unique_violation() => {
CoreError::Conflict("email already registered".to_string())
}
other => repo_err(other),
})?;
Ok(User {
id: rec.id.into(),
email: rec.email,
display_name: rec.display_name,
password_hash: rec.password_hash,
is_admin: rec.is_admin,
status: rec.status,
created_at: rec.created_at,
updated_at: rec.updated_at,
})
}
async fn find_by_email(&self, email: &str) -> Result<Option<User>, CoreError> {
let rec = sqlx::query!(
r#"
SELECT id, email, display_name, password_hash, is_admin, status, created_at, updated_at
FROM users
WHERE lower(email) = lower($1)
"#,
email,
)
.fetch_optional(&self.pool)
.await
.map_err(repo_err)?;
Ok(rec.map(|r| User {
id: r.id.into(),
email: r.email,
display_name: r.display_name,
password_hash: r.password_hash,
is_admin: r.is_admin,
status: r.status,
created_at: r.created_at,
updated_at: r.updated_at,
}))
}
async fn find_by_id(&self, id: UserId) -> Result<Option<User>, CoreError> {
let rec = sqlx::query!(
r#"
SELECT id, email, display_name, password_hash, is_admin, status, created_at, updated_at
FROM users
WHERE id = $1
"#,
id.as_uuid(),
)
.fetch_optional(&self.pool)
.await
.map_err(repo_err)?;
Ok(rec.map(|r| User {
id: r.id.into(),
email: r.email,
display_name: r.display_name,
password_hash: r.password_hash,
is_admin: r.is_admin,
status: r.status,
created_at: r.created_at,
updated_at: r.updated_at,
}))
}
async fn set_status(&self, id: UserId, status: &str) -> Result<(), CoreError> {
sqlx::query!(
"UPDATE users SET status = $2, updated_at = now() WHERE id = $1",
id.as_uuid(),
status,
)
.execute(&self.pool)
.await
.map_err(repo_err)?;
Ok(())
}
}

View File

@@ -0,0 +1,173 @@
//! MinIO / S3-compatible object store adapter.
use std::time::Duration;
use async_trait::async_trait;
use aws_sdk_s3::Client;
use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
use aws_sdk_s3::presigning::PresigningConfig;
use aws_sdk_s3::primitives::ByteStream;
use audioblume_core::CoreError;
use audioblume_core::ports::BlobStore;
use audioblume_entities::BlobKind;
/// Settings for connecting to the object store.
#[derive(Debug, Clone)]
pub struct S3Settings {
/// The S3 endpoint URL, e.g. `http://localhost:9000` for a local MinIO.
pub endpoint: String,
/// The region label. MinIO ignores it but the SDK requires one.
pub region: String,
/// Access key id.
pub access_key: String,
/// Secret access key.
pub secret_key: String,
}
/// An object store backed by an S3-compatible service (MinIO in this deployment).
pub struct S3BlobStore {
client: Client,
}
impl S3BlobStore {
/// Build a client from the given settings. Path-style addressing is forced because MinIO
/// does not support virtual-host-style bucket addressing by default.
#[must_use]
pub fn new(settings: &S3Settings) -> Self {
let creds = Credentials::new(
&settings.access_key,
&settings.secret_key,
None,
None,
"audioblume-static",
);
let config = aws_sdk_s3::Config::builder()
.behavior_version(BehaviorVersion::latest())
.endpoint_url(&settings.endpoint)
.region(Region::new(settings.region.clone()))
.credentials_provider(creds)
.force_path_style(true)
.build();
Self {
client: Client::from_conf(config),
}
}
/// Borrow the underlying client (used by the CLI's `blob verify` audit).
#[must_use]
pub fn client(&self) -> &Client {
&self.client
}
/// Create the bucket if it does not already exist. Convenient for local development and
/// first deploys; an already-existing bucket is not an error.
pub async fn ensure_bucket(&self, bucket: &str) -> Result<(), CoreError> {
match self.client.create_bucket().bucket(bucket).send().await {
Ok(_) => Ok(()),
Err(e) => {
let svc = e.into_service_error();
if svc.is_bucket_already_owned_by_you() || svc.is_bucket_already_exists() {
Ok(())
} else {
Err(CoreError::Storage(svc.to_string()))
}
}
}
}
}
fn content_type(kind: BlobKind) -> &'static str {
match kind {
BlobKind::Epub => "application/epub+zip",
BlobKind::Cover => "application/octet-stream",
BlobKind::AudioM4b => "audio/mp4",
BlobKind::AudioHlsSegment => "audio/aac",
}
}
#[async_trait]
impl BlobStore for S3BlobStore {
async fn exists(&self, key: &str, bucket: &str) -> Result<bool, CoreError> {
match self
.client
.head_object()
.bucket(bucket)
.key(key)
.send()
.await
{
Ok(_) => Ok(true),
Err(e) => {
let svc = e.into_service_error();
if svc.is_not_found() {
Ok(false)
} else {
Err(CoreError::Storage(svc.to_string()))
}
}
}
}
async fn put(
&self,
key: &str,
bucket: &str,
kind: BlobKind,
bytes: Vec<u8>,
) -> Result<(), CoreError> {
self.client
.put_object()
.bucket(bucket)
.key(key)
.content_type(content_type(kind))
.body(ByteStream::from(bytes))
.send()
.await
.map_err(|e| CoreError::Storage(e.into_service_error().to_string()))?;
Ok(())
}
async fn get(&self, key: &str, bucket: &str) -> Result<Vec<u8>, CoreError> {
let out = self
.client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.map_err(|e| {
let svc = e.into_service_error();
if svc.is_no_such_key() {
CoreError::NotFound
} else {
CoreError::Storage(svc.to_string())
}
})?;
let data = out
.body
.collect()
.await
.map_err(|e| CoreError::Storage(e.to_string()))?;
Ok(data.into_bytes().to_vec())
}
async fn presign_get(
&self,
key: &str,
bucket: &str,
ttl_seconds: u64,
) -> Result<String, CoreError> {
let presign = PresigningConfig::expires_in(Duration::from_secs(ttl_seconds))
.map_err(|e| CoreError::Storage(e.to_string()))?;
let req = self
.client
.get_object()
.bucket(bucket)
.key(key)
.presigned(presign)
.await
.map_err(|e| CoreError::Storage(e.to_string()))?;
Ok(req.uri().to_string())
}
}

View File

@@ -0,0 +1,129 @@
//! Wiring: build a Postgres pool + S3 client and assemble a [`Ctx`] of port adapters.
use std::path::PathBuf;
use std::sync::Arc;
use sqlx::PgPool;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode};
use audioblume_core::CoreError;
use audioblume_core::context::{CoreConfig, Ctx};
use crate::epub::EpubExtractorImpl;
use crate::pg::{PgBlobRepo, PgCatalogRepo, PgEntitlementRepo, PgSessionRepo, PgUserRepo};
use crate::s3::{S3BlobStore, S3Settings};
/// Postgres connection settings.
///
/// In production the connection is mTLS and passwordless: `sslmode = verify-full` with a host
/// client certificate, and the database role is resolved from the cert CN via `pg_ident`.
/// The `password` field exists only for local development against a throwaway database.
#[derive(Debug, Clone)]
pub struct PgSettings {
/// Database host.
pub host: String,
/// Database port.
pub port: u16,
/// Database name.
pub database: String,
/// Database role / username.
pub user: String,
/// Development-only password. Leave `None` in production (mTLS).
pub password: Option<String>,
/// One of `disable`, `require`, `verify-ca`, `verify-full`.
pub sslmode: String,
/// Path to the root CA bundle that signs the server cert.
pub ssl_root_cert: Option<PathBuf>,
/// Path to the client certificate (for mTLS).
pub ssl_client_cert: Option<PathBuf>,
/// Path to the client private key (for mTLS).
pub ssl_client_key: Option<PathBuf>,
/// Connection pool size.
pub max_connections: u32,
}
fn ssl_mode(name: &str) -> PgSslMode {
match name {
"require" => PgSslMode::Require,
"verify-ca" => PgSslMode::VerifyCa,
"verify-full" => PgSslMode::VerifyFull,
_ => PgSslMode::Disable,
}
}
/// Build a connection pool from the settings.
pub async fn connect_pool(pg: &PgSettings) -> Result<PgPool, CoreError> {
let mut opts = PgConnectOptions::new()
.host(&pg.host)
.port(pg.port)
.database(&pg.database)
.username(&pg.user)
.ssl_mode(ssl_mode(&pg.sslmode));
if let Some(pw) = &pg.password {
opts = opts.password(pw);
}
if let Some(p) = &pg.ssl_root_cert {
opts = opts.ssl_root_cert(p);
}
if let Some(p) = &pg.ssl_client_cert {
opts = opts.ssl_client_cert(p);
}
if let Some(p) = &pg.ssl_client_key {
opts = opts.ssl_client_key(p);
}
PgPoolOptions::new()
.max_connections(pg.max_connections)
.connect_with(opts)
.await
.map_err(|e| CoreError::Repo(format!("connecting to postgres: {e}")))
}
/// The assembled data stack: the pool (for migrations) and a ready-to-use [`Ctx`].
pub struct DataStack {
/// The Postgres connection pool.
pub pool: PgPool,
/// The wired-up business-logic context.
pub ctx: Ctx,
}
impl DataStack {
/// Connect to Postgres and the object store and assemble the adapters into a [`Ctx`].
pub async fn connect(
pg: &PgSettings,
s3: &S3Settings,
core_config: CoreConfig,
) -> Result<Self, CoreError> {
let pool = connect_pool(pg).await?;
let users = Arc::new(PgUserRepo::new(pool.clone()));
let sessions = Arc::new(PgSessionRepo::new(pool.clone()));
let blobs = Arc::new(PgBlobRepo::new(pool.clone()));
let catalog = Arc::new(PgCatalogRepo::new(pool.clone()));
let entitlements = Arc::new(PgEntitlementRepo::new(pool.clone()));
let blob_store = Arc::new(S3BlobStore::new(s3));
let epub = Arc::new(EpubExtractorImpl);
let ctx = Ctx {
users,
sessions,
blobs,
blob_store,
catalog,
entitlements,
epub,
config: core_config,
};
Ok(Self { pool, ctx })
}
/// Run the embedded migrations against the pool.
pub async fn migrate(&self) -> Result<(), CoreError> {
sqlx::migrate!("./migrations")
.run(&self.pool)
.await
.map_err(|e| CoreError::Repo(format!("running migrations: {e}")))
}
}

View File

@@ -0,0 +1,16 @@
[package]
name = "audioblume-entities"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
description = "Domain types, DTOs, and error enums for audioblume. No I/O."
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
chrono.workspace = true
uuid.workspace = true
hex.workspace = true

View File

@@ -0,0 +1,210 @@
//! Core domain entities.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::enums::{BlobKind, CopyrightStatus, EntitlementSource};
use crate::hash::Sha256;
use crate::ids::{EditionId, EntitlementId, UserId, WorkId};
use crate::isbn::Isbn13;
/// A user account, including the stored password hash. Never serialize this to a client;
/// use [`PublicUser`] for outward-facing responses.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
/// Account identifier.
pub id: UserId,
/// Case-insensitive email address (unique).
pub email: String,
/// Optional display name.
pub display_name: Option<String>,
/// Argon2id PHC-encoded password hash.
pub password_hash: String,
/// Whether the account has operator privileges.
pub is_admin: bool,
/// `active` or `disabled`.
pub status: String,
/// When the account was created.
pub created_at: DateTime<Utc>,
/// When the account was last modified.
pub updated_at: DateTime<Utc>,
}
impl User {
/// Whether the account may authenticate and act.
#[must_use]
pub fn is_active(&self) -> bool {
self.status == "active"
}
/// Project to the outward-facing view.
#[must_use]
pub fn to_public(&self) -> PublicUser {
PublicUser {
id: self.id,
email: self.email.clone(),
display_name: self.display_name.clone(),
is_admin: self.is_admin,
created_at: self.created_at,
}
}
}
/// The client-safe projection of a [`User`] — no password hash, no internal status.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublicUser {
/// Account identifier.
pub id: UserId,
/// Email address.
pub email: String,
/// Optional display name.
pub display_name: Option<String>,
/// Whether the account has operator privileges.
pub is_admin: bool,
/// When the account was created.
pub created_at: DateTime<Utc>,
}
/// A physical, content-addressed object stored once in the object store.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Blob {
/// The content address.
pub sha256: Sha256,
/// What kind of content this is.
pub kind: BlobKind,
/// Size in bytes.
pub byte_size: i64,
/// The object-store key.
pub storage_key: String,
/// The object-store bucket.
pub storage_bucket: String,
/// When the blob was first stored.
pub created_at: DateTime<Utc>,
}
/// A logical title that clusters one or more editions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Work {
/// Work identifier.
pub id: WorkId,
/// Canonical display title.
pub canonical_title: String,
/// Canonical author, if known.
pub canonical_author: Option<String>,
/// Copyright disposition of the work.
pub copyright_status: CopyrightStatus,
/// Operator annotation explaining the copyright determination.
pub copyright_note: Option<String>,
/// When the work was created.
pub created_at: DateTime<Utc>,
/// When the work was last modified.
pub updated_at: DateTime<Utc>,
}
/// Metadata extracted from an EPUB's OPF package document.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EditionMetadata {
/// Title (`dc:title`).
pub title: Option<String>,
/// Author (`dc:creator`).
pub author: Option<String>,
/// BCP-47 / `dc:language`.
pub language: Option<String>,
/// Normalized ISBN-13 derived from `dc:identifier`, if present and valid.
pub isbn13: Option<Isbn13>,
/// Publisher (`dc:publisher`).
pub publisher: Option<String>,
/// Full raw OPF metadata, retained for forward compatibility and richer future passes.
pub raw_opf: serde_json::Value,
}
/// A specific EPUB file plus its extracted metadata, belonging to one work.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Edition {
/// Edition identifier.
pub id: EditionId,
/// The work this edition belongs to.
pub work_id: WorkId,
/// Content address of the EPUB blob.
pub blob_sha256: Sha256,
/// Title as recorded for this edition.
pub title: Option<String>,
/// Author as recorded for this edition.
pub author: Option<String>,
/// Language as recorded for this edition.
pub language: Option<String>,
/// ISBN-13, if extracted.
pub isbn13: Option<Isbn13>,
/// Publisher, if extracted.
pub publisher: Option<String>,
/// Content address of the extracted cover image blob, if any.
pub cover_blob_sha256: Option<Sha256>,
/// Per-edition copyright override; falls back to the work's status when `Unknown`.
pub copyright_status: CopyrightStatus,
/// `pending`, `ok`, or `failed`.
pub extraction_status: String,
/// Detail when extraction failed.
pub extraction_error: Option<String>,
/// When the edition was created.
pub created_at: DateTime<Utc>,
}
/// A record that a user holds the right to access an edition.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Entitlement {
/// Entitlement identifier.
pub id: EntitlementId,
/// The user who holds the entitlement.
pub user_id: UserId,
/// The edition the entitlement applies to.
pub edition_id: EditionId,
/// How the entitlement was acquired.
pub source: EntitlementSource,
/// When the entitlement was granted.
pub created_at: DateTime<Utc>,
}
/// Why an access decision came out the way it did.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AccessReason {
/// The work is public domain.
PublicDomain,
/// The caller uploaded this edition.
OwnedViaUpload,
/// The caller purchased this edition.
OwnedViaPurchase,
/// An operator granted the caller access.
GrantedByAdmin,
/// The caller has no entitlement and the work is not public domain.
Denied,
}
/// The outcome of an access check for a `(user, edition)` pair.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccessDecision {
/// Whether access to the content is permitted.
pub allowed: bool,
/// The reason for the decision.
pub reason: AccessReason,
}
impl AccessDecision {
/// An allowing decision with the given reason.
#[must_use]
pub fn allow(reason: AccessReason) -> Self {
Self {
allowed: true,
reason,
}
}
/// A denying decision.
#[must_use]
pub fn deny() -> Self {
Self {
allowed: false,
reason: AccessReason::Denied,
}
}
}

View File

@@ -0,0 +1,139 @@
//! Request and response payloads for the API. Kept in `entities` so any client (web,
//! desktop, mobile) can depend on the same shapes.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::domain::{AccessDecision, Edition, PublicUser, Work};
use crate::enums::CopyrightStatus;
use crate::hash::Sha256;
use crate::ids::{EditionId, WorkId};
use crate::token::SessionToken;
/// Request body for account creation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignupRequest {
/// Email address (must be unique).
pub email: String,
/// Plaintext password (hashed server-side; never stored).
pub password: String,
/// Optional display name.
#[serde(default)]
pub display_name: Option<String>,
}
/// Request body for login.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginRequest {
/// Email address.
pub email: String,
/// Plaintext password.
pub password: String,
}
/// Successful login response. The token is returned exactly once and cannot be retrieved
/// again — only its hash is persisted.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginResponse {
/// The bearer token to send as `Authorization: Bearer <token>`.
pub token: SessionToken,
/// When the token expires.
pub expires_at: DateTime<Utc>,
/// The authenticated user.
pub user: PublicUser,
}
/// Response after uploading an EPUB.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UploadResponse {
/// The edition the upload resolved to (new or pre-existing).
pub edition_id: EditionId,
/// The work the edition belongs to.
pub work_id: WorkId,
/// The content address of the stored EPUB.
pub blob_sha256: Sha256,
/// Whether the bytes already existed (the upload was deduplicated).
pub deduplicated: bool,
/// The effective copyright status of the edition.
pub copyright_status: CopyrightStatus,
}
/// A single catalog listing. Metadata is visible to everyone (bookstore-style); `access`
/// reports whether the calling user may read the content.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CatalogItem {
/// The edition.
pub edition_id: EditionId,
/// The work the edition belongs to.
pub work_id: WorkId,
/// Display title.
pub title: Option<String>,
/// Display author.
pub author: Option<String>,
/// Language.
pub language: Option<String>,
/// Whether a cover image is available at `/v1/editions/{id}/cover`.
pub has_cover: bool,
/// Effective copyright status.
pub copyright_status: CopyrightStatus,
/// The calling user's access decision (always `Denied` for anonymous callers unless the
/// work is public domain).
pub access: AccessDecision,
}
/// Detailed view of a single edition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditionDetail {
/// The edition record.
pub edition: Edition,
/// The parent work.
pub work: Work,
/// The calling user's access decision.
pub access: AccessDecision,
}
/// Detailed view of a work and its editions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkDetail {
/// The work record.
pub work: Work,
/// All editions clustered under the work.
pub editions: Vec<Edition>,
}
/// Pagination request parameters.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct PageReq {
/// Zero-based page offset in items.
#[serde(default)]
pub offset: i64,
/// Page size (clamped server-side).
#[serde(default = "default_limit")]
pub limit: i64,
}
fn default_limit() -> i64 {
50
}
impl Default for PageReq {
fn default() -> Self {
Self {
offset: 0,
limit: default_limit(),
}
}
}
/// A page of results.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Page<T> {
/// The items on this page.
pub items: Vec<T>,
/// The total number of items available across all pages.
pub total: i64,
/// The offset this page started at.
pub offset: i64,
/// The page size used.
pub limit: i64,
}

View File

@@ -0,0 +1,92 @@
//! Domain enumerations, mirrored by Postgres `CREATE TYPE` enums in the data crate.
use serde::{Deserialize, Serialize};
/// Copyright disposition of a work or edition.
///
/// `Unknown` is the conservative default for freshly ingested uploads: it is gated exactly
/// like `InCopyright` (content requires an entitlement) but is distinguishable from a
/// deliberate determination, so an operator or future enrichment pass can review it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CopyrightStatus {
/// Not yet determined. Gated like `InCopyright`.
Unknown,
/// Out of copyright; readable by every user.
PublicDomain,
/// In copyright; readable only by users who own this edition.
InCopyright,
/// In copyright but licensed for sale; readable by purchasers (a future sale creates an
/// entitlement). Until then, gated like `InCopyright`.
LicensedForSale,
}
impl CopyrightStatus {
/// Whether this status makes content freely readable by any user.
#[must_use]
pub fn is_publicly_readable(self) -> bool {
matches!(self, CopyrightStatus::PublicDomain)
}
/// String form matching the Postgres enum label.
#[must_use]
pub fn as_db_str(self) -> &'static str {
match self {
CopyrightStatus::Unknown => "unknown",
CopyrightStatus::PublicDomain => "public_domain",
CopyrightStatus::InCopyright => "in_copyright",
CopyrightStatus::LicensedForSale => "licensed_for_sale",
}
}
}
/// Why a user holds an entitlement to an edition.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EntitlementSource {
/// The user uploaded this edition's file (proof they have their own copy).
Upload,
/// The user purchased access (later phase).
Purchase,
/// An operator granted access.
Grant,
}
/// The kind of content a blob holds. Foundation only stores `Epub` and `Cover`; the audio
/// variants are reserved for the later narration pipeline.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BlobKind {
/// An EPUB document.
Epub,
/// An extracted cover image.
Cover,
/// A downloadable narrated audiobook (M4B). Reserved.
AudioM4b,
/// A single HLS audio segment. Reserved.
AudioHlsSegment,
}
impl BlobKind {
/// The object-store key prefix for this kind.
#[must_use]
pub fn key_prefix(self) -> &'static str {
match self {
BlobKind::Epub => "epub",
BlobKind::Cover => "cover",
BlobKind::AudioM4b => "audio-m4b",
BlobKind::AudioHlsSegment => "audio-hls",
}
}
/// String form matching the Postgres enum label.
#[must_use]
pub fn as_db_str(self) -> &'static str {
match self {
BlobKind::Epub => "epub",
BlobKind::Cover => "cover",
BlobKind::AudioM4b => "audio_m4b",
BlobKind::AudioHlsSegment => "audio_hls_segment",
}
}
}

View File

@@ -0,0 +1,26 @@
//! Validation errors for constructing domain types.
use thiserror::Error;
/// Errors raised when validating or constructing domain values.
///
/// These are pure validation failures with no I/O involved; the `core` crate wraps
/// them into its own error type at the business-logic boundary.
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum EntityError {
/// An email address failed basic structural validation.
#[error("invalid email address")]
InvalidEmail,
/// A password did not meet the minimum policy.
#[error("password does not meet policy: {0}")]
InvalidPassword(&'static str),
/// An ISBN-13 string was not 13 digits (after normalization).
#[error("invalid ISBN-13: {0}")]
InvalidIsbn(String),
/// A SHA-256 hex string could not be parsed.
#[error("invalid SHA-256 hex: {0}")]
InvalidSha256(String),
/// A required text field was empty.
#[error("field must not be empty: {0}")]
Empty(&'static str),
}

View File

@@ -0,0 +1,104 @@
//! Content-addressing primitive: a SHA-256 digest.
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::EntityError;
/// A SHA-256 digest, used as the content address of a stored blob.
///
/// Serializes to and from a lowercase hex string so it is convenient in JSON and URLs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Sha256(pub [u8; 32]);
impl Sha256 {
/// The raw 32 bytes of the digest.
#[must_use]
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
/// Lowercase hex encoding of the digest (64 characters).
#[must_use]
pub fn to_hex(&self) -> String {
hex::encode(self.0)
}
/// Construct from a raw 32-byte slice; returns `None` if the length is wrong.
#[must_use]
pub fn from_slice(bytes: &[u8]) -> Option<Self> {
let arr: [u8; 32] = bytes.try_into().ok()?;
Some(Self(arr))
}
/// The object-store key for this digest, fanned out by the first two bytes to keep
/// any single prefix from accumulating an unbounded number of objects.
///
/// e.g. `epub/3f/a9/3fa9...` for kind `epub`.
#[must_use]
pub fn storage_key(&self, prefix: &str) -> String {
let hex = self.to_hex();
format!("{prefix}/{}/{}/{hex}", &hex[0..2], &hex[2..4])
}
}
impl fmt::Display for Sha256 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_hex())
}
}
impl FromStr for Sha256 {
type Err = EntityError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let bytes = hex::decode(s).map_err(|e| EntityError::InvalidSha256(e.to_string()))?;
Self::from_slice(&bytes).ok_or_else(|| {
EntityError::InvalidSha256(format!("expected 32 bytes, got {}", bytes.len()))
})
}
}
impl Serialize for Sha256 {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_hex())
}
}
impl<'de> Deserialize<'de> for Sha256 {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_round_trip() {
let digest = Sha256([0xab; 32]);
let hex = digest.to_hex();
assert_eq!(hex.len(), 64);
assert_eq!(Sha256::from_str(&hex).unwrap(), digest);
}
#[test]
fn storage_key_fans_out() {
let digest =
Sha256::from_str("3fa9b2c1d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff")
.unwrap();
assert_eq!(
digest.storage_key("epub"),
"epub/3f/a9/3fa9b2c1d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff"
);
}
#[test]
fn rejects_bad_length() {
assert!(Sha256::from_str("abcd").is_err());
}
}

View File

@@ -0,0 +1,84 @@
//! UUID-backed identifier newtypes.
//!
//! Each identifier is a transparent wrapper over [`uuid::Uuid`]. The newtypes prevent
//! accidentally passing a `UserId` where an `EditionId` is expected.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
macro_rules! uuid_newtype {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
)]
#[serde(transparent)]
pub struct $name(pub Uuid);
impl $name {
/// Generate a fresh random (v4) identifier.
#[must_use]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
/// The underlying [`Uuid`].
#[must_use]
pub fn as_uuid(&self) -> Uuid {
self.0
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl From<Uuid> for $name {
fn from(id: Uuid) -> Self {
Self(id)
}
}
impl From<$name> for Uuid {
fn from(id: $name) -> Self {
id.0
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::str::FromStr for $name {
type Err = uuid::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(Uuid::parse_str(s)?))
}
}
};
}
uuid_newtype!(
/// Identifies a user account.
UserId
);
uuid_newtype!(
/// Identifies a session.
SessionId
);
uuid_newtype!(
/// Identifies a logical work (a title that clusters one or more editions).
WorkId
);
uuid_newtype!(
/// Identifies an edition (a specific EPUB file plus its extracted metadata).
EditionId
);
uuid_newtype!(
/// Identifies an entitlement (a user's right to access an edition).
EntitlementId
);

View File

@@ -0,0 +1,147 @@
//! ISBN-13 value type with normalization.
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::error::EntityError;
/// A normalized ISBN-13: exactly 13 digits with check-digit validation.
///
/// Hyphens and surrounding whitespace are stripped on construction. ISBN-10 inputs are
/// converted to their ISBN-13 form. Note that ISBNs are a *clustering signal*, not a global
/// unique key — reprints can share an ISBN — so the catalog indexes but does not constrain.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Isbn13(String);
impl Isbn13 {
/// The 13-digit string.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
fn check_digit(first12: &[u8]) -> u8 {
let sum: u32 = first12
.iter()
.enumerate()
.map(|(i, d)| {
let weight = if i % 2 == 0 { 1 } else { 3 };
u32::from(*d) * weight
})
.sum();
let rem = (sum % 10) as u8;
if rem == 0 { 0 } else { 10 - rem }
}
fn isbn10_check_digit(first9: &[u8]) -> Option<char> {
let sum: u32 = first9
.iter()
.enumerate()
.map(|(i, d)| u32::from(*d) * (10 - i as u32))
.sum();
let rem = (11 - (sum % 11)) % 11;
match rem {
10 => Some('X'),
d => char::from_digit(d, 10),
}
}
}
impl FromStr for Isbn13 {
type Err = EntityError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let cleaned: String = s
.chars()
.filter(|c| !c.is_whitespace() && *c != '-')
.collect();
// Accept ISBN-10 and convert to ISBN-13.
if cleaned.len() == 10 {
let digits: Vec<u8> = cleaned[..9]
.chars()
.map(|c| c.to_digit(10).map(|d| d as u8))
.collect::<Option<_>>()
.ok_or_else(|| EntityError::InvalidIsbn(s.to_string()))?;
let expected = Isbn13::isbn10_check_digit(&digits)
.ok_or_else(|| EntityError::InvalidIsbn(s.to_string()))?;
let last = cleaned.chars().nth(9).unwrap().to_ascii_uppercase();
if last != expected {
return Err(EntityError::InvalidIsbn(s.to_string()));
}
// Prefix 978 and recompute the ISBN-13 check digit.
let mut prefixed = vec![9u8, 7, 8];
prefixed.extend_from_slice(&digits);
let cd = Isbn13::check_digit(&prefixed);
let body: String = prefixed
.iter()
.map(|d| char::from_digit(u32::from(*d), 10).unwrap())
.collect();
return Ok(Isbn13(format!("{body}{cd}")));
}
if cleaned.len() != 13 {
return Err(EntityError::InvalidIsbn(s.to_string()));
}
let digits: Vec<u8> = cleaned
.chars()
.map(|c| c.to_digit(10).map(|d| d as u8))
.collect::<Option<_>>()
.ok_or_else(|| EntityError::InvalidIsbn(s.to_string()))?;
let cd = Isbn13::check_digit(&digits[..12]);
if cd != digits[12] {
return Err(EntityError::InvalidIsbn(s.to_string()));
}
Ok(Isbn13(cleaned))
}
}
impl TryFrom<String> for Isbn13 {
type Error = EntityError;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse()
}
}
impl From<Isbn13> for String {
fn from(value: Isbn13) -> Self {
value.0
}
}
impl fmt::Display for Isbn13 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_valid_isbn13_with_hyphens() {
let isbn: Isbn13 = "978-0-306-40615-7".parse().unwrap();
assert_eq!(isbn.as_str(), "9780306406157");
}
#[test]
fn converts_isbn10() {
// 0-306-40615-2 -> 978-0-306-40615-7
let isbn: Isbn13 = "0-306-40615-2".parse().unwrap();
assert_eq!(isbn.as_str(), "9780306406157");
}
#[test]
fn rejects_bad_check_digit() {
assert!("9780306406158".parse::<Isbn13>().is_err());
}
#[test]
fn rejects_non_digits() {
assert!("97803064061XY".parse::<Isbn13>().is_err());
}
}

View File

@@ -0,0 +1,32 @@
//! Domain types, DTOs, and error enums for audioblume.
//!
//! This crate is pure data: no I/O, no async runtime, no database or network dependencies.
//! Everything else in the workspace depends on it, and the API request/response shapes live
//! here so clients can share them.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod domain;
pub mod dto;
pub mod enums;
pub mod error;
pub mod hash;
pub mod ids;
pub mod isbn;
pub mod token;
pub use domain::{
AccessDecision, AccessReason, Blob, Edition, EditionMetadata, Entitlement, PublicUser, User,
Work,
};
pub use dto::{
CatalogItem, EditionDetail, LoginRequest, LoginResponse, Page, PageReq, SignupRequest,
UploadResponse, WorkDetail,
};
pub use enums::{BlobKind, CopyrightStatus, EntitlementSource};
pub use error::EntityError;
pub use hash::Sha256;
pub use ids::{EditionId, EntitlementId, SessionId, UserId, WorkId};
pub use isbn::Isbn13;
pub use token::SessionToken;

View File

@@ -0,0 +1,40 @@
//! Opaque session token.
use std::fmt;
use serde::{Deserialize, Serialize};
/// An opaque bearer token handed to a client on login.
///
/// The raw value is high-entropy random data; only its SHA-256 hash is ever persisted.
/// `Debug` is redacted so the secret never leaks into logs. The value still serializes
/// in full because it must be returned to the client exactly once (in the login response).
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(transparent)]
pub struct SessionToken(pub String);
impl SessionToken {
/// Borrow the raw token string.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
/// Consume and return the inner string.
#[must_use]
pub fn into_inner(self) -> String {
self.0
}
}
impl fmt::Debug for SessionToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SessionToken(<redacted>)")
}
}
impl From<String> for SessionToken {
fn from(value: String) -> Self {
Self(value)
}
}

99
readme.md Normal file
View File

@@ -0,0 +1,99 @@
# audioblume
A suite for creating and serving audiobooks. Users upload their own EPUBs; the backend
deduplicates identical files into a single object store blob, catalogs each book, and gates
access by copyright status and ownership. Later phases add LLM translation, multi-voice TTS
narration (including consented, compensated clones of real human voices), and title sales.
This repository is a Rust cargo workspace and follows the conventions in
`~/git/architecture/generic.md`.
## What's here (foundation phase)
- **Accounts** — email + Argon2id password, opaque revocable session tokens.
- **EPUB upload + dedup** — files are content-addressed by SHA-256 and stored once in MinIO.
A byte-identical re-upload by another user records ownership without re-storing the bytes.
- **Catalog** — three layers: `blob` (physical bytes) → `edition` (one EPUB + its metadata)
`work` (the logical title clustering editions by ISBN / fuzzy title match).
- **Copyright gating** — `public_domain` works are readable by everyone; `in_copyright` /
`unknown` / `licensed_for_sale` content is readable only by users who own it. Metadata and
covers are browsable by everyone (bookstore-style).
Translation, narration, the voice-contributor marketplace, payments, and the web/mobile
frontends are deferred to later phases; their tables are modeled in migration `0004` so the
schema does not need a destructive migration when they land.
## Workspace layout
```
crates/
audioblume-entities/ types only — no I/O
audioblume-core/ business logic + port traits (no DB/network)
audioblume-data/ sqlx + S3/MinIO + epub adapters; migrations/
audioblume-api/ Axum REST/JSON daemon (/v1)
audioblume-cli/ operator/admin CLI
asset/ deployment artifacts (systemd, firewalld, nginx, config, sql)
script/deploy.sh deployment script
```
## Build
```sh
cargo build --workspace
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --check
cargo test --workspace
```
## Run locally
The API and CLI need a Postgres database and a MinIO (S3-compatible) endpoint. For local
development you can bring both up with podman:
```sh
podman run -d --name audioblume-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 docker.io/postgres:18
podman run -d --name audioblume-minio -p 9000:9000 -p 9001:9001 \
-e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \
docker.io/minio/minio server /data --console-address ':9001'
```
Point the binaries at them with a `config.toml` (see `asset/config/config.toml.tmpl`) or
environment variables (`AUDIOBLUME_*`). Run migrations, then start the API:
```sh
audioblume-cli --config ./config.toml db migrate
audioblume-api --config ./config.toml
```
> In production the database connection is **mTLS, passwordless** (host client certificate,
> cert-CN → role mapping). The `password`/`sslmode=disable` knobs exist only for local
> development against a throwaway database — never use them against the production cluster.
## Deploy
One-time bootstrap (per environment, before the first deploy):
```sh
# Postgres: create roles + databases on the PRIMARY (replication carries them to the standby).
psql -h magrathea.kosherinata.internal -d postgres -f asset/sql/bootstrap.sql
# MinIO (prod): create the bucket + a bucket-scoped service account for the app.
# Production MinIO is caveman.kosherinata.internal:9000. The script stores the app's
# generated credentials in `pass` (audioblume/minio_access_key, audioblume/minio_secret_key),
# where deploy.sh reads them to render the config.
./asset/minio/bootstrap.sh
```
Dev runs MinIO as a local container on the dev host and the app creates its own bucket on
startup (`create_bucket = true`), so dev needs no MinIO bootstrap.
Then deploy:
```sh
./script/deploy.sh <environment> [component...]
./script/deploy.sh dev api
./script/deploy.sh prod all --dry-run
```
See `asset/manifest.yml` for environments and components, and the architecture conventions
document for the full deployment contract.

4
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,4 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]
profile = "default"

263
script/deploy.sh Executable file
View File

@@ -0,0 +1,263 @@
#!/usr/bin/env bash
#
# deploy.sh — build and deploy audioblume components to their target hosts.
#
# ./script/deploy.sh <environment> [component...]
# ./script/deploy.sh dev api
# ./script/deploy.sh prod all
# ./script/deploy.sh prod default
# ./script/deploy.sh dev api --dry-run
#
# Reads asset/manifest.yml (yq) for the environment's components, hosts, and non-secret
# config. Secrets are resolved from `pass` at deploy time and substituted into the rendered
# config; the rendered file is rsynced and never written to source control.
#
# Targets are Fedora Server: systemd units, a dedicated service user, firewalld named
# services, SELinux enforcing. Every step is idempotent — re-running with no changes is a
# no-op beyond file copies. Errors are never suppressed (set -euo pipefail); commands that may
# legitimately fail on a first deploy are handled explicitly with a visible message.
set -euo pipefail
# --- locations ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
MANIFEST="${REPO_ROOT}/asset/manifest.yml"
ASSET="${REPO_ROOT}/asset"
APP="audioblume"
SECRET_PREFIX="${APP}" # `pass` path prefix, e.g. audioblume/minio_access_key
DRY_RUN=0
# --- logging -----------------------------------------------------------------------------
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
# run CMD... locally, honoring --dry-run.
run() {
if [[ "${DRY_RUN}" -eq 1 ]]; then
printf ' [dry-run] %s\n' "$*"
else
"$@"
fi
}
# ssh_run HOST CMD : run a shell command on HOST, honoring --dry-run.
ssh_run() {
local host="$1"; shift
if [[ "${DRY_RUN}" -eq 1 ]]; then
printf ' [dry-run] ssh %s %s\n' "${host}" "$*"
else
ssh -o BatchMode=yes "${host}" "$@"
fi
}
# --- prerequisites -----------------------------------------------------------------------
command -v yq >/dev/null || die "yq is required (mikefarah yq v4)"
command -v pass >/dev/null || die "pass is required for secret resolution"
[[ -f "${MANIFEST}" ]] || die "manifest not found: ${MANIFEST}"
# --- argument parsing --------------------------------------------------------------------
ARGS=()
for a in "$@"; do
case "$a" in
--dry-run) DRY_RUN=1 ;;
*) ARGS+=("$a") ;;
esac
done
set -- "${ARGS[@]:-}"
ENVIRONMENT="${1:-}"
[[ -n "${ENVIRONMENT}" ]] || die "usage: deploy.sh <environment> [component...] [--dry-run]"
shift || true
yq -e ".environments.${ENVIRONMENT}" "${MANIFEST}" >/dev/null 2>&1 \
|| die "environment '${ENVIRONMENT}' not found in manifest"
ALL_COMPONENTS=()
while IFS= read -r c; do ALL_COMPONENTS+=("$c"); done < <(
yq -r ".environments.${ENVIRONMENT}.components | keys | .[]" "${MANIFEST}"
)
[[ "${#ALL_COMPONENTS[@]}" -gt 0 ]] || die "no components defined for ${ENVIRONMENT}"
# Resolve the requested component set.
REQUESTED=("$@")
COMPONENTS=()
if [[ "${#REQUESTED[@]}" -eq 0 || "${REQUESTED[0]}" == "default" || "${REQUESTED[0]}" == "all" ]]; then
# `default` and `all` currently coincide (api is the only deployable component).
COMPONENTS=("${ALL_COMPONENTS[@]}")
else
for c in "${REQUESTED[@]}"; do
printf '%s\n' "${ALL_COMPONENTS[@]}" | grep -qx "$c" \
|| die "component '$c' not defined for ${ENVIRONMENT}"
COMPONENTS+=("$c")
done
fi
info "environment=${ENVIRONMENT} components=${COMPONENTS[*]} dry_run=${DRY_RUN}"
# --- build -------------------------------------------------------------------------------
build_binaries() {
info "building release binaries"
run cargo build --release --locked \
--manifest-path "${REPO_ROOT}/Cargo.toml" \
-p "${APP}-api" -p "${APP}-cli"
}
# --- config rendering --------------------------------------------------------------------
secret() {
# Resolve a secret from pass, failing loudly if absent.
local name="$1"
pass show "${SECRET_PREFIX}/${name}" 2>/dev/null \
|| die "missing secret in pass: ${SECRET_PREFIX}/${name}"
}
# render_config ENVIRONMENT COMPONENT HOST_FQDN OUTFILE
render_config() {
local env="$1" comp="$2" fqdn="$3" out="$4"
local base=".environments.${env}.components.${comp}.config"
local q; q() { yq -r "${base}.$1" "${MANIFEST}"; }
local minio_access minio_secret
minio_access="$(secret minio_access_key)"
minio_secret="$(secret minio_secret_key)"
if [[ "${DRY_RUN}" -eq 1 ]]; then
printf ' [dry-run] render config for %s on %s -> %s\n' "${comp}" "${fqdn}" "${out}"
return
fi
sed \
-e "s|{{BIND}}|$(q bind)|g" \
-e "s|{{LOG_FORMAT}}|$(q log_format)|g" \
-e "s|{{DB_HOST}}|$(q database.host)|g" \
-e "s|{{DB_PORT}}|$(q database.port)|g" \
-e "s|{{DB_NAME}}|$(q database.database)|g" \
-e "s|{{DB_USER}}|$(q database.user)|g" \
-e "s|{{DB_MAX_CONNECTIONS}}|$(q database.max_connections)|g" \
-e "s|{{HOST_FQDN}}|${fqdn}|g" \
-e "s|{{MINIO_ENDPOINT}}|$(q storage.endpoint)|g" \
-e "s|{{MINIO_REGION}}|$(q storage.region)|g" \
-e "s|{{MINIO_BUCKET}}|$(q storage.bucket)|g" \
-e "s|{{CREATE_BUCKET}}|$(q storage.create_bucket)|g" \
-e "s|{{MINIO_ACCESS_KEY}}|${minio_access}|g" \
-e "s|{{MINIO_SECRET_KEY}}|${minio_secret}|g" \
-e "s|{{SESSION_TTL_SECONDS}}|$(q catalog.session_ttl_seconds)|g" \
-e "s|{{FUZZY_MATCH_THRESHOLD}}|$(q catalog.fuzzy_match_threshold)|g" \
-e "s|{{DOWNLOAD_URL_TTL_SECONDS}}|$(q catalog.download_url_ttl_seconds)|g" \
"${ASSET}/config/config.toml.tmpl" > "${out}"
}
# render_cert_path HOST_FQDN OUTFILE — substitute the FQDN into the cert .path unit.
render_cert_path() {
local fqdn="$1" out="$2"
if [[ "${DRY_RUN}" -eq 1 ]]; then
printf ' [dry-run] render cert path unit for %s -> %s\n' "${fqdn}" "${out}"
return
fi
sed -e "s|{{HOST_FQDN}}|${fqdn}|g" \
"${ASSET}/systemd/${APP}-api-cert.path" > "${out}"
}
# --- per-host deploy of the api component ------------------------------------------------
deploy_api_host() {
local env="$1" host="$2"
info "deploying api to ${host}"
local fqdn
if [[ "${DRY_RUN}" -eq 1 ]]; then fqdn="${host}"; else fqdn="$(ssh_run "${host}" 'hostname -f')"; fi
# 1. Render config + host-specific cert path unit into a staging dir.
local stage; stage="$(mktemp -d)"
trap 'rm -rf "${stage}"' RETURN
render_config "${env}" "api" "${fqdn}" "${stage}/config.toml"
render_cert_path "${fqdn}" "${stage}/${APP}-api-cert.path"
# 2. Service account (sysusers) and ACL on the host private key.
run rsync -a "${ASSET}/systemd/${APP}.sysusers.conf" "${host}:/etc/sysusers.d/${APP}.conf"
ssh_run "${host}" "sudo systemd-sysusers"
ssh_run "${host}" "sudo setfacl -m u:${APP}:r /etc/pki/tls/private/\$(hostname -f).pem"
# 3. Directories with correct ownership/modes.
ssh_run "${host}" "sudo install -d -o root -g ${APP} -m 0750 /etc/${APP}"
ssh_run "${host}" "sudo install -d -o ${APP} -g ${APP} -m 0750 /var/lib/${APP}"
# 4. Ship the binaries, config, and units.
run rsync -a "${REPO_ROOT}/target/release/${APP}-api" "${host}:/tmp/${APP}-api"
run rsync -a "${REPO_ROOT}/target/release/${APP}-cli" "${host}:/tmp/${APP}-cli"
ssh_run "${host}" "sudo install -o root -g root -m 0755 /tmp/${APP}-api /usr/local/bin/${APP}-api"
ssh_run "${host}" "sudo install -o root -g root -m 0755 /tmp/${APP}-cli /usr/local/bin/${APP}-cli"
run rsync -a "${stage}/config.toml" "${host}:/tmp/${APP}-config.toml"
ssh_run "${host}" "sudo install -o root -g ${APP} -m 0640 /tmp/${APP}-config.toml /etc/${APP}/config.toml"
ssh_run "${host}" "rm -f /tmp/${APP}-api /tmp/${APP}-cli /tmp/${APP}-config.toml"
run rsync -a "${ASSET}/systemd/${APP}-api.service" "${host}:/tmp/${APP}-api.service"
run rsync -a "${stage}/${APP}-api-cert.path" "${host}:/tmp/${APP}-api-cert.path"
run rsync -a "${ASSET}/systemd/${APP}-api-cert-reload.service" "${host}:/tmp/${APP}-api-cert-reload.service"
ssh_run "${host}" "sudo install -o root -g root -m 0644 /tmp/${APP}-api.service /etc/systemd/system/${APP}-api.service"
ssh_run "${host}" "sudo install -o root -g root -m 0644 /tmp/${APP}-api-cert.path /etc/systemd/system/${APP}-api-cert.path"
ssh_run "${host}" "sudo install -o root -g root -m 0644 /tmp/${APP}-api-cert-reload.service /etc/systemd/system/${APP}-api-cert-reload.service"
ssh_run "${host}" "rm -f /tmp/${APP}-api.service /tmp/${APP}-api-cert.path /tmp/${APP}-api-cert-reload.service"
# 5. SELinux: relabel installed paths; register the API port if not already labelled.
ssh_run "${host}" "sudo restorecon -R /usr/local/bin/${APP}-api /usr/local/bin/${APP}-cli /etc/${APP} /var/lib/${APP}"
local port; port="$(yq -r ".environments.${env}.components.api.config.bind" "${MANIFEST}" | sed 's/.*://')"
ssh_run "${host}" "semanage port -l | grep -qw ${port} || sudo semanage port -a -t http_port_t -p tcp ${port} || sudo semanage port -m -t http_port_t -p tcp ${port}"
# 6. firewalld: install named service; enable in the default zone if reached cross-host.
run rsync -a "${ASSET}/firewalld/${APP}-api.xml" "${host}:/tmp/${APP}-api.xml"
ssh_run "${host}" "sudo install -o root -g root -m 0644 /tmp/${APP}-api.xml /etc/firewalld/services/${APP}-api.xml && rm -f /tmp/${APP}-api.xml"
ssh_run "${host}" "sudo firewall-cmd --reload"
# The API binds loopback behind co-located nginx, so the service is shipped but not enabled
# by default. Enable it explicitly when the API must be reachable from another host:
# firewall-cmd --permanent --zone=\$(firewall-cmd --get-default-zone) --add-service=${APP}-api
# firewall-cmd --zone=\$(firewall-cmd --get-default-zone) --add-service=${APP}-api
# 7. Reload systemd and (re)start the units.
ssh_run "${host}" "sudo systemctl daemon-reload"
ssh_run "${host}" "sudo systemctl enable --now ${APP}-api-cert.path"
ssh_run "${host}" "sudo systemctl enable --now ${APP}-api.service"
ssh_run "${host}" "sudo systemctl restart ${APP}-api.service"
# 8. Health probe.
local bind; bind="$(yq -r ".environments.${env}.components.api.config.bind" "${MANIFEST}")"
if [[ "${DRY_RUN}" -eq 1 ]]; then
printf ' [dry-run] health probe http://%s/health on %s\n' "${bind}" "${host}"
else
if ssh_run "${host}" "curl -fsS --max-time 10 http://${bind}/health >/dev/null"; then
info "api healthy on ${host}"
else
die "api health check failed on ${host}"
fi
fi
}
# --- dispatch ----------------------------------------------------------------------------
needs_build=0
for comp in "${COMPONENTS[@]}"; do
[[ "${comp}" == "api" ]] && needs_build=1
done
[[ "${needs_build}" -eq 1 ]] && build_binaries
for comp in "${COMPONENTS[@]}"; do
mapfile -t hosts < <(yq -r ".environments.${ENVIRONMENT}.components.${comp}.hosts[]" "${MANIFEST}")
[[ "${#hosts[@]}" -gt 0 ]] || die "component ${comp} has no hosts"
for host in "${hosts[@]}"; do
case "${comp}" in
api) deploy_api_host "${ENVIRONMENT}" "${host}" ;;
*) die "no deploy routine for component '${comp}'" ;;
esac
done
done
info "deploy complete"