bootstrap rust services, auth, and compose stack
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
API_LISTEN_ADDR=0.0.0.0:8080
|
||||
ADMIN_UI_LISTEN_ADDR=0.0.0.0:8082
|
||||
INDEXER_LISTEN_ADDR=0.0.0.0:8081
|
||||
DATABASE_URL=postgres://stripstream:stripstream@postgres:5432/stripstream
|
||||
MEILI_URL=http://meilisearch:7700
|
||||
MEILI_MASTER_KEY=change-me
|
||||
API_BOOTSTRAP_TOKEN=change-me-bootstrap-token
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
.env
|
||||
.DS_Store
|
||||
tmp/
|
||||
2679
Cargo.lock
generated
Normal file
2679
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
Cargo.toml
Normal file
30
Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"apps/api",
|
||||
"apps/indexer",
|
||||
"apps/admin-ui",
|
||||
"crates/core",
|
||||
"crates/parsers",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.1.0"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0"
|
||||
argon2 = "0.5"
|
||||
axum = "0.7"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
rand = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono"] }
|
||||
tokio = { version = "1.43", features = ["macros", "rt-multi-thread", "signal"] }
|
||||
tower = "0.5"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
uuid = { version = "1.12", features = ["serde", "v4"] }
|
||||
48
PLAN.md
48
PLAN.md
@@ -17,7 +17,7 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
|
||||
- Auth: token bootstrap env + tokens admin en DB (creables/revocables)
|
||||
- Expiration tokens admin: aucune par defaut (revocation manuelle)
|
||||
- Rendu PDF: a la volee
|
||||
- CBR: extraction temporaire disque (`unrar`) + cleanup
|
||||
- CBR: extraction temporaire disque (`unrar-free`, commande `unrar`) + cleanup
|
||||
- Formats pages: `webp`, `jpeg`, `png`
|
||||
|
||||
---
|
||||
@@ -25,46 +25,46 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
|
||||
## Backlog executable (ordre recommande)
|
||||
|
||||
### T1 - Bootstrap monorepo Rust
|
||||
- [ ] Creer workspace Cargo
|
||||
- [ ] Creer crates/apps: `apps/api`, `apps/indexer`, `apps/admin-ui`, `crates/core`, `crates/parsers`
|
||||
- [ ] Config env centralisee + logging de base
|
||||
- [x] Creer workspace Cargo
|
||||
- [x] Creer crates/apps: `apps/api`, `apps/indexer`, `apps/admin-ui`, `crates/core`, `crates/parsers`
|
||||
- [x] Config env centralisee + logging de base
|
||||
|
||||
**DoD:** Build des crates OK.
|
||||
|
||||
### T2 - Infra Docker Compose
|
||||
- [ ] Definir services `postgres`, `meilisearch`, `api`, `indexer`
|
||||
- [ ] Volumes persistants
|
||||
- [ ] Healthchecks
|
||||
- [x] Definir services `postgres`, `meilisearch`, `api`, `indexer`
|
||||
- [x] Volumes persistants
|
||||
- [x] Healthchecks
|
||||
|
||||
**DoD:** `docker compose up` demarre tout, services healthy.
|
||||
|
||||
### T3 - Schema DB + migrations
|
||||
- [ ] Tables: `libraries`, `books`, `book_files`, `index_jobs`, `api_tokens`
|
||||
- [ ] Index/contraintes (uniques, FK)
|
||||
- [ ] Scripts de migration
|
||||
- [x] Tables: `libraries`, `books`, `book_files`, `index_jobs`, `api_tokens`
|
||||
- [x] Index/contraintes (uniques, FK)
|
||||
- [x] Scripts de migration
|
||||
|
||||
**DoD:** Migrations appliquees sans erreur, schema stable.
|
||||
|
||||
### T4 - Auth hybride
|
||||
- [ ] Middleware `Authorization: Bearer <token>`
|
||||
- [ ] Verif `API_BOOTSTRAP_TOKEN`
|
||||
- [ ] Verif tokens DB (hash Argon2id, non revoques/non expires)
|
||||
- [ ] MAJ `last_used_at`
|
||||
- [x] Middleware `Authorization: Bearer <token>`
|
||||
- [x] Verif `API_BOOTSTRAP_TOKEN`
|
||||
- [x] Verif tokens DB (hash Argon2id, non revoques/non expires)
|
||||
- [x] MAJ `last_used_at`
|
||||
|
||||
**DoD:** Acces protege fonctionnel, tokens revoques refuses.
|
||||
|
||||
### T5 - API admin tokens
|
||||
- [ ] `POST /admin/tokens` (affichage secret une seule fois)
|
||||
- [ ] `GET /admin/tokens` (sans secret)
|
||||
- [ ] `DELETE /admin/tokens/:id` (revoke)
|
||||
- [x] `POST /admin/tokens` (affichage secret une seule fois)
|
||||
- [x] `GET /admin/tokens` (sans secret)
|
||||
- [x] `DELETE /admin/tokens/:id` (revoke)
|
||||
|
||||
**DoD:** Flux creation/liste/revocation valide.
|
||||
|
||||
### T6 - CRUD librairies
|
||||
- [ ] `GET /libraries`
|
||||
- [ ] `POST /libraries`
|
||||
- [ ] `DELETE /libraries/:id`
|
||||
- [ ] Validation stricte des chemins (anti traversal)
|
||||
- [x] `GET /libraries`
|
||||
- [x] `POST /libraries`
|
||||
- [x] `DELETE /libraries/:id`
|
||||
- [x] Validation stricte des chemins (anti traversal)
|
||||
|
||||
**DoD:** Gestion librairies robuste et securisee.
|
||||
|
||||
@@ -189,10 +189,14 @@ Construire un serveur ultra performant pour indexer et servir des bibliotheques
|
||||
---
|
||||
|
||||
## Suivi d'avancement
|
||||
- [ ] Lot 1: Fondations (T1 -> T6)
|
||||
- [x] Lot 1: Fondations (T1 -> T6)
|
||||
- [ ] Lot 2: Ingestion + Search (T7 -> T13)
|
||||
- [ ] Lot 3: Lecture + UI + Hardening (T14 -> T18)
|
||||
|
||||
## Notes
|
||||
- Scope token v1: `admin`, `read`
|
||||
- Bootstrap token = break-glass (peut etre desactive plus tard)
|
||||
|
||||
## Journal
|
||||
- 2026-03-05: `docker compose up -d --build` valide, stack complete en healthy (`postgres`, `meilisearch`, `api`, `indexer`, `admin-ui`).
|
||||
- 2026-03-05: ajustements infra appliques pour demarrage stable (`unrar` -> `unrar-free`, image `rust:1-bookworm`, healthchecks `127.0.0.1`).
|
||||
|
||||
13
apps/admin-ui/Cargo.toml
Normal file
13
apps/admin-ui/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "admin-ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
stripstream-core = { path = "../../crates/core" }
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
22
apps/admin-ui/Dockerfile
Normal file
22
apps/admin-ui/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM rust:1-bookworm AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY Cargo.toml ./
|
||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
||||
COPY apps/admin-ui/Cargo.toml apps/admin-ui/Cargo.toml
|
||||
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
||||
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
||||
COPY apps/api/src apps/api/src
|
||||
COPY apps/indexer/src apps/indexer/src
|
||||
COPY apps/admin-ui/src apps/admin-ui/src
|
||||
COPY crates/core/src crates/core/src
|
||||
COPY crates/parsers/src crates/parsers/src
|
||||
|
||||
RUN cargo build --release -p admin-ui
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /app/target/release/admin-ui /usr/local/bin/admin-ui
|
||||
EXPOSE 8082
|
||||
CMD ["/usr/local/bin/admin-ui"]
|
||||
32
apps/admin-ui/src/main.rs
Normal file
32
apps/admin-ui/src/main.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use axum::{response::Html, routing::get, Router};
|
||||
use stripstream_core::config::AdminUiConfig;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "admin_ui=info,axum=info".to_string()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = AdminUiConfig::from_env();
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/", get(index));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
|
||||
info!(addr = %config.listen_addr, "admin ui listening");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
async fn index() -> Html<&'static str> {
|
||||
Html(
|
||||
"<html><body><h1>Stripstream Admin</h1><p>UI skeleton ready. Next: libraries, jobs, tokens screens.</p></body></html>",
|
||||
)
|
||||
}
|
||||
22
apps/api/Cargo.toml
Normal file
22
apps/api/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
argon2.workspace = true
|
||||
axum.workspace = true
|
||||
base64.workspace = true
|
||||
chrono.workspace = true
|
||||
stripstream-core = { path = "../../crates/core" }
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sqlx.workspace = true
|
||||
tokio.workspace = true
|
||||
tower.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
22
apps/api/Dockerfile
Normal file
22
apps/api/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM rust:1-bookworm AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY Cargo.toml ./
|
||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
||||
COPY apps/admin-ui/Cargo.toml apps/admin-ui/Cargo.toml
|
||||
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
||||
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
||||
COPY apps/api/src apps/api/src
|
||||
COPY apps/indexer/src apps/indexer/src
|
||||
COPY apps/admin-ui/src apps/admin-ui/src
|
||||
COPY crates/core/src crates/core/src
|
||||
COPY crates/parsers/src crates/parsers/src
|
||||
|
||||
RUN cargo build --release -p api
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /app/target/release/api /usr/local/bin/api
|
||||
EXPOSE 8080
|
||||
CMD ["/usr/local/bin/api"]
|
||||
93
apps/api/src/auth.rs
Normal file
93
apps/api/src/auth.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::header::AUTHORIZATION,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::{error::ApiError, AppState};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Scope {
|
||||
Admin,
|
||||
Read,
|
||||
}
|
||||
|
||||
pub async fn require_admin(
|
||||
State(state): State<AppState>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let token = bearer_token(&req).ok_or_else(|| ApiError::unauthorized("missing bearer token"))?;
|
||||
let scope = authenticate(&state, token).await?;
|
||||
|
||||
if !matches!(scope, Scope::Admin) {
|
||||
return Err(ApiError::forbidden("admin scope required"));
|
||||
}
|
||||
|
||||
req.extensions_mut().insert(scope);
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
fn bearer_token(req: &Request) -> Option<&str> {
|
||||
req.headers()
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
}
|
||||
|
||||
async fn authenticate(state: &AppState, token: &str) -> Result<Scope, ApiError> {
|
||||
if token == state.bootstrap_token.as_ref() {
|
||||
return Ok(Scope::Admin);
|
||||
}
|
||||
|
||||
let prefix = parse_prefix(token).ok_or_else(|| ApiError::unauthorized("invalid token format"))?;
|
||||
|
||||
let maybe_row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, token_hash, scope
|
||||
FROM api_tokens
|
||||
WHERE prefix = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())
|
||||
"#,
|
||||
)
|
||||
.bind(prefix)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let row = maybe_row.ok_or_else(|| ApiError::unauthorized("invalid token"))?;
|
||||
|
||||
let token_hash: String = row.try_get("token_hash").map_err(|_| ApiError::unauthorized("invalid token"))?;
|
||||
let parsed_hash = PasswordHash::new(&token_hash).map_err(|_| ApiError::unauthorized("invalid token"))?;
|
||||
|
||||
Argon2::default()
|
||||
.verify_password(token.as_bytes(), &parsed_hash)
|
||||
.map_err(|_| ApiError::unauthorized("invalid token"))?;
|
||||
|
||||
let token_id: uuid::Uuid = row.try_get("id").map_err(|_| ApiError::unauthorized("invalid token"))?;
|
||||
sqlx::query("UPDATE api_tokens SET last_used_at = $1 WHERE id = $2")
|
||||
.bind(Utc::now())
|
||||
.bind(token_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
let scope: String = row.try_get("scope").map_err(|_| ApiError::unauthorized("invalid token"))?;
|
||||
match scope.as_str() {
|
||||
"admin" => Ok(Scope::Admin),
|
||||
"read" => Ok(Scope::Read),
|
||||
_ => Err(ApiError::unauthorized("invalid token scope")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_prefix(token: &str) -> Option<&str> {
|
||||
let mut parts = token.split('_');
|
||||
let namespace = parts.next()?;
|
||||
let prefix = parts.next()?;
|
||||
let secret = parts.next()?;
|
||||
if namespace != "stl" || secret.is_empty() || prefix.len() < 6 {
|
||||
return None;
|
||||
}
|
||||
Some(prefix)
|
||||
}
|
||||
62
apps/api/src/error.rs
Normal file
62
apps/api/src/error.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
pub status: StatusCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorBody<'a> {
|
||||
error: &'a str,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.status, Json(ErrorBody { error: &self.message })).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for ApiError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
Self::internal(format!("database error: {err}"))
|
||||
}
|
||||
}
|
||||
101
apps/api/src/libraries.rs
Normal file
101
apps/api/src/libraries.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use axum::{extract::{Path as AxumPath, State}, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::ApiError, AppState};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LibraryDto {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub root_path: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateLibraryInput {
|
||||
pub name: String,
|
||||
pub root_path: String,
|
||||
}
|
||||
|
||||
pub async fn list_libraries(State(state): State<AppState>) -> Result<Json<Vec<LibraryDto>>, ApiError> {
|
||||
let rows = sqlx::query("SELECT id, name, root_path, enabled FROM libraries ORDER BY created_at DESC")
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|row| LibraryDto {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
root_path: row.get("root_path"),
|
||||
enabled: row.get("enabled"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
pub async fn create_library(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CreateLibraryInput>,
|
||||
) -> Result<Json<LibraryDto>, ApiError> {
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("name is required"));
|
||||
}
|
||||
|
||||
let canonical = canonicalize_library_root(&input.root_path)?;
|
||||
let id = Uuid::new_v4();
|
||||
let root_path = canonical.to_string_lossy().to_string();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO libraries (id, name, root_path, enabled) VALUES ($1, $2, $3, TRUE)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(input.name.trim())
|
||||
.bind(&root_path)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(LibraryDto {
|
||||
id,
|
||||
name: input.name.trim().to_string(),
|
||||
root_path,
|
||||
enabled: true,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn delete_library(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let result = sqlx::query("DELETE FROM libraries WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found("library not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
|
||||
}
|
||||
|
||||
fn canonicalize_library_root(root_path: &str) -> Result<PathBuf, ApiError> {
|
||||
let path = Path::new(root_path);
|
||||
if !path.is_absolute() {
|
||||
return Err(ApiError::bad_request("root_path must be absolute"));
|
||||
}
|
||||
|
||||
let canonical = std::fs::canonicalize(path)
|
||||
.map_err(|_| ApiError::bad_request("root_path does not exist or is inaccessible"))?;
|
||||
|
||||
if !canonical.is_dir() {
|
||||
return Err(ApiError::bad_request("root_path must point to a directory"));
|
||||
}
|
||||
|
||||
Ok(canonical)
|
||||
}
|
||||
58
apps/api/src/main.rs
Normal file
58
apps/api/src/main.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
mod auth;
|
||||
mod error;
|
||||
mod libraries;
|
||||
mod tokens;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{middleware, routing::{delete, get}, Router};
|
||||
use stripstream_core::config::ApiConfig;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
pool: sqlx::PgPool,
|
||||
bootstrap_token: Arc<str>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "api=info,axum=info".to_string()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = ApiConfig::from_env()?;
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect(&config.database_url)
|
||||
.await?;
|
||||
|
||||
let state = AppState {
|
||||
pool,
|
||||
bootstrap_token: Arc::from(config.api_bootstrap_token),
|
||||
};
|
||||
|
||||
let protected = Router::new()
|
||||
.route("/libraries", get(libraries::list_libraries).post(libraries::create_library))
|
||||
.route("/libraries/:id", delete(libraries::delete_library))
|
||||
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
|
||||
.route("/admin/tokens/:id", delete(tokens::revoke_token))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), auth::require_admin));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.merge(protected)
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
|
||||
info!(addr = %config.listen_addr, "api listening");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
122
apps/api/src/tokens.rs
Normal file
122
apps/api/src/tokens.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
||||
use axum::{extract::{Path, State}, Json};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::ApiError, AppState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateTokenInput {
|
||||
pub name: String,
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreatedToken {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub scope: String,
|
||||
pub token: String,
|
||||
pub prefix: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TokenItem {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub scope: String,
|
||||
pub prefix: String,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn create_token(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CreateTokenInput>,
|
||||
) -> Result<Json<CreatedToken>, ApiError> {
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("name is required"));
|
||||
}
|
||||
|
||||
let scope = match input.scope.as_deref().unwrap_or("read") {
|
||||
"admin" => "admin",
|
||||
"read" => "read",
|
||||
_ => return Err(ApiError::bad_request("scope must be 'admin' or 'read'")),
|
||||
};
|
||||
|
||||
let mut random = [0u8; 24];
|
||||
OsRng.fill_bytes(&mut random);
|
||||
let secret = URL_SAFE_NO_PAD.encode(random);
|
||||
let prefix: String = secret.chars().take(8).collect();
|
||||
let token = format!("stl_{prefix}_{secret}");
|
||||
|
||||
let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
|
||||
let token_hash = Argon2::default()
|
||||
.hash_password(token.as_bytes(), &salt)
|
||||
.map_err(|_| ApiError::internal("failed to hash token"))?
|
||||
.to_string();
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO api_tokens (id, name, prefix, token_hash, scope) VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(input.name.trim())
|
||||
.bind(&prefix)
|
||||
.bind(token_hash)
|
||||
.bind(scope)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(CreatedToken {
|
||||
id,
|
||||
name: input.name.trim().to_string(),
|
||||
scope: scope.to_string(),
|
||||
token,
|
||||
prefix,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<TokenItem>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, name, scope, prefix, last_used_at, revoked_at, created_at FROM api_tokens ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|row| TokenItem {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
scope: row.get("scope"),
|
||||
prefix: row.get("prefix"),
|
||||
last_used_at: row.get("last_used_at"),
|
||||
revoked_at: row.get("revoked_at"),
|
||||
created_at: row.get("created_at"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
pub async fn revoke_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let result = sqlx::query("UPDATE api_tokens SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::not_found("token not found"));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"revoked": true, "id": id})))
|
||||
}
|
||||
13
apps/indexer/Cargo.toml
Normal file
13
apps/indexer/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "indexer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
stripstream-core = { path = "../../crates/core" }
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
22
apps/indexer/Dockerfile
Normal file
22
apps/indexer/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM rust:1-bookworm AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY Cargo.toml ./
|
||||
COPY apps/api/Cargo.toml apps/api/Cargo.toml
|
||||
COPY apps/indexer/Cargo.toml apps/indexer/Cargo.toml
|
||||
COPY apps/admin-ui/Cargo.toml apps/admin-ui/Cargo.toml
|
||||
COPY crates/core/Cargo.toml crates/core/Cargo.toml
|
||||
COPY crates/parsers/Cargo.toml crates/parsers/Cargo.toml
|
||||
COPY apps/api/src apps/api/src
|
||||
COPY apps/indexer/src apps/indexer/src
|
||||
COPY apps/admin-ui/src apps/admin-ui/src
|
||||
COPY crates/core/src crates/core/src
|
||||
COPY crates/parsers/src crates/parsers/src
|
||||
|
||||
RUN cargo build --release -p indexer
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget unrar-free && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /app/target/release/indexer /usr/local/bin/indexer
|
||||
EXPOSE 8081
|
||||
CMD ["/usr/local/bin/indexer"]
|
||||
24
apps/indexer/src/main.rs
Normal file
24
apps/indexer/src/main.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use axum::{routing::get, Router};
|
||||
use stripstream_core::config::IndexerConfig;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "indexer=info,axum=info".to_string()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = IndexerConfig::from_env();
|
||||
let app = Router::new().route("/health", get(health));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?;
|
||||
info!(addr = %config.listen_addr, "indexer listening");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
9
crates/core/Cargo.toml
Normal file
9
crates/core/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "stripstream-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
47
crates/core/src/config.rs
Normal file
47
crates/core/src/config.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiConfig {
|
||||
pub listen_addr: String,
|
||||
pub database_url: String,
|
||||
pub api_bootstrap_token: String,
|
||||
}
|
||||
|
||||
impl ApiConfig {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
Ok(Self {
|
||||
listen_addr: std::env::var("API_LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string()),
|
||||
database_url: std::env::var("DATABASE_URL").context("DATABASE_URL is required")?,
|
||||
api_bootstrap_token: std::env::var("API_BOOTSTRAP_TOKEN")
|
||||
.context("API_BOOTSTRAP_TOKEN is required")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IndexerConfig {
|
||||
pub listen_addr: String,
|
||||
}
|
||||
|
||||
impl IndexerConfig {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
listen_addr: std::env::var("INDEXER_LISTEN_ADDR")
|
||||
.unwrap_or_else(|_| "0.0.0.0:8081".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdminUiConfig {
|
||||
pub listen_addr: String,
|
||||
}
|
||||
|
||||
impl AdminUiConfig {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
listen_addr: std::env::var("ADMIN_UI_LISTEN_ADDR")
|
||||
.unwrap_or_else(|_| "0.0.0.0:8082".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
1
crates/core/src/lib.rs
Normal file
1
crates/core/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod config;
|
||||
8
crates/parsers/Cargo.toml
Normal file
8
crates/parsers/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "parsers"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
3
crates/parsers/src/lib.rs
Normal file
3
crates/parsers/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub fn supported_formats() -> &'static [&'static str] {
|
||||
&["cbz", "cbr", "pdf"]
|
||||
}
|
||||
89
infra/docker-compose.yml
Normal file
89
infra/docker-compose.yml
Normal file
@@ -0,0 +1,89 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: stripstream
|
||||
POSTGRES_USER: stripstream
|
||||
POSTGRES_PASSWORD: stripstream
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U stripstream -d stripstream"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.12
|
||||
environment:
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-change-me}
|
||||
ports:
|
||||
- "7700:7700"
|
||||
volumes:
|
||||
- meili_data:/meili_data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:7700/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/api/Dockerfile
|
||||
env_file:
|
||||
- ../.env
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
meilisearch:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
indexer:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/indexer/Dockerfile
|
||||
env_file:
|
||||
- ../.env
|
||||
ports:
|
||||
- "8081:8081"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
meilisearch:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8081/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
admin-ui:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/admin-ui/Dockerfile
|
||||
env_file:
|
||||
- ../.env
|
||||
ports:
|
||||
- "8082:8082"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8082/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
meili_data:
|
||||
67
infra/migrations/0001_init.sql
Normal file
67
infra/migrations/0001_init.sql
Normal file
@@ -0,0 +1,67 @@
|
||||
CREATE TABLE IF NOT EXISTS libraries (
|
||||
id UUID PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
root_path TEXT NOT NULL UNIQUE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS books (
|
||||
id UUID PRIMARY KEY,
|
||||
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('ebook', 'comic', 'bd')),
|
||||
title TEXT NOT NULL,
|
||||
author TEXT,
|
||||
series TEXT,
|
||||
volume TEXT,
|
||||
language TEXT,
|
||||
page_count INT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS book_files (
|
||||
id UUID PRIMARY KEY,
|
||||
book_id UUID NOT NULL REFERENCES books(id) ON DELETE CASCADE,
|
||||
format TEXT NOT NULL CHECK (format IN ('pdf', 'cbz', 'cbr')),
|
||||
abs_path TEXT NOT NULL UNIQUE,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
mtime TIMESTAMPTZ NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
checksum_opt TEXT,
|
||||
parse_status TEXT NOT NULL DEFAULT 'pending' CHECK (parse_status IN ('pending', 'ok', 'error')),
|
||||
parse_error_opt TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS index_jobs (
|
||||
id UUID PRIMARY KEY,
|
||||
library_id UUID REFERENCES libraries(id) ON DELETE SET NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('scan', 'rebuild')),
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'success', 'failed')),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
stats_json JSONB,
|
||||
error_opt TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id UUID PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
prefix TEXT NOT NULL UNIQUE,
|
||||
token_hash TEXT NOT NULL,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('admin', 'read')),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_books_library_id ON books(library_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_book_files_book_id ON book_files(book_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_book_files_parse_status ON book_files(parse_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_index_jobs_status ON index_jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_scope ON api_tokens(scope);
|
||||
Reference in New Issue
Block a user