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:
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal 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/
|
||||
58
.sqlx/query-0d7cb819ec99d32eb17fdcf1729629125e53794940954896d9e0175c528a5ce8.json
generated
Normal file
58
.sqlx/query-0d7cb819ec99d32eb17fdcf1729629125e53794940954896d9e0175c528a5ce8.json
generated
Normal 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"
|
||||
}
|
||||
24
.sqlx/query-118c889c761a9ac9c11120d9bc4cfa37bfc474e6fde09ee7bd57e8578d429882.json
generated
Normal file
24
.sqlx/query-118c889c761a9ac9c11120d9bc4cfa37bfc474e6fde09ee7bd57e8578d429882.json
generated
Normal 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"
|
||||
}
|
||||
64
.sqlx/query-20f76717cd92d00946d0de700e2157d492103f9afb20f74c7a981ef2598aa4fd.json
generated
Normal file
64
.sqlx/query-20f76717cd92d00946d0de700e2157d492103f9afb20f74c7a981ef2598aa4fd.json
generated
Normal 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"
|
||||
}
|
||||
29
.sqlx/query-3097facd033b5ddb225920dbb6ee52d1075d64c1a079b239f46c1c21b07c54f6.json
generated
Normal file
29
.sqlx/query-3097facd033b5ddb225920dbb6ee52d1075d64c1a079b239f46c1c21b07c54f6.json
generated
Normal 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"
|
||||
}
|
||||
14
.sqlx/query-31e56f05bdfc4728d59767a351596e693556925abd37ff44cc48e13a93c11743.json
generated
Normal file
14
.sqlx/query-31e56f05bdfc4728d59767a351596e693556925abd37ff44cc48e13a93c11743.json
generated
Normal 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"
|
||||
}
|
||||
16
.sqlx/query-3321bd4b774f9580c24254f274a9a29b565935e69474edc8f7757f5e0985a984.json
generated
Normal file
16
.sqlx/query-3321bd4b774f9580c24254f274a9a29b565935e69474edc8f7757f5e0985a984.json
generated
Normal 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"
|
||||
}
|
||||
95
.sqlx/query-44f58fa2a3bab3d4a7397bcb3c4f4b065eb0245f8e821dc188a6c2e2681eca8d.json
generated
Normal file
95
.sqlx/query-44f58fa2a3bab3d4a7397bcb3c4f4b065eb0245f8e821dc188a6c2e2681eca8d.json
generated
Normal 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"
|
||||
}
|
||||
66
.sqlx/query-47c66d2f7e8b5609dc81888bfef3ea08a080296b7be3cb44c211872f4bed4cc6.json
generated
Normal file
66
.sqlx/query-47c66d2f7e8b5609dc81888bfef3ea08a080296b7be3cb44c211872f4bed4cc6.json
generated
Normal 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"
|
||||
}
|
||||
16
.sqlx/query-5c6b6376e1f50a2dd31eb67caf0d846ce2aa59f4ca96be319430ab39d93ac561.json
generated
Normal file
16
.sqlx/query-5c6b6376e1f50a2dd31eb67caf0d846ce2aa59f4ca96be319430ab39d93ac561.json
generated
Normal 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"
|
||||
}
|
||||
22
.sqlx/query-7e42f52375a14e4f4791892d53b039f089f9d9927cba7c36a6f9b8dd75978ec2.json
generated
Normal file
22
.sqlx/query-7e42f52375a14e4f4791892d53b039f089f9d9927cba7c36a6f9b8dd75978ec2.json
generated
Normal 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"
|
||||
}
|
||||
64
.sqlx/query-876243393dd600f9f0e1ecf08548a59b1815df4cab406e99207c017269617b4b.json
generated
Normal file
64
.sqlx/query-876243393dd600f9f0e1ecf08548a59b1815df4cab406e99207c017269617b4b.json
generated
Normal 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"
|
||||
}
|
||||
105
.sqlx/query-928a6049f0f238ba4c4336f1eb3dd79644f1cbb9a4992366778787b04829c507.json
generated
Normal file
105
.sqlx/query-928a6049f0f238ba4c4336f1eb3dd79644f1cbb9a4992366778787b04829c507.json
generated
Normal 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"
|
||||
}
|
||||
15
.sqlx/query-9a2f03d9ac91cd735f7eb03e9086b2dbfb23a3d276685d8379c2336e5293d2c2.json
generated
Normal file
15
.sqlx/query-9a2f03d9ac91cd735f7eb03e9086b2dbfb23a3d276685d8379c2336e5293d2c2.json
generated
Normal 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"
|
||||
}
|
||||
15
.sqlx/query-a77f205a696a2dc35a49b4ea0e98aa47d477d752f1b8f32071d8295a37ca3504.json
generated
Normal file
15
.sqlx/query-a77f205a696a2dc35a49b4ea0e98aa47d477d752f1b8f32071d8295a37ca3504.json
generated
Normal 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"
|
||||
}
|
||||
15
.sqlx/query-adf06711fd97cdca457765f091a2b3c1741b37c42484b0f3d691113330babd51.json
generated
Normal file
15
.sqlx/query-adf06711fd97cdca457765f091a2b3c1741b37c42484b0f3d691113330babd51.json
generated
Normal 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"
|
||||
}
|
||||
94
.sqlx/query-b3498c0e3e18b3b553175d669c01b3e69cea60e4f544b4c6b4e14a8cb9de4f01.json
generated
Normal file
94
.sqlx/query-b3498c0e3e18b3b553175d669c01b3e69cea60e4f544b4c6b4e14a8cb9de4f01.json
generated
Normal 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"
|
||||
}
|
||||
18
.sqlx/query-b7f0353801ef188e342924a276f3fc3b9b5ff38a7eabf71e91abff0a4795136b.json
generated
Normal file
18
.sqlx/query-b7f0353801ef188e342924a276f3fc3b9b5ff38a7eabf71e91abff0a4795136b.json
generated
Normal 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"
|
||||
}
|
||||
14
.sqlx/query-bd7734ce47a17f177f9dde9c52cf8bb691d11356dfde263c1a2b3cfdabd89745.json
generated
Normal file
14
.sqlx/query-bd7734ce47a17f177f9dde9c52cf8bb691d11356dfde263c1a2b3cfdabd89745.json
generated
Normal 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"
|
||||
}
|
||||
52
.sqlx/query-be9c103674af1a805d9e6a038eebc75f0591e84bb66b4ece2fb71779cadfbf21.json
generated
Normal file
52
.sqlx/query-be9c103674af1a805d9e6a038eebc75f0591e84bb66b4ece2fb71779cadfbf21.json
generated
Normal 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"
|
||||
}
|
||||
60
.sqlx/query-c5f0fff4ac332027b6cced89e13f7654846d4521a796e04a4ea61ea17fc5a683.json
generated
Normal file
60
.sqlx/query-c5f0fff4ac332027b6cced89e13f7654846d4521a796e04a4ea61ea17fc5a683.json
generated
Normal 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"
|
||||
}
|
||||
94
.sqlx/query-ccde30b99b3117fc96bff0cb30408b9aff254fb556408977d1c79f321ed00853.json
generated
Normal file
94
.sqlx/query-ccde30b99b3117fc96bff0cb30408b9aff254fb556408977d1c79f321ed00853.json
generated
Normal 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"
|
||||
}
|
||||
17
.sqlx/query-f140e8fcdbc353f4fb2d9f02c51b60cd7e76ee41352e647d205df9b1badccd22.json
generated
Normal file
17
.sqlx/query-f140e8fcdbc353f4fb2d9f02c51b60cd7e76ee41352e647d205df9b1badccd22.json
generated
Normal 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"
|
||||
}
|
||||
23
.sqlx/query-f2383d53d45c6d1ac53dc34b23c5821deb02b236d8dc402ea9f2726b840f71a8.json
generated
Normal file
23
.sqlx/query-f2383d53d45c6d1ac53dc34b23c5821deb02b236d8dc402ea9f2726b840f71a8.json
generated
Normal 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"
|
||||
}
|
||||
14
.sqlx/query-f2df3d324a5e89c6e6299e1021db8ef02fcf30bf7fb12b4ca05484c8283aa213.json
generated
Normal file
14
.sqlx/query-f2df3d324a5e89c6e6299e1021db8ef02fcf30bf7fb12b4ca05484c8283aa213.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM works WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f2df3d324a5e89c6e6299e1021db8ef02fcf30bf7fb12b4ca05484c8283aa213"
|
||||
}
|
||||
58
.sqlx/query-f4521f50ca1b956309e45f46859bf17fdd4c53cd3d60a8d0c57789882d131a0a.json
generated
Normal file
58
.sqlx/query-f4521f50ca1b956309e45f46859bf17fdd4c53cd3d60a8d0c57789882d131a0a.json
generated
Normal 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"
|
||||
}
|
||||
20
.sqlx/query-f6ad2caadb1fc2127cb99326fa1dff00efee3e61730351e151940155cc8717dd.json
generated
Normal file
20
.sqlx/query-f6ad2caadb1fc2127cb99326fa1dff00efee3e61730351e151940155cc8717dd.json
generated
Normal 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"
|
||||
}
|
||||
67
.sqlx/query-f9ee4612fe3b3d8c9480f55e1d46a10a5aa919589aa79eab38622c68da12b85a.json
generated
Normal file
67
.sqlx/query-f9ee4612fe3b3d8c9480f55e1d46a10a5aa919589aa79eab38622c68da12b85a.json
generated
Normal 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"
|
||||
}
|
||||
94
.sqlx/query-fc845092accc2a55a4971a04c271939beb5ae58f8a7e5eed0af442e9f66656e5.json
generated
Normal file
94
.sqlx/query-fc845092accc2a55a4971a04c271939beb5ae58f8a7e5eed0af442e9f66656e5.json
generated
Normal 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
4239
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
75
Cargo.toml
Normal file
75
Cargo.toml
Normal 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"
|
||||
36
asset/config/config.toml.tmpl
Normal file
36
asset/config/config.toml.tmpl
Normal 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}}
|
||||
7
asset/firewalld/audioblume-api.xml
Normal file
7
asset/firewalld/audioblume-api.xml
Normal 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
53
asset/manifest.yml
Normal 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
|
||||
15
asset/minio/audioblume-policy.json.tmpl
Normal file
15
asset/minio/audioblume-policy.json.tmpl
Normal 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
108
asset/minio/bootstrap.sh
Executable 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"
|
||||
38
asset/nginx/audioblume.hanzalova.conf
Normal file
38
asset/nginx/audioblume.hanzalova.conf
Normal 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
33
asset/sql/bootstrap.sql
Normal 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;
|
||||
7
asset/systemd/audioblume-api-cert-reload.service
Normal file
7
asset/systemd/audioblume-api-cert-reload.service
Normal 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
|
||||
12
asset/systemd/audioblume-api-cert.path
Normal file
12
asset/systemd/audioblume-api-cert.path
Normal 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
|
||||
40
asset/systemd/audioblume-api.service
Normal file
40
asset/systemd/audioblume-api.service
Normal 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
|
||||
2
asset/systemd/audioblume.sysusers.conf
Normal file
2
asset/systemd/audioblume.sysusers.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
#Type Name ID GECOS Home directory Shell
|
||||
u audioblume - "audioblume service account" /var/lib/audioblume /usr/sbin/nologin
|
||||
30
crates/audioblume-api/Cargo.toml
Normal file
30
crates/audioblume-api/Cargo.toml
Normal 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
|
||||
174
crates/audioblume-api/src/config.rs
Normal file
174
crates/audioblume-api/src/config.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
65
crates/audioblume-api/src/error.rs
Normal file
65
crates/audioblume-api/src/error.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
76
crates/audioblume-api/src/extract.rs
Normal file
76
crates/audioblume-api/src/extract.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
185
crates/audioblume-api/src/handlers.rs
Normal file
185
crates/audioblume-api/src/handlers.rs
Normal 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"
|
||||
}
|
||||
}
|
||||
131
crates/audioblume-api/src/main.rs
Normal file
131
crates/audioblume-api/src/main.rs
Normal 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");
|
||||
}
|
||||
36
crates/audioblume-api/src/router.rs
Normal file
36
crates/audioblume-api/src/router.rs
Normal 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)
|
||||
}
|
||||
12
crates/audioblume-api/src/state.rs
Normal file
12
crates/audioblume-api/src/state.rs
Normal 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,
|
||||
}
|
||||
25
crates/audioblume-cli/Cargo.toml
Normal file
25
crates/audioblume-cli/Cargo.toml
Normal 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
|
||||
163
crates/audioblume-cli/src/config.rs
Normal file
163
crates/audioblume-cli/src/config.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
398
crates/audioblume-cli/src/main.rs
Normal file
398
crates/audioblume-cli/src/main.rs
Normal 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()
|
||||
}
|
||||
23
crates/audioblume-core/Cargo.toml
Normal file
23
crates/audioblume-core/Cargo.toml
Normal 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 }
|
||||
73
crates/audioblume-core/src/access.rs
Normal file
73
crates/audioblume-core/src/access.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
110
crates/audioblume-core/src/auth.rs
Normal file
110
crates/audioblume-core/src/auth.rs
Normal 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)
|
||||
}
|
||||
84
crates/audioblume-core/src/catalog.rs
Normal file
84
crates/audioblume-core/src/catalog.rs
Normal 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 })
|
||||
}
|
||||
55
crates/audioblume-core/src/context.rs
Normal file
55
crates/audioblume-core/src/context.rs
Normal 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.0–1.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,
|
||||
}
|
||||
85
crates/audioblume-core/src/crypto.rs
Normal file
85
crates/audioblume-core/src/crypto.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
42
crates/audioblume-core/src/error.rs
Normal file
42
crates/audioblume-core/src/error.rs
Normal 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>;
|
||||
24
crates/audioblume-core/src/lib.rs
Normal file
24
crates/audioblume-core/src/lib.rs
Normal 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;
|
||||
229
crates/audioblume-core/src/ports.rs
Normal file
229
crates/audioblume-core/src/ports.rs
Normal 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>;
|
||||
}
|
||||
533
crates/audioblume-core/src/tests.rs
Normal file
533
crates/audioblume-core/src/tests.rs
Normal 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
|
||||
);
|
||||
}
|
||||
172
crates/audioblume-core/src/upload.rs
Normal file
172
crates/audioblume-core/src/upload.rs
Normal 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
|
||||
}
|
||||
25
crates/audioblume-data/Cargo.toml
Normal file
25
crates/audioblume-data/Cargo.toml
Normal 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
|
||||
28
crates/audioblume-data/migrations/0001_init.sql
Normal file
28
crates/audioblume-data/migrations/0001_init.sql
Normal 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'
|
||||
);
|
||||
29
crates/audioblume-data/migrations/0002_accounts.sql
Normal file
29
crates/audioblume-data/migrations/0002_accounts.sql
Normal 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;
|
||||
74
crates/audioblume-data/migrations/0003_catalog.sql
Normal file
74
crates/audioblume-data/migrations/0003_catalog.sql
Normal 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()
|
||||
);
|
||||
@@ -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()
|
||||
);
|
||||
56
crates/audioblume-data/src/convert.rs
Normal file
56
crates/audioblume-data/src/convert.rs
Normal 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()
|
||||
))
|
||||
})
|
||||
}
|
||||
78
crates/audioblume-data/src/epub.rs
Normal file
78
crates/audioblume-data/src/epub.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
18
crates/audioblume-data/src/lib.rs
Normal file
18
crates/audioblume-data/src/lib.rs
Normal 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};
|
||||
71
crates/audioblume-data/src/pg/blobs.rs
Normal file
71
crates/audioblume-data/src/pg/blobs.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
416
crates/audioblume-data/src/pg/catalog.rs
Normal file
416
crates/audioblume-data/src/pg/catalog.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
74
crates/audioblume-data/src/pg/entitlements.rs
Normal file
74
crates/audioblume-data/src/pg/entitlements.rs
Normal 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)))
|
||||
}
|
||||
}
|
||||
13
crates/audioblume-data/src/pg/mod.rs
Normal file
13
crates/audioblume-data/src/pg/mod.rs
Normal 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;
|
||||
103
crates/audioblume-data/src/pg/sessions.rs
Normal file
103
crates/audioblume-data/src/pg/sessions.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
128
crates/audioblume-data/src/pg/users.rs
Normal file
128
crates/audioblume-data/src/pg/users.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
173
crates/audioblume-data/src/s3.rs
Normal file
173
crates/audioblume-data/src/s3.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
129
crates/audioblume-data/src/stack.rs
Normal file
129
crates/audioblume-data/src/stack.rs
Normal 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}")))
|
||||
}
|
||||
}
|
||||
16
crates/audioblume-entities/Cargo.toml
Normal file
16
crates/audioblume-entities/Cargo.toml
Normal 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
|
||||
210
crates/audioblume-entities/src/domain.rs
Normal file
210
crates/audioblume-entities/src/domain.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
139
crates/audioblume-entities/src/dto.rs
Normal file
139
crates/audioblume-entities/src/dto.rs
Normal 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,
|
||||
}
|
||||
92
crates/audioblume-entities/src/enums.rs
Normal file
92
crates/audioblume-entities/src/enums.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
26
crates/audioblume-entities/src/error.rs
Normal file
26
crates/audioblume-entities/src/error.rs
Normal 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),
|
||||
}
|
||||
104
crates/audioblume-entities/src/hash.rs
Normal file
104
crates/audioblume-entities/src/hash.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
84
crates/audioblume-entities/src/ids.rs
Normal file
84
crates/audioblume-entities/src/ids.rs
Normal 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
|
||||
);
|
||||
147
crates/audioblume-entities/src/isbn.rs
Normal file
147
crates/audioblume-entities/src/isbn.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
32
crates/audioblume-entities/src/lib.rs
Normal file
32
crates/audioblume-entities/src/lib.rs
Normal 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;
|
||||
40
crates/audioblume-entities/src/token.rs
Normal file
40
crates/audioblume-entities/src/token.rs
Normal 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
99
readme.md
Normal 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
4
rust-toolchain.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["rustfmt", "clippy"]
|
||||
profile = "default"
|
||||
263
script/deploy.sh
Executable file
263
script/deploy.sh
Executable 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"
|
||||
Reference in New Issue
Block a user