chore: Remove admin-ui, improve .env.example, add comprehensive README
- Removed deprecated admin-ui Rust application - Updated .env.example with better organization and comments - Added comprehensive README.md with: - Architecture overview - Quick start guide - Development instructions - Feature documentation - Environment variable reference - API documentation link
This commit is contained in:
45
.env.example
45
.env.example
@@ -1,9 +1,48 @@
|
||||
# Stripstream Librarian - Environment Configuration
|
||||
|
||||
# =============================================================================
|
||||
# REQUIRED - Change these values in production!
|
||||
# =============================================================================
|
||||
|
||||
# Master key for Meilisearch authentication (required)
|
||||
MEILI_MASTER_KEY=change-me-in-production
|
||||
|
||||
# Bootstrap token for initial API admin access (required)
|
||||
# Use this token for the first API calls before creating proper API tokens
|
||||
API_BOOTSTRAP_TOKEN=change-me-in-production
|
||||
|
||||
# =============================================================================
|
||||
# Services Configuration
|
||||
# =============================================================================
|
||||
|
||||
# API Service
|
||||
API_LISTEN_ADDR=0.0.0.0:8080
|
||||
BACKOFFICE_PORT=8082
|
||||
API_BASE_URL=http://api:8080
|
||||
|
||||
# Indexer Service
|
||||
INDEXER_LISTEN_ADDR=0.0.0.0:8081
|
||||
INDEXER_SCAN_INTERVAL_SECONDS=5
|
||||
|
||||
# Backoffice Web UI
|
||||
BACKOFFICE_PORT=8082
|
||||
|
||||
# =============================================================================
|
||||
# Database Configuration
|
||||
# =============================================================================
|
||||
|
||||
# PostgreSQL connection string
|
||||
DATABASE_URL=postgres://stripstream:stripstream@postgres:5432/stripstream
|
||||
|
||||
# =============================================================================
|
||||
# Search Configuration
|
||||
# =============================================================================
|
||||
|
||||
# Meilisearch connection URL
|
||||
MEILI_URL=http://meilisearch:7700
|
||||
MEILI_MASTER_KEY=change-me
|
||||
API_BOOTSTRAP_TOKEN=change-me-bootstrap-token
|
||||
|
||||
# =============================================================================
|
||||
# Optional - Development only
|
||||
# =============================================================================
|
||||
|
||||
# Path to libraries directory (default: /libraries in Docker)
|
||||
# LIBRARIES_ROOT_PATH=/libraries
|
||||
|
||||
151
README.md
Normal file
151
README.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Stripstream Librarian
|
||||
|
||||
A comprehensive comic book and e-book management system with automatic indexing, full-text search, and a modern web interface.
|
||||
|
||||
## Architecture
|
||||
|
||||
The project consists of the following components:
|
||||
|
||||
- **API** (`apps/api/`) - Rust-based REST API service
|
||||
- **Indexer** (`apps/indexer/`) - Rust-based background indexing service
|
||||
- **Backoffice** (`apps/backoffice/`) - Next.js web administration interface
|
||||
- **Infrastructure** (`infra/`) - Docker Compose setup with PostgreSQL and Meilisearch
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker and Docker Compose
|
||||
- (Optional) Rust toolchain for local development
|
||||
- (Optional) Node.js 18+ for backoffice development
|
||||
|
||||
### Environment Setup
|
||||
|
||||
1. Copy the example environment file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Edit `.env` and set secure values for:
|
||||
- `MEILI_MASTER_KEY` - Master key for Meilisearch
|
||||
- `API_BOOTSTRAP_TOKEN` - Bootstrap token for initial API authentication
|
||||
|
||||
### Running with Docker
|
||||
|
||||
```bash
|
||||
cd infra
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This will start:
|
||||
- PostgreSQL (port 5432)
|
||||
- Meilisearch (port 7700)
|
||||
- API service (port 8080)
|
||||
- Indexer service (port 8081)
|
||||
- Backoffice web UI (port 8082)
|
||||
|
||||
### Accessing the Application
|
||||
|
||||
- **Backoffice**: http://localhost:8082
|
||||
- **API**: http://localhost:8080
|
||||
- **Meilisearch**: http://localhost:7700
|
||||
|
||||
### Default Credentials
|
||||
|
||||
The default bootstrap token is configured in your `.env` file. Use this for initial API authentication.
|
||||
|
||||
## Development
|
||||
|
||||
### Local Development (without Docker)
|
||||
|
||||
#### API & Indexer (Rust)
|
||||
|
||||
```bash
|
||||
# Start dependencies
|
||||
cd infra
|
||||
docker compose up -d postgres meilisearch
|
||||
|
||||
# Run API
|
||||
cd apps/api
|
||||
cargo run
|
||||
|
||||
# Run Indexer (in another terminal)
|
||||
cd apps/indexer
|
||||
cargo run
|
||||
```
|
||||
|
||||
#### Backoffice (Next.js)
|
||||
|
||||
```bash
|
||||
cd apps/backoffice
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The backoffice will be available at http://localhost:3000
|
||||
|
||||
## Features
|
||||
|
||||
### Libraries Management
|
||||
- Create and manage multiple libraries
|
||||
- Configure automatic scanning schedules (hourly, daily, weekly)
|
||||
- Real-time file watcher for instant indexing
|
||||
- Full and incremental rebuild options
|
||||
|
||||
### Books Management
|
||||
- Support for CBZ, CBR, and PDF formats
|
||||
- Automatic metadata extraction
|
||||
- Series and volume detection
|
||||
- Full-text search with Meilisearch
|
||||
|
||||
### Jobs Monitoring
|
||||
- Real-time job progress tracking
|
||||
- Detailed statistics (scanned, indexed, removed, errors)
|
||||
- Job history and logs
|
||||
- Cancel pending jobs
|
||||
|
||||
### Search
|
||||
- Full-text search across titles, authors, and series
|
||||
- Library filtering
|
||||
- Real-time suggestions
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `API_LISTEN_ADDR` | API service bind address | `0.0.0.0:8080` |
|
||||
| `INDEXER_LISTEN_ADDR` | Indexer service bind address | `0.0.0.0:8081` |
|
||||
| `BACKOFFICE_PORT` | Backoffice web UI port | `8082` |
|
||||
| `DATABASE_URL` | PostgreSQL connection string | `postgres://stripstream:stripstream@postgres:5432/stripstream` |
|
||||
| `MEILI_URL` | Meilisearch connection URL | `http://meilisearch:7700` |
|
||||
| `MEILI_MASTER_KEY` | Meilisearch master key (required) | - |
|
||||
| `API_BOOTSTRAP_TOKEN` | Initial API admin token (required) | - |
|
||||
| `INDEXER_SCAN_INTERVAL_SECONDS` | Watcher scan interval | `5` |
|
||||
| `LIBRARIES_ROOT_PATH` | Path to libraries directory | `/libraries` |
|
||||
|
||||
## API Documentation
|
||||
|
||||
The API is documented with OpenAPI/Swagger. When running locally, access the docs at:
|
||||
|
||||
```
|
||||
http://localhost:8080/api-docs
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
stripstream-librarian/
|
||||
├── apps/
|
||||
│ ├── api/ # Rust REST API
|
||||
│ ├── indexer/ # Rust background indexer
|
||||
│ └── backoffice/ # Next.js web UI
|
||||
├── infra/
|
||||
│ ├── docker-compose.yml
|
||||
│ └── migrations/ # SQL database migrations
|
||||
├── libraries/ # Book storage (mounted volume)
|
||||
└── .env # Environment configuration
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[Your License Here]
|
||||
@@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "admin-ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
stripstream-core = { path = "../../crates/core" }
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
@@ -1,22 +0,0 @@
|
||||
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"]
|
||||
@@ -1,337 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Form, State},
|
||||
response::{Html, Redirect},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use stripstream_core::config::AdminUiConfig;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
api_base_url: String,
|
||||
api_token: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LibraryDto {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
root_path: String,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IndexJobDto {
|
||||
id: Uuid,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
status: String,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenDto {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
scope: String,
|
||||
prefix: String,
|
||||
revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreatedToken {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AddLibraryForm {
|
||||
name: String,
|
||||
root_path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DeleteLibraryForm {
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RebuildForm {
|
||||
library_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateTokenForm {
|
||||
name: String,
|
||||
scope: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RevokeTokenForm {
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
#[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 state = AppState {
|
||||
api_base_url: config.api_base_url,
|
||||
api_token: config.api_token,
|
||||
client: Client::new(),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/", get(index))
|
||||
.route("/libraries", get(libraries_page))
|
||||
.route("/libraries/add", post(add_library))
|
||||
.route("/libraries/delete", post(delete_library))
|
||||
.route("/jobs", get(jobs_page))
|
||||
.route("/jobs/rebuild", post(rebuild_jobs))
|
||||
.route("/tokens", get(tokens_page))
|
||||
.route("/tokens/create", post(create_token))
|
||||
.route("/tokens/revoke", post(revoke_token))
|
||||
.with_state(state);
|
||||
|
||||
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<String> {
|
||||
Html(layout(
|
||||
"Dashboard",
|
||||
"<h1>Stripstream Admin</h1><p>Gestion des librairies, jobs d'indexation et tokens API.</p><p><a href='/libraries'>Libraries</a> | <a href='/jobs'>Jobs</a> | <a href='/tokens'>Tokens</a></p>",
|
||||
))
|
||||
}
|
||||
|
||||
async fn libraries_page(State(state): State<AppState>) -> Html<String> {
|
||||
let libraries = fetch_libraries(&state).await.unwrap_or_default();
|
||||
let mut rows = String::new();
|
||||
for lib in libraries {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>{}</td><td><code>{}</code></td><td>{}</td><td><form method='post' action='/libraries/delete'><input type='hidden' name='id' value='{}'/><button type='submit'>Delete</button></form></td></tr>",
|
||||
html_escape(&lib.name),
|
||||
html_escape(&lib.root_path),
|
||||
if lib.enabled { "yes" } else { "no" },
|
||||
lib.id
|
||||
));
|
||||
}
|
||||
|
||||
let content = format!(
|
||||
"<h1>Libraries</h1>
|
||||
<form method='post' action='/libraries/add'>
|
||||
<input name='name' placeholder='Name' required />
|
||||
<input name='root_path' placeholder='/libraries/demo' required />
|
||||
<button type='submit'>Add</button>
|
||||
</form>
|
||||
<table border='1' cellpadding='6'>
|
||||
<tr><th>Name</th><th>Root Path</th><th>Enabled</th><th>Actions</th></tr>
|
||||
{}
|
||||
</table>",
|
||||
rows
|
||||
);
|
||||
|
||||
Html(layout("Libraries", &content))
|
||||
}
|
||||
|
||||
async fn add_library(State(state): State<AppState>, Form(form): Form<AddLibraryForm>) -> Redirect {
|
||||
let _ = state
|
||||
.client
|
||||
.post(format!("{}/libraries", state.api_base_url))
|
||||
.bearer_auth(&state.api_token)
|
||||
.json(&serde_json::json!({"name": form.name, "root_path": form.root_path}))
|
||||
.send()
|
||||
.await;
|
||||
Redirect::to("/libraries")
|
||||
}
|
||||
|
||||
async fn delete_library(State(state): State<AppState>, Form(form): Form<DeleteLibraryForm>) -> Redirect {
|
||||
let _ = state
|
||||
.client
|
||||
.delete(format!("{}/libraries/{}", state.api_base_url, form.id))
|
||||
.bearer_auth(&state.api_token)
|
||||
.send()
|
||||
.await;
|
||||
Redirect::to("/libraries")
|
||||
}
|
||||
|
||||
async fn jobs_page(State(state): State<AppState>) -> Html<String> {
|
||||
let jobs = fetch_jobs(&state).await.unwrap_or_default();
|
||||
let mut rows = String::new();
|
||||
for job in jobs {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td></tr>",
|
||||
job.id,
|
||||
html_escape(&job.kind),
|
||||
html_escape(&job.status),
|
||||
html_escape(&job.created_at)
|
||||
));
|
||||
}
|
||||
|
||||
let content = format!(
|
||||
"<h1>Index Jobs</h1>
|
||||
<form method='post' action='/jobs/rebuild'>
|
||||
<input name='library_id' placeholder='optional library UUID' />
|
||||
<button type='submit'>Queue Rebuild</button>
|
||||
</form>
|
||||
<table border='1' cellpadding='6'>
|
||||
<tr><th>ID</th><th>Type</th><th>Status</th><th>Created</th></tr>
|
||||
{}
|
||||
</table>",
|
||||
rows
|
||||
);
|
||||
|
||||
Html(layout("Jobs", &content))
|
||||
}
|
||||
|
||||
async fn rebuild_jobs(State(state): State<AppState>, Form(form): Form<RebuildForm>) -> Redirect {
|
||||
let body = if form.library_id.trim().is_empty() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
serde_json::json!({"library_id": form.library_id.trim()})
|
||||
};
|
||||
let _ = state
|
||||
.client
|
||||
.post(format!("{}/index/rebuild", state.api_base_url))
|
||||
.bearer_auth(&state.api_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await;
|
||||
Redirect::to("/jobs")
|
||||
}
|
||||
|
||||
async fn tokens_page(State(state): State<AppState>) -> Html<String> {
|
||||
let tokens = fetch_tokens(&state).await.unwrap_or_default();
|
||||
let mut rows = String::new();
|
||||
for token in tokens {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>{}</td><td>{}</td><td><code>{}</code></td><td>{}</td><td><form method='post' action='/tokens/revoke'><input type='hidden' name='id' value='{}'/><button type='submit'>Revoke</button></form></td></tr>",
|
||||
html_escape(&token.name),
|
||||
html_escape(&token.scope),
|
||||
html_escape(&token.prefix),
|
||||
if token.revoked_at.is_some() { "yes" } else { "no" },
|
||||
token.id
|
||||
));
|
||||
}
|
||||
|
||||
let content = format!(
|
||||
"<h1>API Tokens</h1>
|
||||
<form method='post' action='/tokens/create'>
|
||||
<input name='name' placeholder='token name' required />
|
||||
<select name='scope'>
|
||||
<option value='read'>read</option>
|
||||
<option value='admin'>admin</option>
|
||||
</select>
|
||||
<button type='submit'>Create Token</button>
|
||||
</form>
|
||||
<table border='1' cellpadding='6'>
|
||||
<tr><th>Name</th><th>Scope</th><th>Prefix</th><th>Revoked</th><th>Actions</th></tr>
|
||||
{}
|
||||
</table>",
|
||||
rows
|
||||
);
|
||||
|
||||
Html(layout("Tokens", &content))
|
||||
}
|
||||
|
||||
async fn create_token(State(state): State<AppState>, Form(form): Form<CreateTokenForm>) -> Html<String> {
|
||||
let response = state
|
||||
.client
|
||||
.post(format!("{}/admin/tokens", state.api_base_url))
|
||||
.bearer_auth(&state.api_token)
|
||||
.json(&serde_json::json!({"name": form.name, "scope": form.scope}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let created: Result<CreatedToken, _> = resp.json().await;
|
||||
match created {
|
||||
Ok(token) => Html(layout(
|
||||
"Token Created",
|
||||
&format!(
|
||||
"<h1>Token Created</h1><p>Copie ce token maintenant (il ne sera plus affiche):</p><pre>{}</pre><p><a href='/tokens'>Back to tokens</a></p>",
|
||||
html_escape(&token.token)
|
||||
),
|
||||
)),
|
||||
Err(_) => Html(layout("Error", "<p>Token created but response parse failed.</p><p><a href='/tokens'>Back</a></p>")),
|
||||
}
|
||||
}
|
||||
_ => Html(layout("Error", "<p>Token creation failed.</p><p><a href='/tokens'>Back</a></p>")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn revoke_token(State(state): State<AppState>, Form(form): Form<RevokeTokenForm>) -> Redirect {
|
||||
let _ = state
|
||||
.client
|
||||
.delete(format!("{}/admin/tokens/{}", state.api_base_url, form.id))
|
||||
.bearer_auth(&state.api_token)
|
||||
.send()
|
||||
.await;
|
||||
Redirect::to("/tokens")
|
||||
}
|
||||
|
||||
async fn fetch_libraries(state: &AppState) -> Result<Vec<LibraryDto>, reqwest::Error> {
|
||||
state
|
||||
.client
|
||||
.get(format!("{}/libraries", state.api_base_url))
|
||||
.bearer_auth(&state.api_token)
|
||||
.send()
|
||||
.await?
|
||||
.json::<Vec<LibraryDto>>()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_jobs(state: &AppState) -> Result<Vec<IndexJobDto>, reqwest::Error> {
|
||||
state
|
||||
.client
|
||||
.get(format!("{}/index/status", state.api_base_url))
|
||||
.bearer_auth(&state.api_token)
|
||||
.send()
|
||||
.await?
|
||||
.json::<Vec<IndexJobDto>>()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_tokens(state: &AppState) -> Result<Vec<TokenDto>, reqwest::Error> {
|
||||
state
|
||||
.client
|
||||
.get(format!("{}/admin/tokens", state.api_base_url))
|
||||
.bearer_auth(&state.api_token)
|
||||
.send()
|
||||
.await?
|
||||
.json::<Vec<TokenDto>>()
|
||||
.await
|
||||
}
|
||||
|
||||
fn layout(title: &str, content: &str) -> String {
|
||||
format!(
|
||||
"<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>{}</title><style>body{{font-family:ui-sans-serif,system-ui;margin:2rem;line-height:1.4}}table{{width:100%;border-collapse:collapse;margin-top:1rem}}th,td{{text-align:left}}input,select,button{{padding:.45rem;margin:.25rem}}</style></head><body><nav><a href='/'>Home</a> | <a href='/libraries'>Libraries</a> | <a href='/jobs'>Jobs</a> | <a href='/tokens'>Tokens</a></nav><hr />{}</body></html>",
|
||||
html_escape(title),
|
||||
content
|
||||
)
|
||||
}
|
||||
|
||||
fn html_escape(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
Reference in New Issue
Block a user