Initial commit: web-app boilerplate with auth, OIDC/SSO, admin panel, i18n
Rust (cot framework) + PostgreSQL boilerplate providing: - Session-based auth with Admin/User roles - OIDC/SSO login with PKCE, group-to-role mapping, auto-provisioning - Admin panel: user management, settings, debug/config inspector - 3-tier config system (compiled default → DB → FURU_* env vars) - i18n (English + Russian) with compile-time translations macro - JSON API skeleton (GET /api/me) - DB-backed sessions (survive server restarts) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/nul
|
||||
Generated
+4095
File diff suppressed because it is too large
Load Diff
+16
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||
|
||||
[dependencies]
|
||||
cot = { path = "../cot/cot", features = ["postgres", "json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
openidconnect = "4.0"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
tokio = { version = "1", features = ["sync"] }
|
||||
base64 = "0.22"
|
||||
serde_json = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
@@ -0,0 +1,193 @@
|
||||
# furumusic
|
||||
|
||||
Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL.
|
||||
|
||||
Built with Rust ([cot](https://cot.rs) framework).
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
export FURU_DATABASE_URL=postgres://user:pass@localhost/furumusic
|
||||
cargo run
|
||||
# Open http://localhost:8000/admin/setup to create the first admin account
|
||||
```
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
Cargo.toml Project manifest and dependencies
|
||||
build.rs Captures rustc version + target at compile time
|
||||
src/
|
||||
main.rs Entrypoint; HTTP router, login/logout handlers, tracing init
|
||||
config.rs 3-tier config system (default → DB → env); FURU_* env vars
|
||||
auth.rs Session auth, Role enum (Admin/User), login/logout/guards
|
||||
user.rs User + OidcLink DB models, CRUD, password hashing, migrations
|
||||
oidc.rs OIDC/SSO flow: discovery, PKCE, token exchange, user provisioning
|
||||
i18n/
|
||||
mod.rs Language resolution (cookie → Accept-Language → default), extractor
|
||||
phrases.rs All UI strings in English and Russian (translations! macro)
|
||||
api/
|
||||
mod.rs JSON API endpoints (mounted at /api), session-based auth
|
||||
admin/
|
||||
mod.rs Admin sub-app router: dashboard, settings, users, debug, setup
|
||||
views.rs Admin page handlers and templates
|
||||
templates/
|
||||
base.html Root HTML layout with lang/title blocks
|
||||
login.html Login page (password + optional SSO button)
|
||||
admin/
|
||||
layout.html Admin sidebar/nav wrapper
|
||||
index.html Admin dashboard
|
||||
debug.html Build info + config table (with secret redaction)
|
||||
settings.html OIDC and auth settings form
|
||||
setup.html First-run admin account creation
|
||||
users.html User list
|
||||
user_form.html User create/edit form
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Config system (`src/config.rs`)
|
||||
|
||||
Every setting lives in `AppConfig` and is resolved in three layers:
|
||||
|
||||
1. **Compiled default** — `AppConfig::default()`
|
||||
2. **Database override** — rows in the `furumusic__config_entry` table
|
||||
3. **Environment variable** — `FURU_<FIELD_NAME>` (highest priority)
|
||||
|
||||
`ConfigSources` tracks where each field's effective value came from (shown in the admin debug page).
|
||||
|
||||
**To add a new config field:**
|
||||
|
||||
1. Add the field to `AppConfig` struct
|
||||
2. Set its default in `AppConfig::default()`
|
||||
3. Add the field to `ConfigSources` struct and its `Default` impl
|
||||
4. Add it to the `impl_env_overrides!(…)` invocation
|
||||
5. Add an `apply_db_field!()` call in `apply_db_overrides`
|
||||
6. Add an `entry!()` line in `admin/views.rs → config_display_entries()`
|
||||
|
||||
### Auth (`src/auth.rs`)
|
||||
|
||||
Session-based authentication with two roles:
|
||||
|
||||
- **`Role::Admin`** — full access to admin panel
|
||||
- **`Role::User`** — standard user
|
||||
|
||||
Key functions:
|
||||
- `login(session, user_id)` — sets session, cycles session ID
|
||||
- `logout(session)` — flushes session
|
||||
- `get_session_user(session, db)` — returns `AuthenticatedUser` if active
|
||||
- `require_admin_or_redirect(session, db)` — guard that returns 403 or redirects to `/login`
|
||||
|
||||
### OIDC/SSO (`src/oidc.rs`)
|
||||
|
||||
Full OpenID Connect authorization code flow with PKCE:
|
||||
|
||||
1. `GET /auth/oidc/start` — discovers provider, builds auth URL, stores CSRF/nonce/PKCE in session, redirects to IdP
|
||||
2. `GET /auth/oidc/callback` — validates CSRF, exchanges code for tokens, verifies ID token, provisions user
|
||||
|
||||
Provider metadata is cached for 1 hour and invalidated when OIDC config changes.
|
||||
|
||||
**Group-to-role mapping:** The `oidc_admin_groups` config field lists OIDC group names (comma-separated) that grant the admin role. Groups are extracted from the `groups` claim in the ID token JWT payload.
|
||||
|
||||
**User provisioning order:**
|
||||
1. Find existing `OidcLink` by issuer+sub → update claims, update role
|
||||
2. Find existing `User` by email → create OidcLink, update role
|
||||
3. Create new user (no password) + OidcLink
|
||||
|
||||
Stale links (pointing to deleted users) are cleaned up automatically.
|
||||
|
||||
### User model (`src/user.rs`)
|
||||
|
||||
Two database models:
|
||||
|
||||
- **`User`** — id, username (unique), password (optional for OIDC-only), email, display_name, avatar_url, role, is_active
|
||||
- **`OidcLink`** — id, user_id, issuer, sub, email, name, avatar_url; unique index on (issuer, sub)
|
||||
|
||||
Migrations: M0003 (User table), M0004 (OidcLink table), M0005 (OidcLink indexes).
|
||||
|
||||
### i18n (`src/i18n/`)
|
||||
|
||||
Compile-time bilingual UI (English + Russian).
|
||||
|
||||
- `translations!` macro in `phrases.rs` generates a `Translations` struct with static `EN` and `RU` instances
|
||||
- Language resolution: `furu_lang` cookie → `Accept-Language` header → English default
|
||||
- `I18n` is a cot request extractor — handlers receive it automatically
|
||||
- `set_lang` endpoint (`/set-lang?lang=ru&next=/`) sets the cookie
|
||||
|
||||
### API (`src/api/`)
|
||||
|
||||
JSON API mounted at `/api`. Uses the same session cookie as HTML pages — works automatically for same-origin frontend requests (no CORS, no tokens needed).
|
||||
|
||||
Helpers in `api/mod.rs`:
|
||||
- `json_ok(value)` — 200 with `application/json`
|
||||
- `json_error(status, message)` — error response as `{"error": "..."}`
|
||||
|
||||
| Route | Method | Description |
|
||||
|-------|--------|-------------|
|
||||
| `/api/me` | GET | Current user (id, name, role) or 401 |
|
||||
|
||||
To add a new API endpoint: write an async handler returning `cot::Result<cot::response::Response>`, use `json_ok`/`json_error`, add a `Route` in `ApiApp::router()`.
|
||||
|
||||
### Admin panel (`src/admin/`)
|
||||
|
||||
Mounted at `/admin`. All routes (except `/admin/setup`) require `Role::Admin`.
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `/admin/setup` | First-run: create initial admin (only works when zero users exist) |
|
||||
| `/admin/` | Dashboard |
|
||||
| `/admin/debug` | Build info, config values with sources, DB connectivity |
|
||||
| `/admin/settings` | OIDC config, auth toggles (saved to DB config table) |
|
||||
| `/admin/users` | User list |
|
||||
| `/admin/users/new` | Create user |
|
||||
| `/admin/users/{id}/edit` | Edit user |
|
||||
| `/admin/users/{id}/delete` | Delete user (POST) |
|
||||
|
||||
## How to extend
|
||||
|
||||
### 1. Add a config field
|
||||
|
||||
See [Config system](#config-system-srcconfigrs) above — 6 locations to update.
|
||||
|
||||
### 2. Add a database model
|
||||
|
||||
1. Define a struct with `#[cot::db::model]` in a new or existing file
|
||||
2. Write a migration struct implementing `cot::db::migrations::Migration`
|
||||
3. Register the migration in the `AdminApp::migrations()` method in `src/admin/mod.rs`
|
||||
|
||||
### 3. Add a page
|
||||
|
||||
1. Create a template in `templates/`
|
||||
2. Write a handler function that returns `Html`
|
||||
3. Add a `Route::with_handler_and_name(…)` in the appropriate `router()` method
|
||||
4. If admin-only, wrap with `require_admin_or_redirect`
|
||||
|
||||
### 4. Add a translation
|
||||
|
||||
Add a line to the `translations!` macro in `src/i18n/phrases.rs`:
|
||||
|
||||
```rust
|
||||
my_key: "English text", "Русский текст";
|
||||
```
|
||||
|
||||
Access it in handlers/templates as `i18n.t.my_key` (or `t.my_key` in templates).
|
||||
|
||||
### 5. Add an API endpoint
|
||||
|
||||
Same as adding a page, but return a JSON response instead of `Html`. The `json` feature is enabled in Cargo.toml.
|
||||
|
||||
## Environment variables
|
||||
|
||||
All prefixed with `FURU_`. Priority: env var > DB override > compiled default.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `FURU_DATABASE_URL` | PostgreSQL connection URL | *(empty — required)* |
|
||||
| `FURU_LOG_LEVEL` | Tracing filter (e.g. `info`, `debug`, `warn,furumusic=trace`) | `info` |
|
||||
| `FURU_AUTH_PASSWORD_ENABLED` | Enable password login | `true` |
|
||||
| `FURU_AUTH_SSO_ENABLED` | Enable SSO/OIDC login | `false` |
|
||||
| `FURU_OIDC_ISSUER` | OIDC issuer URL | *(empty)* |
|
||||
| `FURU_OIDC_CLIENT_ID` | OIDC client ID | *(empty)* |
|
||||
| `FURU_OIDC_CLIENT_SECRET` | OIDC client secret | *(empty)* |
|
||||
| `FURU_OIDC_BUTTON_TEXT` | SSO button label | `Sign in with SSO` |
|
||||
| `FURU_OIDC_ADMIN_GROUPS` | Comma-separated OIDC groups that grant admin | *(empty)* |
|
||||
@@ -0,0 +1,17 @@
|
||||
fn main() {
|
||||
println!(
|
||||
"cargo::rustc-env=FURU_TARGET={}",
|
||||
std::env::var("TARGET").unwrap()
|
||||
);
|
||||
|
||||
let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into());
|
||||
let output = std::process::Command::new(rustc)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.expect("failed to run rustc --version");
|
||||
let version = String::from_utf8_lossy(&output.stdout);
|
||||
println!(
|
||||
"cargo::rustc-env=FURU_RUSTC_VERSION={}",
|
||||
version.trim()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
pub mod views;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cot::db::Database;
|
||||
use cot::db::migrations::SyncDynMigration;
|
||||
use cot::request::extractors::{Path, RequestForm, UrlQuery};
|
||||
use cot::response::IntoResponse;
|
||||
use cot::router::method::get;
|
||||
use cot::router::{Route, Router};
|
||||
use cot::session::Session;
|
||||
use cot::App;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth;
|
||||
use crate::config::AppConfig;
|
||||
use crate::i18n::I18n;
|
||||
use crate::user::User;
|
||||
use views::{OidcSettingsForm, SetupForm, UserForm};
|
||||
|
||||
/// Build-time metadata baked in by `build.rs` and Cargo env vars.
|
||||
#[derive(Debug)]
|
||||
pub struct BuildInfo {
|
||||
pub pkg_name: &'static str,
|
||||
pub pkg_version: &'static str,
|
||||
pub profile: &'static str,
|
||||
pub target: &'static str,
|
||||
pub rustc_version: &'static str,
|
||||
}
|
||||
|
||||
pub static BUILD_INFO: BuildInfo = BuildInfo {
|
||||
pkg_name: env!("CARGO_PKG_NAME"),
|
||||
pkg_version: env!("CARGO_PKG_VERSION"),
|
||||
profile: if cfg!(debug_assertions) {
|
||||
"debug"
|
||||
} else {
|
||||
"release"
|
||||
},
|
||||
target: env!("FURU_TARGET"),
|
||||
rustc_version: env!("FURU_RUSTC_VERSION"),
|
||||
};
|
||||
|
||||
pub struct AdminApp {
|
||||
config: Arc<AppConfig>,
|
||||
}
|
||||
|
||||
impl AdminApp {
|
||||
pub fn new(config: Arc<AppConfig>) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SettingsQuery {
|
||||
saved: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PathId {
|
||||
id: i64,
|
||||
}
|
||||
|
||||
impl App for AdminApp {
|
||||
fn name(&self) -> &'static str {
|
||||
"admin"
|
||||
}
|
||||
|
||||
fn router(&self) -> Router {
|
||||
Router::with_urls([
|
||||
// -- Setup (first-run, no auth required) --------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/setup",
|
||||
get(|i18n: I18n, db: Database| async move {
|
||||
let count = User::count_all(&db).await.unwrap_or(1);
|
||||
if count > 0 {
|
||||
return Ok(auth::redirect("/admin/"));
|
||||
}
|
||||
views::setup_page(i18n, String::new())
|
||||
.await?
|
||||
.into_response()
|
||||
})
|
||||
.post(
|
||||
|i18n: I18n, db: Database, session: Session,
|
||||
form: RequestForm<SetupForm>| async move {
|
||||
let count = User::count_all(&db).await.unwrap_or(1);
|
||||
if count > 0 {
|
||||
return Ok(auth::redirect("/admin/"));
|
||||
}
|
||||
views::setup_submit(i18n, &db, &session, form).await
|
||||
},
|
||||
),
|
||||
"admin_setup",
|
||||
),
|
||||
// -- Dashboard ----------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/",
|
||||
|session: Session, db: Database, i18n: I18n| async move {
|
||||
// First-run redirect
|
||||
let count = User::count_all(&db).await.unwrap_or(0);
|
||||
if count == 0 {
|
||||
return Ok(auth::redirect("/admin/setup"));
|
||||
}
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::admin_index(admin, i18n).await?.into_response()
|
||||
},
|
||||
"admin_index",
|
||||
),
|
||||
// -- Debug --------------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/debug",
|
||||
{
|
||||
let config = Arc::clone(&self.config);
|
||||
move |session: Session, db: Database, i18n: I18n| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
let admin =
|
||||
match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::debug_handler(admin, i18n, &config, &db)
|
||||
.await?
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin_debug",
|
||||
),
|
||||
// -- Settings -----------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/settings",
|
||||
get({
|
||||
let config = Arc::clone(&self.config);
|
||||
move |session: Session, db: Database, i18n: I18n,
|
||||
query: UrlQuery<SettingsQuery>| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
let admin =
|
||||
match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
let saved = query.0.saved.as_deref() == Some("1");
|
||||
views::settings_handler(admin, i18n, &config, &db, saved)
|
||||
.await?
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
})
|
||||
.post({
|
||||
let config = Arc::clone(&self.config);
|
||||
move |session: Session, db: Database, i18n: I18n,
|
||||
form: RequestForm<OidcSettingsForm>| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
let admin =
|
||||
match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::settings_submit(admin, i18n, &config, &db, form).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_settings",
|
||||
),
|
||||
// -- Users --------------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/users",
|
||||
|session: Session, db: Database, i18n: I18n| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_list(admin, i18n, &db).await?.into_response()
|
||||
},
|
||||
"admin_users",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/users/new",
|
||||
get(|session: Session, db: Database, i18n: I18n| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_new(admin, i18n).await?.into_response()
|
||||
})
|
||||
.post(
|
||||
|session: Session, db: Database, form: RequestForm<UserForm>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_create(admin, &db, form).await
|
||||
},
|
||||
),
|
||||
"admin_users_new",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/users/{id}/edit",
|
||||
get(
|
||||
|session: Session, db: Database, i18n: I18n,
|
||||
path: Path<PathId>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_edit(admin, i18n, &db, path.0.id)
|
||||
.await?
|
||||
.into_response()
|
||||
},
|
||||
)
|
||||
.post(
|
||||
|session: Session, db: Database, path: Path<PathId>,
|
||||
form: RequestForm<UserForm>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_update(admin, &db, path.0.id, form).await
|
||||
},
|
||||
),
|
||||
"admin_users_edit",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/users/{id}/delete",
|
||||
cot::router::method::post(
|
||||
|session: Session, db: Database, path: Path<PathId>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_delete(admin, &db, path.0.id).await
|
||||
},
|
||||
),
|
||||
"admin_users_delete",
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn migrations(&self) -> Vec<Box<SyncDynMigration>> {
|
||||
let mut all =
|
||||
cot::db::migrations::wrap_migrations(crate::config::db_migrations::MIGRATIONS);
|
||||
all.extend(cot::db::migrations::wrap_migrations(
|
||||
crate::user::db_migrations::MIGRATIONS,
|
||||
));
|
||||
all
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
use cot::db::{Database, Model};
|
||||
use cot::form::{Form, FormResult};
|
||||
use cot::html::Html;
|
||||
use cot::request::extractors::RequestForm;
|
||||
use cot::response::IntoResponse;
|
||||
use cot::session::Session;
|
||||
use cot::{Body, Template};
|
||||
|
||||
use crate::auth::{self, AuthenticatedUser};
|
||||
use crate::config::{AppConfig, ConfigEntry, ConfigSources};
|
||||
use crate::i18n::{I18n, Translations};
|
||||
use crate::user::User;
|
||||
use super::BUILD_INFO;
|
||||
|
||||
/// A config entry for display in the unified debug table.
|
||||
#[derive(Debug)]
|
||||
pub struct ConfigDisplayEntry {
|
||||
pub key: String,
|
||||
pub env_var: String,
|
||||
pub value: String,
|
||||
pub default_value: String,
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
/// Secret field names that should be redacted in the debug view.
|
||||
const SECRET_FIELDS: &[&str] = &[
|
||||
"database_url",
|
||||
"oidc_client_secret",
|
||||
];
|
||||
|
||||
fn is_secret(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
SECRET_FIELDS.iter().any(|s| lower.contains(s))
|
||||
|| lower.contains("secret")
|
||||
|| lower.contains("token")
|
||||
}
|
||||
|
||||
fn redact(value: &str) -> String {
|
||||
if value.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
"********".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/debug.html")]
|
||||
struct DebugTemplate<'a> {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
build: &'a super::BuildInfo,
|
||||
config_entries: Vec<ConfigDisplayEntry>,
|
||||
db_status: String,
|
||||
}
|
||||
|
||||
fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<ConfigDisplayEntry> {
|
||||
let defaults = AppConfig::default();
|
||||
|
||||
macro_rules! entry {
|
||||
($field:ident, $value:expr, $default:expr) => {
|
||||
{
|
||||
let raw = $value;
|
||||
let default_raw = $default;
|
||||
let secret = is_secret(stringify!($field));
|
||||
let display = if secret { redact(&raw) } else { raw };
|
||||
let default_display = if secret { redact(&default_raw) } else { default_raw };
|
||||
ConfigDisplayEntry {
|
||||
key: stringify!($field).into(),
|
||||
env_var: format!("FURU_{}", stringify!($field).to_ascii_uppercase()),
|
||||
value: display,
|
||||
default_value: default_display,
|
||||
source: sources.$field.code(),
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
vec![
|
||||
entry!(database_url, config.database_url.clone(), defaults.database_url.clone()),
|
||||
entry!(oidc_issuer, config.oidc_issuer.clone(), defaults.oidc_issuer.clone()),
|
||||
entry!(oidc_client_id, config.oidc_client_id.clone(), defaults.oidc_client_id.clone()),
|
||||
entry!(oidc_client_secret, config.oidc_client_secret.clone(), defaults.oidc_client_secret.clone()),
|
||||
entry!(log_level, config.log_level.clone(), defaults.log_level.clone()),
|
||||
entry!(auth_password_enabled, config.auth_password_enabled.to_string(), defaults.auth_password_enabled.to_string()),
|
||||
entry!(auth_sso_enabled, config.auth_sso_enabled.to_string(), defaults.auth_sso_enabled.to_string()),
|
||||
entry!(oidc_button_text, config.oidc_button_text.clone(), defaults.oidc_button_text.clone()),
|
||||
entry!(oidc_admin_groups, config.oidc_admin_groups.clone(), defaults.oidc_admin_groups.clone()),
|
||||
]
|
||||
}
|
||||
|
||||
pub async fn debug_handler(
|
||||
admin: AuthenticatedUser,
|
||||
i18n: I18n,
|
||||
_startup_config: &AppConfig,
|
||||
db: &Database,
|
||||
) -> cot::Result<Html> {
|
||||
let (config, sources) = AppConfig::load_with_db(db).await;
|
||||
|
||||
let db_status = match db.raw("SELECT 1").await {
|
||||
Ok(_) => i18n.t.debug_db_connected.to_owned(),
|
||||
Err(e) => format!("{}: {e}", i18n.t.debug_db_error),
|
||||
};
|
||||
|
||||
let template = DebugTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
build: &BUILD_INFO,
|
||||
config_entries: config_display_entries(&config, &sources),
|
||||
db_status,
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/index.html")]
|
||||
struct AdminIndexTemplate {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
}
|
||||
|
||||
pub async fn admin_index(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> {
|
||||
let template = AdminIndexTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/settings.html")]
|
||||
struct SettingsTemplate {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
saved: bool,
|
||||
auth_password_enabled: bool,
|
||||
auth_password_enabled_source: &'static str,
|
||||
auth_sso_enabled: bool,
|
||||
auth_sso_enabled_source: &'static str,
|
||||
oidc_button_text: String,
|
||||
oidc_button_text_source: &'static str,
|
||||
oidc_issuer: String,
|
||||
oidc_issuer_source: &'static str,
|
||||
oidc_client_id: String,
|
||||
oidc_client_id_source: &'static str,
|
||||
oidc_client_secret: String,
|
||||
oidc_client_secret_source: &'static str,
|
||||
oidc_admin_groups: String,
|
||||
oidc_admin_groups_source: &'static str,
|
||||
}
|
||||
|
||||
pub async fn settings_handler(
|
||||
admin: AuthenticatedUser,
|
||||
i18n: I18n,
|
||||
_startup_config: &AppConfig,
|
||||
db: &Database,
|
||||
saved: bool,
|
||||
) -> cot::Result<Html> {
|
||||
let (config, sources) = AppConfig::load_with_db(db).await;
|
||||
|
||||
let template = SettingsTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
saved,
|
||||
auth_password_enabled: config.auth_password_enabled,
|
||||
auth_password_enabled_source: sources.auth_password_enabled.code(),
|
||||
auth_sso_enabled: config.auth_sso_enabled,
|
||||
auth_sso_enabled_source: sources.auth_sso_enabled.code(),
|
||||
oidc_button_text: config.oidc_button_text,
|
||||
oidc_button_text_source: sources.oidc_button_text.code(),
|
||||
oidc_issuer: config.oidc_issuer,
|
||||
oidc_issuer_source: sources.oidc_issuer.code(),
|
||||
oidc_client_id: config.oidc_client_id,
|
||||
oidc_client_id_source: sources.oidc_client_id.code(),
|
||||
oidc_client_secret: config.oidc_client_secret,
|
||||
oidc_client_secret_source: sources.oidc_client_secret.code(),
|
||||
oidc_admin_groups: config.oidc_admin_groups,
|
||||
oidc_admin_groups_source: sources.oidc_admin_groups.code(),
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Form)]
|
||||
pub struct OidcSettingsForm {
|
||||
auth_password_enabled: Option<String>,
|
||||
auth_sso_enabled: Option<String>,
|
||||
oidc_button_text: String,
|
||||
oidc_issuer: String,
|
||||
oidc_client_id: String,
|
||||
oidc_client_secret: String,
|
||||
oidc_admin_groups: String,
|
||||
}
|
||||
|
||||
pub async fn settings_submit(
|
||||
_admin: AuthenticatedUser,
|
||||
_i18n: I18n,
|
||||
_startup_config: &AppConfig,
|
||||
db: &Database,
|
||||
form: RequestForm<OidcSettingsForm>,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
let RequestForm(result) = form;
|
||||
match result {
|
||||
FormResult::Ok(data) => {
|
||||
let pw_enabled = if data.auth_password_enabled.is_some() { "true" } else { "false" };
|
||||
let sso_enabled = if data.auth_sso_enabled.is_some() { "true" } else { "false" };
|
||||
let fields: [(&str, &str); 7] = [
|
||||
("auth_password_enabled", pw_enabled),
|
||||
("auth_sso_enabled", sso_enabled),
|
||||
("oidc_button_text", &data.oidc_button_text),
|
||||
("oidc_issuer", &data.oidc_issuer),
|
||||
("oidc_client_id", &data.oidc_client_id),
|
||||
("oidc_client_secret", &data.oidc_client_secret),
|
||||
("oidc_admin_groups", &data.oidc_admin_groups),
|
||||
];
|
||||
for (key, value) in fields {
|
||||
let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned());
|
||||
if let Err(e) = entry.save(db).await {
|
||||
tracing::error!(key, error = %e, "failed to save config entry");
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(auth::redirect("/admin/settings?saved=1"))
|
||||
}
|
||||
FormResult::ValidationError(_ctx) => {
|
||||
Ok(auth::redirect("/admin/settings"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/users.html")]
|
||||
struct UsersTemplate {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
users: Vec<User>,
|
||||
}
|
||||
|
||||
pub async fn users_list(admin: AuthenticatedUser, i18n: I18n, db: &Database) -> cot::Result<Html> {
|
||||
let users = User::list_all(db).await.unwrap_or_default();
|
||||
let template = UsersTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
users,
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/user_form.html")]
|
||||
struct UserFormTemplate {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
is_edit: bool,
|
||||
form_user_id: i64,
|
||||
form_username: String,
|
||||
form_email: String,
|
||||
form_display_name: String,
|
||||
form_role: String,
|
||||
}
|
||||
|
||||
pub async fn users_new(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> {
|
||||
let template = UserFormTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
is_edit: false,
|
||||
form_user_id: 0,
|
||||
form_username: String::new(),
|
||||
form_email: String::new(),
|
||||
form_display_name: String::new(),
|
||||
form_role: "user".into(),
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Form)]
|
||||
pub struct UserForm {
|
||||
username: String,
|
||||
email: String,
|
||||
display_name: String,
|
||||
password: String,
|
||||
role: String,
|
||||
}
|
||||
|
||||
pub async fn users_create(
|
||||
_admin: AuthenticatedUser,
|
||||
db: &Database,
|
||||
form: RequestForm<UserForm>,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
let RequestForm(result) = form;
|
||||
match result {
|
||||
FormResult::Ok(data) => {
|
||||
let email = if data.email.is_empty() { None } else { Some(data.email.as_str()) };
|
||||
let display_name = if data.display_name.is_empty() { None } else { Some(data.display_name.as_str()) };
|
||||
User::create(db, &data.username, email, display_name, &data.password, &data.role).await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to create user: {e}")))?;
|
||||
Ok(auth::redirect("/admin/users"))
|
||||
}
|
||||
FormResult::ValidationError(_) => {
|
||||
Ok(auth::redirect("/admin/users/new"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn users_edit(
|
||||
admin: AuthenticatedUser,
|
||||
i18n: I18n,
|
||||
db: &Database,
|
||||
user_id: i64,
|
||||
) -> cot::Result<Html> {
|
||||
let target = User::get_by_id(db, user_id).await
|
||||
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
|
||||
.ok_or_else(|| cot::Error::internal("user not found"))?;
|
||||
let template = UserFormTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
is_edit: true,
|
||||
form_user_id: target.id_val(),
|
||||
form_username: target.username_str().to_owned(),
|
||||
form_email: target.email_str(),
|
||||
form_display_name: target.display_name_str(),
|
||||
form_role: target.role_str().to_owned(),
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
pub async fn users_update(
|
||||
_admin: AuthenticatedUser,
|
||||
db: &Database,
|
||||
user_id: i64,
|
||||
form: RequestForm<UserForm>,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
let RequestForm(result) = form;
|
||||
match result {
|
||||
FormResult::Ok(data) => {
|
||||
let mut target = User::get_by_id(db, user_id).await
|
||||
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
|
||||
.ok_or_else(|| cot::Error::internal("user not found"))?;
|
||||
let email = if data.email.is_empty() { None } else { Some(data.email.as_str()) };
|
||||
let display_name = if data.display_name.is_empty() { None } else { Some(data.display_name.as_str()) };
|
||||
let new_password = if data.password.is_empty() { None } else { Some(data.password.as_str()) };
|
||||
target.update_fields(db, &data.username, email, display_name, new_password, &data.role).await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to update user: {e}")))?;
|
||||
Ok(auth::redirect("/admin/users"))
|
||||
}
|
||||
FormResult::ValidationError(_) => {
|
||||
Ok(auth::redirect(&format!("/admin/users/{user_id}/edit")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn users_delete(
|
||||
_admin: AuthenticatedUser,
|
||||
db: &Database,
|
||||
user_id: i64,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
User::delete_by_id(db, user_id).await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to delete user: {e}")))?;
|
||||
Ok(auth::redirect("/admin/users"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// First-run setup page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/setup.html")]
|
||||
struct SetupTemplate {
|
||||
t: &'static Translations,
|
||||
message: String,
|
||||
}
|
||||
|
||||
pub async fn setup_page(i18n: I18n, message: String) -> cot::Result<Html> {
|
||||
let template = SetupTemplate {
|
||||
t: i18n.t,
|
||||
message,
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Form)]
|
||||
pub struct SetupForm {
|
||||
username: String,
|
||||
password: String,
|
||||
confirm_password: String,
|
||||
}
|
||||
|
||||
pub async fn setup_submit(
|
||||
i18n: I18n,
|
||||
db: &Database,
|
||||
session: &Session,
|
||||
form: RequestForm<SetupForm>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let RequestForm(result) = form;
|
||||
let data = match result {
|
||||
FormResult::Ok(data) => data,
|
||||
FormResult::ValidationError(_) => {
|
||||
return setup_page(i18n, String::new()).await?.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if data.password != data.confirm_password {
|
||||
let msg = i18n.t.setup_mismatch.to_owned();
|
||||
return setup_page(i18n, msg).await?.into_response();
|
||||
}
|
||||
|
||||
let user = User::create(db, &data.username, None, None, &data.password, "admin")
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to create admin: {e}")))?;
|
||||
|
||||
auth::login(session, user.id_val()).await?;
|
||||
Ok(auth::redirect("/admin/"))
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use cot::db::Database;
|
||||
use cot::router::method::get;
|
||||
use cot::router::{Route, Router};
|
||||
use cot::session::Session;
|
||||
use cot::{App, Body};
|
||||
|
||||
use crate::auth;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON response helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn json_ok(value: &serde_json::Value) -> cot::response::Response {
|
||||
cot::http::Response::builder()
|
||||
.status(cot::http::StatusCode::OK)
|
||||
.header(cot::http::header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::fixed(value.to_string()))
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response {
|
||||
let body = serde_json::json!({ "error": message });
|
||||
cot::http::Response::builder()
|
||||
.status(status)
|
||||
.header(cot::http::header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::fixed(body.to_string()))
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/me
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn me_handler(
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||
return Ok(json_error(
|
||||
cot::http::StatusCode::UNAUTHORIZED,
|
||||
"not authenticated",
|
||||
));
|
||||
};
|
||||
|
||||
Ok(json_ok(&serde_json::json!({
|
||||
"id": user.id,
|
||||
"name": user.name,
|
||||
"role": user.role.code(),
|
||||
})))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct ApiApp;
|
||||
|
||||
impl App for ApiApp {
|
||||
fn name(&self) -> &'static str {
|
||||
"api"
|
||||
}
|
||||
|
||||
fn router(&self) -> Router {
|
||||
Router::with_urls([
|
||||
Route::with_handler_and_name("/me", get(me_handler), "api_me"),
|
||||
])
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
use cot::db::Database;
|
||||
use cot::response::IntoResponse;
|
||||
use cot::session::Session;
|
||||
use cot::Body;
|
||||
|
||||
use crate::user::User;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Role enum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
Admin,
|
||||
User,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Role::Admin => "admin",
|
||||
Role::User => "user",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_code(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"admin" => Some(Role::Admin),
|
||||
"user" => Some(Role::User),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session-based auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SESSION_USER_ID: &str = "user_id";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthenticatedUser {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
/// Read `user_id` from the session, fetch the `User` from DB, return
|
||||
/// `AuthenticatedUser` if the user exists and is active.
|
||||
pub async fn get_session_user(session: &Session, db: &Database) -> Option<AuthenticatedUser> {
|
||||
let user_id: i64 = session.get(SESSION_USER_ID).await.ok()??;
|
||||
let user = User::get_by_id(db, user_id).await.ok()??;
|
||||
if !user.is_active() {
|
||||
return None;
|
||||
}
|
||||
let name = {
|
||||
let display = user.display_name_str();
|
||||
if display.is_empty() {
|
||||
user.username_str().to_owned()
|
||||
} else {
|
||||
display
|
||||
}
|
||||
};
|
||||
Some(AuthenticatedUser {
|
||||
id: user.id_val(),
|
||||
name,
|
||||
role: user.role(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Return `Ok(user)` if the session belongs to an active admin, otherwise
|
||||
/// `Err(response)` — a redirect to `/login` or a 403.
|
||||
pub async fn require_admin_or_redirect(
|
||||
session: &Session,
|
||||
db: &Database,
|
||||
) -> Result<AuthenticatedUser, cot::response::Response> {
|
||||
let Some(user) = get_session_user(session, db).await else {
|
||||
return Err(redirect("/login"));
|
||||
};
|
||||
if user.role != Role::Admin {
|
||||
return Err(
|
||||
"Forbidden"
|
||||
.with_status(cot::http::StatusCode::FORBIDDEN)
|
||||
.into_response()
|
||||
.expect("valid response"),
|
||||
);
|
||||
}
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Insert user_id into the session and cycle the session ID.
|
||||
pub async fn login(session: &Session, user_id: i64) -> cot::Result<()> {
|
||||
session
|
||||
.cycle_id()
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
session
|
||||
.insert(SESSION_USER_ID, user_id)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush (destroy) the session.
|
||||
pub async fn logout(session: &Session) -> cot::Result<()> {
|
||||
session
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a 303 See Other redirect response.
|
||||
pub fn redirect(location: &str) -> cot::response::Response {
|
||||
cot::http::Response::builder()
|
||||
.status(cot::http::StatusCode::SEE_OTHER)
|
||||
.header(cot::http::header::LOCATION, location)
|
||||
.body(Body::fixed(""))
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn role_roundtrip() {
|
||||
assert_eq!(Role::from_code("admin"), Some(Role::Admin));
|
||||
assert_eq!(Role::from_code("user"), Some(Role::User));
|
||||
assert_eq!(Role::from_code("other"), None);
|
||||
assert_eq!(Role::Admin.code(), "admin");
|
||||
assert_eq!(Role::User.code(), "user");
|
||||
}
|
||||
}
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
/// Application-level configuration for furumusic.
|
||||
///
|
||||
/// Every field is available both as a `FURU_`-prefixed environment variable
|
||||
/// and through the admin UI. The resolution order is:
|
||||
///
|
||||
/// env var > DB override > compiled default
|
||||
///
|
||||
/// Adding a new field to [`AppConfig`] automatically makes it settable via
|
||||
/// the `FURU_<FIELD_NAME>` env var thanks to the [`impl_env_overrides`] macro.
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
|
||||
use cot::db::{Database, DatabaseField, Identifier, LimitedString, Model};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfigSource — tracks where each field's effective value came from
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConfigSource {
|
||||
Default,
|
||||
Database,
|
||||
Env,
|
||||
}
|
||||
|
||||
impl ConfigSource {
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::Default => "default",
|
||||
Self::Database => "database",
|
||||
Self::Env => "env",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfigEntry — DB model for the furu__config table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cot::db::model]
|
||||
pub struct ConfigEntry {
|
||||
#[model(primary_key)]
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl ConfigEntry {
|
||||
pub fn new(key: String, value: String) -> Self {
|
||||
Self { key, value }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub mod db_migrations {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0001CreateConfig;
|
||||
|
||||
impl migrations::Migration for M0001CreateConfig {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0001_create_config";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("furu__config"))
|
||||
.fields(&[
|
||||
Field::new(
|
||||
Identifier::new("key"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
)
|
||||
.primary_key()
|
||||
.set_null(<LimitedString<255> as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("value"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
|
||||
// -- M0002: rename furu__config → furumusic__config_entry ---------------
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn rename_config_table(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
ctx.db
|
||||
.raw("ALTER TABLE furu__config RENAME TO furumusic__config_entry")
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0002RenameConfigTable;
|
||||
|
||||
impl migrations::Migration for M0002RenameConfigTable {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0002_rename_config_table";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration("furumusic", "m_0001_create_config"),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::custom(rename_config_table).build(),
|
||||
];
|
||||
}
|
||||
|
||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[&M0001CreateConfig, &M0002RenameConfigTable];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfigSources — parallel struct tracking the source of each field
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct ConfigSources {
|
||||
pub database_url: ConfigSource,
|
||||
pub oidc_issuer: ConfigSource,
|
||||
pub oidc_client_id: ConfigSource,
|
||||
pub oidc_client_secret: ConfigSource,
|
||||
pub log_level: ConfigSource,
|
||||
pub auth_password_enabled: ConfigSource,
|
||||
pub auth_sso_enabled: ConfigSource,
|
||||
pub oidc_button_text: ConfigSource,
|
||||
pub oidc_admin_groups: ConfigSource,
|
||||
}
|
||||
|
||||
impl Default for ConfigSources {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
database_url: ConfigSource::Default,
|
||||
oidc_issuer: ConfigSource::Default,
|
||||
oidc_client_id: ConfigSource::Default,
|
||||
oidc_client_secret: ConfigSource::Default,
|
||||
log_level: ConfigSource::Default,
|
||||
auth_password_enabled: ConfigSource::Default,
|
||||
auth_sso_enabled: ConfigSource::Default,
|
||||
oidc_button_text: ConfigSource::Default,
|
||||
oidc_admin_groups: ConfigSource::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Env-var helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read a single env var with the `FURU_` prefix, returning `None` when the
|
||||
/// variable is absent and logging a warning when it is present but cannot be
|
||||
/// parsed.
|
||||
fn env_override<T: std::str::FromStr>(field: &str) -> Option<T> {
|
||||
let key = format!("FURU_{}", field.to_ascii_uppercase());
|
||||
match std::env::var(&key) {
|
||||
Ok(val) => match val.parse::<T>() {
|
||||
Ok(v) => Some(v),
|
||||
Err(_) => {
|
||||
tracing::warn!("ignoring invalid value for {key}: {val:?}");
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Macro: generates apply_env_overrides + apply_env_overrides_tracked
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generates two methods on [`AppConfig`]:
|
||||
///
|
||||
/// - `apply_env_overrides`: overwrites fields from `FURU_*` env vars (no source tracking).
|
||||
/// - `apply_env_overrides_tracked`: same but also marks sources as [`ConfigSource::Env`].
|
||||
macro_rules! impl_env_overrides {
|
||||
($($field:ident),* $(,)?) => {
|
||||
impl AppConfig {
|
||||
/// Apply `FURU_*` environment variable overrides to self.
|
||||
pub fn apply_env_overrides(&mut self) {
|
||||
$(
|
||||
if let Some(v) = env_override(stringify!($field)) {
|
||||
self.$field = v;
|
||||
}
|
||||
)*
|
||||
}
|
||||
|
||||
/// Apply `FURU_*` environment variable overrides and record sources.
|
||||
pub fn apply_env_overrides_tracked(&mut self, sources: &mut ConfigSources) {
|
||||
$(
|
||||
if let Some(v) = env_override(stringify!($field)) {
|
||||
self.$field = v;
|
||||
sources.$field = ConfigSource::Env;
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
/// PostgreSQL connection URL.
|
||||
pub database_url: String,
|
||||
/// OIDC issuer URL.
|
||||
pub oidc_issuer: String,
|
||||
/// OIDC client ID.
|
||||
pub oidc_client_id: String,
|
||||
/// OIDC client secret.
|
||||
pub oidc_client_secret: String,
|
||||
/// Tracing log level filter (e.g. "info", "debug", "warn,furumusic=debug").
|
||||
pub log_level: String,
|
||||
/// Whether password-based login is enabled.
|
||||
pub auth_password_enabled: bool,
|
||||
/// Whether SSO (OIDC) login is enabled.
|
||||
pub auth_sso_enabled: bool,
|
||||
/// Label shown on the SSO login button.
|
||||
pub oidc_button_text: String,
|
||||
/// Comma-separated list of OIDC group names that grant admin role.
|
||||
pub oidc_admin_groups: String,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
database_url: String::new(),
|
||||
oidc_issuer: String::new(),
|
||||
oidc_client_id: String::new(),
|
||||
oidc_client_secret: String::new(),
|
||||
log_level: "info".into(),
|
||||
auth_password_enabled: true,
|
||||
auth_sso_enabled: false,
|
||||
oidc_button_text: "Sign in with SSO".into(),
|
||||
oidc_admin_groups: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register every field that should be overridable via FURU_* env vars.
|
||||
impl_env_overrides!(
|
||||
database_url,
|
||||
oidc_issuer,
|
||||
oidc_client_id,
|
||||
oidc_client_secret,
|
||||
log_level,
|
||||
auth_password_enabled,
|
||||
auth_sso_enabled,
|
||||
oidc_button_text,
|
||||
oidc_admin_groups,
|
||||
);
|
||||
|
||||
impl AppConfig {
|
||||
/// Build config: start from defaults, then overlay env vars.
|
||||
/// Used at startup before the DB is available (to get `database_url`).
|
||||
pub fn load() -> Self {
|
||||
let mut cfg = Self::default();
|
||||
cfg.apply_env_overrides();
|
||||
cfg
|
||||
}
|
||||
|
||||
/// Build config with full 3-layer resolution (default → DB → env) and
|
||||
/// track the source of each field.
|
||||
pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) {
|
||||
let mut cfg = Self::default();
|
||||
let mut sources = ConfigSources::default();
|
||||
cfg.apply_db_overrides(db, &mut sources).await;
|
||||
cfg.apply_env_overrides_tracked(&mut sources);
|
||||
(cfg, sources)
|
||||
}
|
||||
|
||||
/// Query all rows from `furu__config` and overlay matching fields.
|
||||
async fn apply_db_overrides(&mut self, db: &Database, sources: &mut ConfigSources) {
|
||||
let rows = match ConfigEntry::objects().all(db).await {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to read furu__config: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let map: HashMap<String, String> = rows
|
||||
.into_iter()
|
||||
.map(|entry| (entry.key.to_string(), entry.value))
|
||||
.collect();
|
||||
|
||||
macro_rules! apply_db_field {
|
||||
($field:ident) => {
|
||||
if let Some(val) = map.get(stringify!($field)) {
|
||||
match val.parse() {
|
||||
Ok(v) => {
|
||||
self.$field = v;
|
||||
sources.$field = ConfigSource::Database;
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
"ignoring invalid DB config value for {}: {:?}",
|
||||
stringify!($field),
|
||||
val,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
apply_db_field!(database_url);
|
||||
apply_db_field!(oidc_issuer);
|
||||
apply_db_field!(oidc_client_id);
|
||||
apply_db_field!(oidc_client_secret);
|
||||
apply_db_field!(log_level);
|
||||
apply_db_field!(auth_password_enabled);
|
||||
apply_db_field!(auth_sso_enabled);
|
||||
apply_db_field!(oidc_button_text);
|
||||
apply_db_field!(oidc_admin_groups);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_are_sane() {
|
||||
let cfg = AppConfig::default();
|
||||
assert!(cfg.database_url.is_empty());
|
||||
assert_eq!(cfg.log_level, "info");
|
||||
}
|
||||
|
||||
// SAFETY: tests run with --test-threads=1 so no concurrent env access.
|
||||
unsafe fn set(k: &str, v: &str) { unsafe { std::env::set_var(k, v) }; }
|
||||
unsafe fn unset(k: &str) { unsafe { std::env::remove_var(k) }; }
|
||||
|
||||
#[test]
|
||||
fn env_override_string_field() {
|
||||
unsafe { set("FURU_OIDC_ISSUER", "https://example.com"); }
|
||||
let cfg = AppConfig::load();
|
||||
assert_eq!(cfg.oidc_issuer, "https://example.com");
|
||||
unsafe { unset("FURU_OIDC_ISSUER"); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_bool_field() {
|
||||
unsafe { set("FURU_AUTH_SSO_ENABLED", "true"); }
|
||||
let cfg = AppConfig::load();
|
||||
assert!(cfg.auth_sso_enabled);
|
||||
unsafe { unset("FURU_AUTH_SSO_ENABLED"); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_tracking_env() {
|
||||
unsafe { set("FURU_OIDC_ISSUER", "https://tracked.example.com"); }
|
||||
let mut cfg = AppConfig::default();
|
||||
let mut sources = ConfigSources::default();
|
||||
cfg.apply_env_overrides_tracked(&mut sources);
|
||||
assert_eq!(cfg.oidc_issuer, "https://tracked.example.com");
|
||||
assert_eq!(sources.oidc_issuer, ConfigSource::Env);
|
||||
assert_eq!(sources.database_url, ConfigSource::Default);
|
||||
unsafe { unset("FURU_OIDC_ISSUER"); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_source_codes() {
|
||||
assert_eq!(ConfigSource::Default.code(), "default");
|
||||
assert_eq!(ConfigSource::Database.code(), "database");
|
||||
assert_eq!(ConfigSource::Env.code(), "env");
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
mod phrases;
|
||||
|
||||
pub use phrases::Translations;
|
||||
|
||||
use cot::request::extractors::FromRequestHead;
|
||||
use cot::request::RequestHead;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lang enum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Lang {
|
||||
En,
|
||||
Ru,
|
||||
}
|
||||
|
||||
impl Lang {
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Lang::En => "en",
|
||||
Lang::Ru => "ru",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_code(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"en" => Some(Lang::En),
|
||||
"ru" => Some(Lang::Ru),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// translations! macro
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
macro_rules! translations {
|
||||
( $( $key:ident : $en:expr , $ru:expr );* $(;)? ) => {
|
||||
#[derive(Debug)]
|
||||
pub struct Translations {
|
||||
pub lang: $crate::i18n::Lang,
|
||||
$( pub $key: &'static str, )*
|
||||
}
|
||||
|
||||
static EN: Translations = Translations {
|
||||
lang: $crate::i18n::Lang::En,
|
||||
$( $key: $en, )*
|
||||
};
|
||||
|
||||
static RU: Translations = Translations {
|
||||
lang: $crate::i18n::Lang::Ru,
|
||||
$( $key: $ru, )*
|
||||
};
|
||||
|
||||
impl Translations {
|
||||
pub fn for_lang(lang: $crate::i18n::Lang) -> &'static Self {
|
||||
match lang {
|
||||
$crate::i18n::Lang::En => &EN,
|
||||
$crate::i18n::Lang::Ru => &RU,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use translations;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cookie helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COOKIE_NAME: &str = "furu_lang";
|
||||
|
||||
/// Build a `Set-Cookie` header value that persists the language choice for 1 year.
|
||||
pub fn lang_cookie(lang: Lang) -> String {
|
||||
format!("{COOKIE_NAME}={}; Path=/; SameSite=Lax; Max-Age=31536000", lang.code())
|
||||
}
|
||||
|
||||
/// Parse `furu_lang` from the `Cookie` request header.
|
||||
fn lang_from_cookie(headers: &cot::http::HeaderMap) -> Option<Lang> {
|
||||
let raw = headers.get(cot::http::header::COOKIE)?.to_str().ok()?;
|
||||
for part in raw.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(value) = part.strip_prefix("furu_lang=") {
|
||||
return Lang::from_code(value.trim());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accept-Language parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse the Accept-Language header and return the best matching `Lang`.
|
||||
fn parse_accept_language(header: &str) -> Option<Lang> {
|
||||
let mut langs: Vec<(&str, u16)> = header
|
||||
.split(',')
|
||||
.filter_map(|part| {
|
||||
let part = part.trim();
|
||||
let (tag, quality) = if let Some((tag, q)) = part.split_once(";q=") {
|
||||
let q = q.trim().parse::<f32>().ok()?;
|
||||
(tag.trim(), (q * 1000.0) as u16)
|
||||
} else {
|
||||
(part, 1000)
|
||||
};
|
||||
Some((tag, quality))
|
||||
})
|
||||
.collect();
|
||||
|
||||
langs.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
for (tag, _) in langs {
|
||||
let primary = tag.split('-').next().unwrap_or(tag);
|
||||
if let Some(lang) = Lang::from_code(primary) {
|
||||
return Some(lang);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Language resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn resolve_lang(headers: &cot::http::HeaderMap) -> Lang {
|
||||
// 1. Explicit cookie override.
|
||||
if let Some(lang) = lang_from_cookie(headers) {
|
||||
return lang;
|
||||
}
|
||||
|
||||
// 2. Accept-Language header.
|
||||
if let Some(value) = headers.get(cot::http::header::ACCEPT_LANGUAGE) {
|
||||
if let Ok(s) = value.to_str() {
|
||||
if let Some(lang) = parse_accept_language(s) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Default.
|
||||
Lang::En
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I18n extractor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct I18n {
|
||||
pub lang: Lang,
|
||||
pub t: &'static Translations,
|
||||
}
|
||||
|
||||
impl FromRequestHead for I18n {
|
||||
async fn from_request_head(head: &RequestHead) -> cot::Result<Self> {
|
||||
let lang = resolve_lang(&head.headers);
|
||||
Ok(I18n {
|
||||
lang,
|
||||
t: Translations::for_lang(lang),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lang_roundtrip() {
|
||||
assert_eq!(Lang::from_code("en"), Some(Lang::En));
|
||||
assert_eq!(Lang::from_code("ru"), Some(Lang::Ru));
|
||||
assert_eq!(Lang::from_code("de"), None);
|
||||
assert_eq!(Lang::En.code(), "en");
|
||||
assert_eq!(Lang::Ru.code(), "ru");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_simple_accept_language() {
|
||||
assert_eq!(parse_accept_language("ru"), Some(Lang::Ru));
|
||||
assert_eq!(parse_accept_language("en-US"), Some(Lang::En));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_weighted_accept_language() {
|
||||
assert_eq!(
|
||||
parse_accept_language("en-US,en;q=0.9,ru;q=0.8"),
|
||||
Some(Lang::En)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_accept_language("ru-RU,ru;q=0.9,en;q=0.5"),
|
||||
Some(Lang::Ru)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unknown_falls_through() {
|
||||
assert_eq!(
|
||||
parse_accept_language("de;q=1.0,ru;q=0.5"),
|
||||
Some(Lang::Ru)
|
||||
);
|
||||
assert_eq!(parse_accept_language("de,fr,ja"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_parsing() {
|
||||
let mut headers = cot::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
cot::http::header::COOKIE,
|
||||
"other=x; furu_lang=ru; foo=bar".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(lang_from_cookie(&headers), Some(Lang::Ru));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_missing() {
|
||||
let headers = cot::http::HeaderMap::new();
|
||||
assert_eq!(lang_from_cookie(&headers), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use super::translations;
|
||||
|
||||
translations! {
|
||||
// Global
|
||||
site_name: "furumusic" , "furumusic";
|
||||
|
||||
// Navigation / sidebar
|
||||
nav_admin: "admin" , "админка";
|
||||
nav_dashboard: "Dashboard" , "Панель управления";
|
||||
nav_debug: "Debug" , "Отладка";
|
||||
|
||||
// Index page
|
||||
index_heading: "furumusic" , "furumusic";
|
||||
index_status: "server is running" , "сервер запущен";
|
||||
|
||||
// Admin index
|
||||
admin_heading: "Admin" , "Админка";
|
||||
admin_debug_link: "Debug info" , "Отладочная информация";
|
||||
|
||||
// Debug page
|
||||
debug_heading: "Debug Information" , "Отладочная информация";
|
||||
debug_build_info: "Build Info" , "Информация о сборке";
|
||||
debug_app_config: "App Config" , "Конфигурация";
|
||||
debug_field: "Field" , "Поле";
|
||||
debug_value: "Value" , "Значение";
|
||||
debug_source: "Source" , "Источник";
|
||||
|
||||
// Navigation (settings)
|
||||
nav_settings: "Settings" , "Настройки";
|
||||
|
||||
// Debug page — DB status
|
||||
debug_db_status: "Database" , "База данных";
|
||||
debug_db_connected: "connected" , "подключена";
|
||||
debug_db_error: "error" , "ошибка";
|
||||
|
||||
// Settings page
|
||||
settings_heading: "Settings" , "Настройки";
|
||||
settings_oidc: "OIDC Configuration" , "Настройки OIDC";
|
||||
settings_save: "Save" , "Сохранить";
|
||||
settings_saved: "Settings saved." , "Настройки сохранены.";
|
||||
|
||||
// Auth settings
|
||||
settings_auth: "Authentication" , "Аутентификация";
|
||||
settings_password_login: "Password login" , "Вход по паролю";
|
||||
settings_sso_login: "SSO login" , "Вход через SSO";
|
||||
settings_oidc_button: "SSO button text" , "Текст кнопки SSO";
|
||||
|
||||
// Login page
|
||||
login_heading: "Sign in" , "Вход";
|
||||
login_username: "Username" , "Имя пользователя";
|
||||
login_password: "Password" , "Пароль";
|
||||
login_submit: "Sign in" , "Войти";
|
||||
login_disabled: "Login is currently disabled." , "Вход сейчас отключён.";
|
||||
login_invalid: "Invalid username or password." , "Неверное имя пользователя или пароль.";
|
||||
|
||||
// Logout
|
||||
nav_logout: "Logout" , "Выход";
|
||||
|
||||
// Setup page
|
||||
setup_heading: "Create Admin Account" , "Создание аккаунта администратора";
|
||||
setup_username: "Username" , "Имя пользователя";
|
||||
setup_password: "Password" , "Пароль";
|
||||
setup_confirm: "Confirm password" , "Подтверждение пароля";
|
||||
setup_submit: "Create" , "Создать";
|
||||
setup_mismatch: "Passwords do not match." , "Пароли не совпадают.";
|
||||
|
||||
// OIDC help
|
||||
settings_oidc_help: "Register this application with your identity provider. Use the callback URL shown below as the Redirect URI." , "Зарегистрируйте это приложение у вашего провайдера идентификации. Используйте указанный ниже callback URL в качестве Redirect URI.";
|
||||
settings_oidc_callback: "Callback URL" , "Callback URL";
|
||||
settings_oidc_issuer_help: "Base URL of the OIDC provider (e.g. https://accounts.google.com)" , "Базовый URL провайдера OIDC (напр. https://accounts.google.com)";
|
||||
settings_oidc_admin_groups: "Admin groups" , "Группы администраторов";
|
||||
settings_oidc_admin_groups_help: "Comma-separated OIDC group names that grant admin role (e.g. /admin,/furumusic-admins)" , "OIDC группы через запятую, дающие роль администратора (напр. /admin,/furumusic-admins)";
|
||||
|
||||
// User management
|
||||
nav_users: "Users" , "Пользователи";
|
||||
users_heading: "Users" , "Пользователи";
|
||||
users_add: "Add user" , "Добавить пользователя";
|
||||
users_username: "Username" , "Имя пользователя";
|
||||
users_email: "Email" , "Email";
|
||||
users_display_name: "Display name" , "Отображаемое имя";
|
||||
users_role: "Role" , "Роль";
|
||||
users_active: "Active" , "Активен";
|
||||
users_actions: "Actions" , "Действия";
|
||||
users_edit: "Edit" , "Редактировать";
|
||||
users_delete: "Delete" , "Удалить";
|
||||
users_delete_confirm: "Are you sure?" , "Вы уверены?";
|
||||
users_new_heading: "New user" , "Новый пользователь";
|
||||
users_edit_heading: "Edit user" , "Редактирование пользователя";
|
||||
users_password_hint: "Leave blank to keep current" , "Оставьте пустым, чтобы не менять";
|
||||
users_saved: "User saved." , "Пользователь сохранён.";
|
||||
|
||||
// OIDC login errors
|
||||
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
|
||||
login_sso_disabled: "SSO login is not configured." , "Вход через SSO не настроен.";
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
mod admin;
|
||||
mod api;
|
||||
mod auth;
|
||||
mod config;
|
||||
mod i18n;
|
||||
mod oidc;
|
||||
mod user;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cot::auth::PasswordVerificationResult;
|
||||
use cot::cli::CliMetadata;
|
||||
use cot::common_types::Password;
|
||||
use cot::config::{
|
||||
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig,
|
||||
SessionStoreTypeConfig,
|
||||
};
|
||||
use cot::db::Database;
|
||||
use cot::form::{Form, FormResult};
|
||||
use cot::html::Html;
|
||||
use cot::middleware::SessionMiddleware;
|
||||
use cot::project::RegisterAppsContext;
|
||||
use cot::request::extractors::{RequestForm, UrlQuery};
|
||||
use cot::response::IntoResponse;
|
||||
use cot::router::method::get;
|
||||
use cot::router::{Route, Router};
|
||||
use cot::session::Session;
|
||||
use cot::{App, AppBuilder, Body, Project, Template};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::i18n::{I18n, Translations};
|
||||
use crate::user::User;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn index(
|
||||
session: Session,
|
||||
db: Database,
|
||||
i18n: I18n,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let user = match auth::get_session_user(&session, &db).await {
|
||||
Some(u) => u,
|
||||
None => return Ok(auth::redirect("/login")),
|
||||
};
|
||||
let role_label = match user.role {
|
||||
auth::Role::Admin => format!(
|
||||
r#"{} | <a href="/admin/">{}</a>"#,
|
||||
user.role.code(),
|
||||
i18n.t.nav_admin
|
||||
),
|
||||
_ => user.role.code().to_owned(),
|
||||
};
|
||||
Html::new(format!(
|
||||
"<h1>{}</h1><p>{}</p><p>{}: {}</p>",
|
||||
i18n.t.index_heading, i18n.t.index_status, user.name, role_label
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetLangQuery {
|
||||
lang: String,
|
||||
next: Option<String>,
|
||||
}
|
||||
|
||||
async fn set_lang(
|
||||
UrlQuery(query): UrlQuery<SetLangQuery>,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
let lang = i18n::Lang::from_code(&query.lang).unwrap_or(i18n::Lang::En);
|
||||
let next = query.next.as_deref().unwrap_or("/");
|
||||
|
||||
let response = cot::http::Response::builder()
|
||||
.status(cot::http::StatusCode::SEE_OTHER)
|
||||
.header(cot::http::header::LOCATION, next)
|
||||
.header(cot::http::header::SET_COOKIE, i18n::lang_cookie(lang))
|
||||
.body(Body::fixed(""))
|
||||
.expect("valid response");
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "login.html")]
|
||||
struct LoginTemplate {
|
||||
t: &'static Translations,
|
||||
auth_password_enabled: bool,
|
||||
auth_sso_enabled: bool,
|
||||
oidc_button_text: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn login_page_handler(
|
||||
i18n: I18n,
|
||||
_startup_config: &AppConfig,
|
||||
db: Database,
|
||||
message: String,
|
||||
) -> cot::Result<Html> {
|
||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||
let template = LoginTemplate {
|
||||
t: i18n.t,
|
||||
auth_password_enabled: config.auth_password_enabled,
|
||||
auth_sso_enabled: config.auth_sso_enabled,
|
||||
oidc_button_text: config.oidc_button_text,
|
||||
message,
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Form)]
|
||||
struct LoginForm {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn logout_handler(session: Session) -> cot::Result<cot::response::Response> {
|
||||
auth::logout(&session).await?;
|
||||
Ok(auth::redirect("/login"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct FuruApp {
|
||||
config: Arc<AppConfig>,
|
||||
}
|
||||
|
||||
impl App for FuruApp {
|
||||
fn name(&self) -> &'static str {
|
||||
env!("CARGO_PKG_NAME")
|
||||
}
|
||||
|
||||
fn router(&self) -> Router {
|
||||
Router::with_urls([
|
||||
Route::with_handler_and_name(
|
||||
"/admin",
|
||||
get(|| async { Ok::<_, cot::Error>(auth::redirect("/admin/")) }),
|
||||
"admin_redirect",
|
||||
),
|
||||
Route::with_handler_and_name("/", index, "index"),
|
||||
Route::with_handler_and_name(
|
||||
"/login",
|
||||
get({
|
||||
let config = Arc::clone(&self.config);
|
||||
move |i18n: I18n, db: Database| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
// No users at all → redirect to first-run setup
|
||||
if User::count_all(&db).await.unwrap_or(0) == 0 {
|
||||
return Ok(auth::redirect("/admin/setup"));
|
||||
}
|
||||
login_page_handler(i18n, &config, db, String::new())
|
||||
.await?
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}).post({
|
||||
let config = Arc::clone(&self.config);
|
||||
move |i18n: I18n, db: Database, session: Session,
|
||||
form: RequestForm<LoginForm>| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
let RequestForm(result) = form;
|
||||
let data = match result {
|
||||
FormResult::Ok(data) => data,
|
||||
FormResult::ValidationError(_) => {
|
||||
let msg = i18n.t.login_invalid.to_owned();
|
||||
return login_page_handler(i18n, &config, db, msg)
|
||||
.await?
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Try to authenticate
|
||||
if let Ok(Some(user)) =
|
||||
User::get_by_username(&db, &data.username).await
|
||||
{
|
||||
if let Some(hash) = user.password_ref() {
|
||||
let password = Password::new(&data.password);
|
||||
match hash.verify(&password) {
|
||||
PasswordVerificationResult::Ok
|
||||
| PasswordVerificationResult::OkObsolete(_) => {
|
||||
auth::login(&session, user.id_val()).await?;
|
||||
return Ok(auth::redirect("/"));
|
||||
}
|
||||
PasswordVerificationResult::Invalid => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let msg = i18n.t.login_invalid.to_owned();
|
||||
login_page_handler(i18n, &config, db, msg)
|
||||
.await?
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}),
|
||||
"login",
|
||||
),
|
||||
Route::with_handler_and_name("/logout", get(logout_handler), "logout"),
|
||||
Route::with_handler_and_name("/set-lang", set_lang, "set_lang"),
|
||||
Route::with_handler_and_name(
|
||||
"/auth/oidc/start",
|
||||
get(oidc::oidc_start_handler),
|
||||
"oidc_start",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/auth/oidc/callback",
|
||||
get(oidc::oidc_callback_handler),
|
||||
"oidc_callback",
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct FuruProject {
|
||||
app_config: Arc<AppConfig>,
|
||||
}
|
||||
|
||||
impl Project for FuruProject {
|
||||
fn cli_metadata(&self) -> CliMetadata {
|
||||
CliMetadata {
|
||||
description: concat!(
|
||||
env!("CARGO_PKG_DESCRIPTION"),
|
||||
"\n\n",
|
||||
"CONFIGURATION\n",
|
||||
" All settings are available as FURU_-prefixed environment variables.\n",
|
||||
" Priority: env var > DB override > compiled default.\n",
|
||||
"\n",
|
||||
" Database (required for most features):\n",
|
||||
" FURU_DATABASE_URL PostgreSQL connection URL\n",
|
||||
" Example: postgres://user:pass@localhost/furumusic\n",
|
||||
"\n",
|
||||
" Server:\n",
|
||||
" FURU_LOG_LEVEL Tracing filter (default: info)\n",
|
||||
"\n",
|
||||
" Authentication:\n",
|
||||
" FURU_AUTH_PASSWORD_ENABLED Enable password login (default: true)\n",
|
||||
" FURU_AUTH_SSO_ENABLED Enable SSO/OIDC login (default: false)\n",
|
||||
" FURU_OIDC_ISSUER OIDC issuer URL\n",
|
||||
" FURU_OIDC_CLIENT_ID OIDC client ID\n",
|
||||
" FURU_OIDC_CLIENT_SECRET OIDC client secret\n",
|
||||
" FURU_OIDC_BUTTON_TEXT SSO button label (default: Sign in with SSO)\n",
|
||||
" FURU_OIDC_ADMIN_GROUPS OIDC groups that grant admin role\n",
|
||||
"\n",
|
||||
"QUICK START\n",
|
||||
" export FURU_DATABASE_URL=postgres://user:pass@localhost/furumusic\n",
|
||||
" furumusic run",
|
||||
),
|
||||
..cot::cli::metadata!()
|
||||
}
|
||||
}
|
||||
|
||||
fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
|
||||
let mut builder = ProjectConfig::builder();
|
||||
builder.debug(cfg!(debug_assertions));
|
||||
|
||||
if !self.app_config.database_url.is_empty() {
|
||||
builder.database(
|
||||
DatabaseConfig::builder()
|
||||
.url(self.app_config.database_url.as_str())
|
||||
.build(),
|
||||
);
|
||||
builder.middlewares(
|
||||
MiddlewareConfig::builder()
|
||||
.session(
|
||||
SessionMiddlewareConfig::builder()
|
||||
.store(
|
||||
SessionStoreConfig::builder()
|
||||
.store_type(SessionStoreTypeConfig::Database)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(builder.build())
|
||||
}
|
||||
|
||||
fn middlewares(
|
||||
&self,
|
||||
handler: cot::project::RootHandlerBuilder,
|
||||
context: &cot::project::MiddlewareContext,
|
||||
) -> cot::project::RootHandler {
|
||||
handler
|
||||
.middleware(
|
||||
SessionMiddleware::from_context(context)
|
||||
.same_site(cot::config::SameSite::Lax),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) {
|
||||
apps.register(cot::session::db::SessionApp::new());
|
||||
apps.register_with_views(
|
||||
FuruApp {
|
||||
config: Arc::clone(&self.app_config),
|
||||
},
|
||||
"",
|
||||
);
|
||||
apps.register_with_views(
|
||||
admin::AdminApp::new(Arc::clone(&self.app_config)),
|
||||
"/admin",
|
||||
);
|
||||
apps.register_with_views(api::ApiApp, "/api");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entrypoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cot::main]
|
||||
fn main() -> impl Project {
|
||||
let app_config = Arc::new(AppConfig::load());
|
||||
|
||||
// Initialise tracing subscriber with the configured log level.
|
||||
// FURU_LOG_LEVEL (or the default "info") is parsed as an EnvFilter
|
||||
// directive, so values like "debug", "warn,furumusic=trace" all work.
|
||||
let filter = tracing_subscriber::EnvFilter::try_new(&app_config.log_level)
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"WARNING: invalid FURU_LOG_LEVEL {:?}: {e}; falling back to \"info\"",
|
||||
app_config.log_level,
|
||||
);
|
||||
tracing_subscriber::EnvFilter::new("info")
|
||||
});
|
||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||
|
||||
tracing::info!("loaded config: {:?}", app_config);
|
||||
|
||||
FuruProject { app_config }
|
||||
}
|
||||
+578
@@ -0,0 +1,578 @@
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Instant;
|
||||
|
||||
use cot::db::Database;
|
||||
use cot::session::Session;
|
||||
use openidconnect::core::{CoreClient, CoreProviderMetadata};
|
||||
use openidconnect::{
|
||||
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
|
||||
EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
|
||||
};
|
||||
|
||||
use cot::request::RequestHead;
|
||||
use cot::request::extractors::FromRequestHead;
|
||||
|
||||
use crate::auth;
|
||||
use crate::config::AppConfig;
|
||||
use crate::i18n::I18n;
|
||||
use crate::user::{OidcLink, User};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request origin extractor (scheme + host from headers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extracts the origin (e.g. "http://127.0.0.1:3001") from the request so we
|
||||
/// can build the correct OIDC redirect URI.
|
||||
pub struct RequestOrigin(pub String);
|
||||
|
||||
impl FromRequestHead for RequestOrigin {
|
||||
async fn from_request_head(head: &RequestHead) -> cot::Result<Self> {
|
||||
let scheme = head
|
||||
.headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("http");
|
||||
|
||||
let host = head
|
||||
.headers
|
||||
.get(cot::http::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("localhost");
|
||||
|
||||
Ok(RequestOrigin(format!("{scheme}://{host}")))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session keys for OIDC flow state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SESSION_CSRF_STATE: &str = "oidc_csrf_state";
|
||||
const SESSION_NONCE: &str = "oidc_nonce";
|
||||
const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier";
|
||||
const SESSION_REDIRECT_URI: &str = "oidc_redirect_uri";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Concrete client type returned by `from_provider_metadata` + `set_redirect_uri`.
|
||||
/// The provider metadata discovery sets auth URL to EndpointSet, and token/userinfo
|
||||
/// endpoints to EndpointMaybeSet. The remaining endpoints stay EndpointNotSet.
|
||||
type ConfiguredClient = CoreClient<
|
||||
EndpointSet,
|
||||
EndpointNotSet,
|
||||
EndpointNotSet,
|
||||
EndpointNotSet,
|
||||
EndpointMaybeSet,
|
||||
EndpointMaybeSet,
|
||||
>;
|
||||
|
||||
struct CachedProvider {
|
||||
client: ConfiguredClient,
|
||||
fetched_at: Instant,
|
||||
config_hash: u64,
|
||||
}
|
||||
|
||||
static PROVIDER_CACHE: LazyLock<tokio::sync::RwLock<Option<CachedProvider>>> =
|
||||
LazyLock::new(|| tokio::sync::RwLock::new(None));
|
||||
|
||||
/// TTL for cached provider metadata (1 hour).
|
||||
const PROVIDER_TTL_SECS: u64 = 3600;
|
||||
|
||||
/// Compute a hash of the OIDC configuration values so we can detect changes.
|
||||
fn config_hash(issuer: &str, client_id: &str, client_secret: &str) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
issuer.hash(&mut hasher);
|
||||
client_id.hash(&mut hasher);
|
||||
client_secret.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn oidc_http_client() -> reqwest::Client {
|
||||
reqwest::ClientBuilder::new()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("valid reqwest client")
|
||||
}
|
||||
|
||||
/// Get or refresh the cached OIDC provider. Returns a cloned `ConfiguredClient`.
|
||||
async fn get_or_refresh_provider(
|
||||
config: &AppConfig,
|
||||
http: &reqwest::Client,
|
||||
) -> Result<ConfiguredClient, String> {
|
||||
let hash = config_hash(
|
||||
&config.oidc_issuer,
|
||||
&config.oidc_client_id,
|
||||
&config.oidc_client_secret,
|
||||
);
|
||||
|
||||
// Fast path: check if we have a valid cached provider.
|
||||
{
|
||||
let cache = PROVIDER_CACHE.read().await;
|
||||
if let Some(ref cached) = *cache {
|
||||
if cached.config_hash == hash
|
||||
&& cached.fetched_at.elapsed().as_secs() < PROVIDER_TTL_SECS
|
||||
{
|
||||
return Ok(cached.client.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: discover provider metadata + JWKS.
|
||||
// Strip /.well-known/openid-configuration suffix if the user pasted the
|
||||
// full discovery URL, so discover_async doesn't double-append it.
|
||||
let issuer = config
|
||||
.oidc_issuer
|
||||
.trim_end_matches('/')
|
||||
.strip_suffix("/.well-known/openid-configuration")
|
||||
.unwrap_or(config.oidc_issuer.trim_end_matches('/'))
|
||||
.to_owned();
|
||||
|
||||
let issuer_url = IssuerUrl::new(issuer)
|
||||
.map_err(|e| format!("invalid issuer URL: {e}"))?;
|
||||
|
||||
let metadata = CoreProviderMetadata::discover_async(issuer_url, http)
|
||||
.await
|
||||
.map_err(|e| format!("OIDC discovery failed: {e}"))?;
|
||||
|
||||
let client = CoreClient::from_provider_metadata(
|
||||
metadata,
|
||||
ClientId::new(config.oidc_client_id.clone()),
|
||||
Some(ClientSecret::new(config.oidc_client_secret.clone())),
|
||||
);
|
||||
|
||||
let mut cache = PROVIDER_CACHE.write().await;
|
||||
*cache = Some(CachedProvider {
|
||||
client: client.clone(),
|
||||
fetched_at: Instant::now(),
|
||||
config_hash: hash,
|
||||
});
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/oidc/start
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn oidc_start_handler(
|
||||
origin: RequestOrigin,
|
||||
i18n: I18n,
|
||||
db: Database,
|
||||
session: Session,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||
|
||||
// Validate SSO is enabled and configured.
|
||||
if !config.auth_sso_enabled
|
||||
|| config.oidc_issuer.is_empty()
|
||||
|| config.oidc_client_id.is_empty()
|
||||
|| config.oidc_client_secret.is_empty()
|
||||
{
|
||||
tracing::warn!("OIDC start requested but SSO is not configured");
|
||||
return redirect_login_with_error(i18n.t.login_sso_disabled);
|
||||
}
|
||||
|
||||
let http = oidc_http_client();
|
||||
let client = match get_or_refresh_provider(&config, &http).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC provider error: {e}");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
|
||||
// Build redirect URI from the actual request origin.
|
||||
let redirect_uri_str = format!("{}/auth/oidc/callback", origin.0);
|
||||
let redirect_url = RedirectUrl::new(redirect_uri_str.clone())
|
||||
.map_err(|e| cot::Error::internal(format!("bad redirect URI: {e}")))?;
|
||||
let client = client.set_redirect_uri(redirect_url);
|
||||
|
||||
// Build PKCE challenge.
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
// Build authorization URL.
|
||||
// The openid scope is added automatically by the crate; only add email + profile.
|
||||
let (auth_url, csrf_state, nonce) = client
|
||||
.authorize_url(
|
||||
openidconnect::AuthenticationFlow::<openidconnect::core::CoreResponseType>::AuthorizationCode,
|
||||
CsrfToken::new_random,
|
||||
Nonce::new_random,
|
||||
)
|
||||
.add_scope(Scope::new("email".to_string()))
|
||||
.add_scope(Scope::new("profile".to_string()))
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.url();
|
||||
|
||||
// Store OIDC flow state in the session.
|
||||
session
|
||||
.insert(SESSION_CSRF_STATE, csrf_state.secret().clone())
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
session
|
||||
.insert(SESSION_NONCE, nonce.secret().clone())
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
session
|
||||
.insert(SESSION_PKCE_VERIFIER, pkce_verifier.secret().clone())
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
session
|
||||
.insert(SESSION_REDIRECT_URI, redirect_uri_str)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
Ok(auth::redirect(auth_url.as_str()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/oidc/callback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct OidcCallbackQuery {
|
||||
code: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
pub async fn oidc_callback_handler(
|
||||
i18n: I18n,
|
||||
db: Database,
|
||||
session: Session,
|
||||
cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery<OidcCallbackQuery>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||
|
||||
// Retrieve OIDC flow state from the session.
|
||||
let saved_csrf: Option<String> = session
|
||||
.get(SESSION_CSRF_STATE)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let saved_nonce: Option<String> = session
|
||||
.get(SESSION_NONCE)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let saved_pkce: Option<String> = session
|
||||
.get(SESSION_PKCE_VERIFIER)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let saved_redirect_uri: Option<String> = session
|
||||
.get(SESSION_REDIRECT_URI)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
// Validate CSRF state.
|
||||
let Some(saved_csrf) = saved_csrf else {
|
||||
tracing::warn!("OIDC callback: no CSRF state in session");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
};
|
||||
if query.state != saved_csrf {
|
||||
tracing::warn!("OIDC callback: CSRF state mismatch");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
|
||||
let Some(nonce_str) = saved_nonce else {
|
||||
tracing::warn!("OIDC callback: no nonce in session");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
};
|
||||
let Some(pkce_str) = saved_pkce else {
|
||||
tracing::warn!("OIDC callback: no PKCE verifier in session");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
};
|
||||
|
||||
let nonce = Nonce::new(nonce_str);
|
||||
let pkce_verifier = PkceCodeVerifier::new(pkce_str);
|
||||
|
||||
let http = oidc_http_client();
|
||||
let client = match get_or_refresh_provider(&config, &http).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC provider error during callback: {e}");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
|
||||
// Restore the redirect URI that was used in the authorization request.
|
||||
let client = if let Some(ref uri) = saved_redirect_uri {
|
||||
let redirect_url = RedirectUrl::new(uri.clone())
|
||||
.map_err(|e| cot::Error::internal(format!("bad redirect URI from session: {e}")))?;
|
||||
client.set_redirect_uri(redirect_url)
|
||||
} else {
|
||||
client
|
||||
};
|
||||
|
||||
// Exchange code for tokens.
|
||||
let token_request = match client
|
||||
.exchange_code(AuthorizationCode::new(query.code.clone()))
|
||||
{
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC token endpoint not configured: {e}");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
let token_response = token_request
|
||||
.set_pkce_verifier(pkce_verifier)
|
||||
.request_async(&http)
|
||||
.await;
|
||||
|
||||
let token_response = match token_response {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC token exchange failed: {e}");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
|
||||
// Verify and extract ID token claims.
|
||||
use openidconnect::TokenResponse;
|
||||
let id_token = match token_response.id_token() {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
tracing::error!("OIDC response missing ID token");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
|
||||
let claims = match id_token.claims(&client.id_token_verifier(), &nonce) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC ID token verification failed: {e}");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
|
||||
let sub = claims.subject().to_string();
|
||||
let issuer = claims.issuer().to_string();
|
||||
let email = claims.email().map(|e| e.to_string());
|
||||
let name = claims
|
||||
.name()
|
||||
.and_then(|n| n.get(None))
|
||||
.map(|n| n.to_string());
|
||||
|
||||
// Extract groups from the raw JWT payload (second dot-separated segment).
|
||||
// The token is already signature-verified above, so we only need to decode
|
||||
// the payload to read the non-standard `groups` claim.
|
||||
let groups: Vec<String> = (|| {
|
||||
use base64::Engine;
|
||||
let raw = id_token.to_string();
|
||||
let payload_b64 = raw.split('.').nth(1)?;
|
||||
// JWT payloads use URL-safe base64; try without padding first, then
|
||||
// fall back to the padded variant (some providers add trailing '=').
|
||||
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload_b64)
|
||||
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64))
|
||||
.ok()?;
|
||||
let value: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||||
let arr = value.get("groups")?.as_array()?;
|
||||
Some(
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect(),
|
||||
)
|
||||
})()
|
||||
.unwrap_or_default();
|
||||
|
||||
tracing::info!(
|
||||
"OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}",
|
||||
config.oidc_admin_groups,
|
||||
);
|
||||
|
||||
// User provisioning logic.
|
||||
let user = match provision_user(
|
||||
&db,
|
||||
&issuer,
|
||||
&sub,
|
||||
email.as_deref(),
|
||||
name.as_deref(),
|
||||
&groups,
|
||||
&config.oidc_admin_groups,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC user provisioning failed: {e}");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
|
||||
// Log the user in.
|
||||
auth::login(&session, user.id_val()).await?;
|
||||
|
||||
// Clear OIDC session keys.
|
||||
let _: Option<String> = session
|
||||
.remove(SESSION_CSRF_STATE)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let _: Option<String> = session
|
||||
.remove(SESSION_NONCE)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let _: Option<String> = session
|
||||
.remove(SESSION_PKCE_VERIFIER)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let _: Option<String> = session
|
||||
.remove(SESSION_REDIRECT_URI)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
Ok(auth::redirect("/"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User provisioning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve the role based on OIDC group membership.
|
||||
/// If `admin_groups` is non-empty and any user group matches, return "admin";
|
||||
/// otherwise return "user".
|
||||
fn resolve_role(groups: &[String], admin_groups: &str) -> &'static str {
|
||||
if admin_groups.is_empty() {
|
||||
return auth::Role::User.code();
|
||||
}
|
||||
let admin_set: std::collections::HashSet<&str> = admin_groups
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if admin_set.is_empty() {
|
||||
return auth::Role::User.code();
|
||||
}
|
||||
for g in groups {
|
||||
if admin_set.contains(g.as_str()) {
|
||||
return auth::Role::Admin.code();
|
||||
}
|
||||
}
|
||||
auth::Role::User.code()
|
||||
}
|
||||
|
||||
async fn provision_user(
|
||||
db: &Database,
|
||||
issuer: &str,
|
||||
sub: &str,
|
||||
email: Option<&str>,
|
||||
name: Option<&str>,
|
||||
groups: &[String],
|
||||
admin_groups: &str,
|
||||
) -> Result<User, String> {
|
||||
let role = resolve_role(groups, admin_groups);
|
||||
|
||||
// 1. Check for existing OIDC link.
|
||||
if let Some(mut link) = OidcLink::find_by_issuer_sub(db, issuer, sub)
|
||||
.await
|
||||
.map_err(|e| format!("DB error finding OIDC link: {e}"))?
|
||||
{
|
||||
// Fetch the linked user.
|
||||
match User::get_by_id(db, link.user_id()).await {
|
||||
Ok(Some(mut user)) => {
|
||||
// Update cached claims.
|
||||
link.update_claims(db, email, name)
|
||||
.await
|
||||
.map_err(|e| format!("DB error updating OIDC link: {e}"))?;
|
||||
|
||||
// Always update role on login.
|
||||
user.update_role(db, role)
|
||||
.await
|
||||
.map_err(|e| format!("DB error updating user role: {e}"))?;
|
||||
|
||||
return Ok(user);
|
||||
}
|
||||
Ok(None) => {
|
||||
// User was deleted but the OIDC link is stale — remove it
|
||||
// and fall through to re-create the user below.
|
||||
tracing::warn!(
|
||||
"OIDC link points to deleted user {}; removing stale link",
|
||||
link.user_id(),
|
||||
);
|
||||
link.delete(db)
|
||||
.await
|
||||
.map_err(|e| format!("DB error deleting stale OIDC link: {e}"))?;
|
||||
}
|
||||
Err(e) => return Err(format!("DB error fetching user: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// 2. No existing link — try to find a user by email.
|
||||
if let Some(email_str) = email {
|
||||
if let Some(mut user) = User::get_by_email(db, email_str)
|
||||
.await
|
||||
.map_err(|e| format!("DB error finding user by email: {e}"))?
|
||||
{
|
||||
// Create OIDC link for existing user.
|
||||
OidcLink::create_link(db, user.id_val(), issuer, sub, email, name)
|
||||
.await
|
||||
.map_err(|e| format!("DB error creating OIDC link: {e}"))?;
|
||||
|
||||
user.update_role(db, role)
|
||||
.await
|
||||
.map_err(|e| format!("DB error updating user role: {e}"))?;
|
||||
|
||||
return Ok(user);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create a brand-new user + OIDC link.
|
||||
// Generate a unique username from the sub or email.
|
||||
let username = if let Some(email_str) = email {
|
||||
email_str.split('@').next().unwrap_or(sub).to_owned()
|
||||
} else {
|
||||
sub.to_owned()
|
||||
};
|
||||
|
||||
// Ensure username uniqueness by appending a suffix if needed.
|
||||
let mut candidate = username.clone();
|
||||
let mut suffix = 0u32;
|
||||
loop {
|
||||
match User::get_by_username(db, &candidate).await {
|
||||
Ok(None) => break,
|
||||
Ok(Some(_)) => {
|
||||
suffix += 1;
|
||||
candidate = format!("{username}_{suffix}");
|
||||
}
|
||||
Err(e) => return Err(format!("DB error checking username: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
let user = User::create_oidc(db, &candidate, email, name, role)
|
||||
.await
|
||||
.map_err(|e| format!("DB error creating user: {e}"))?;
|
||||
|
||||
OidcLink::create_link(db, user.id_val(), issuer, sub, email, name)
|
||||
.await
|
||||
.map_err(|e| format!("DB error creating OIDC link: {e}"))?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn redirect_login_with_error(message: &str) -> cot::Result<cot::response::Response> {
|
||||
let encoded = urlencoded(message);
|
||||
Ok(auth::redirect(&format!("/login?error={encoded}")))
|
||||
}
|
||||
|
||||
/// Minimal percent-encoding for query parameter values.
|
||||
fn urlencoded(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() * 2);
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
_ => {
|
||||
out.push('%');
|
||||
out.push_str(&format!("{b:02X}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
use cot::auth::PasswordHash;
|
||||
use cot::common_types::Password;
|
||||
use cot::db::{Auto, Database, LimitedString, Model};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cot::db::model]
|
||||
pub struct User {
|
||||
#[model(primary_key)]
|
||||
id: Auto<i64>,
|
||||
#[model(unique)]
|
||||
username: LimitedString<255>,
|
||||
password: Option<PasswordHash>,
|
||||
email: Option<LimitedString<255>>,
|
||||
display_name: Option<LimitedString<255>>,
|
||||
avatar_url: Option<String>,
|
||||
role: LimitedString<32>,
|
||||
is_active: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User helper methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl User {
|
||||
/// List all users.
|
||||
pub async fn list_all(db: &Database) -> cot::db::Result<Vec<Self>> {
|
||||
Self::objects().all(db).await
|
||||
}
|
||||
|
||||
/// Get a user by primary key.
|
||||
pub async fn get_by_id(db: &Database, user_id: i64) -> cot::db::Result<Option<Self>> {
|
||||
Self::get_by_primary_key(db, Auto::Fixed(user_id)).await
|
||||
}
|
||||
|
||||
/// Create a new user and insert it into the database.
|
||||
pub async fn create(
|
||||
db: &Database,
|
||||
username: &str,
|
||||
email: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
password: &str,
|
||||
role: &str,
|
||||
) -> cot::db::Result<Self> {
|
||||
let hash = PasswordHash::from_password(&Password::new(password));
|
||||
let mut user = Self {
|
||||
id: Auto::auto(),
|
||||
username: LimitedString::new(username).unwrap(),
|
||||
password: Some(hash),
|
||||
email: email.map(|e| LimitedString::new(e).unwrap()),
|
||||
display_name: display_name.map(|d| LimitedString::new(d).unwrap()),
|
||||
avatar_url: None,
|
||||
role: LimitedString::new(role).unwrap(),
|
||||
is_active: true,
|
||||
};
|
||||
user.insert(db).await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Update an existing user. If `new_password` is `Some`, the password hash
|
||||
/// is replaced; otherwise the existing hash is kept.
|
||||
pub async fn update_fields(
|
||||
&mut self,
|
||||
db: &Database,
|
||||
username: &str,
|
||||
email: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
new_password: Option<&str>,
|
||||
role: &str,
|
||||
) -> cot::db::Result<()> {
|
||||
self.username = LimitedString::new(username).unwrap();
|
||||
self.email = email.map(|e| LimitedString::new(e).unwrap());
|
||||
self.display_name = display_name.map(|d| LimitedString::new(d).unwrap());
|
||||
if let Some(pw) = new_password {
|
||||
self.password = Some(PasswordHash::from_password(&Password::new(pw)));
|
||||
}
|
||||
self.role = LimitedString::new(role).unwrap();
|
||||
self.save(db).await
|
||||
}
|
||||
|
||||
/// Look up a user by username.
|
||||
pub async fn get_by_username(db: &Database, username: &str) -> cot::db::Result<Option<Self>> {
|
||||
let Ok(username) = LimitedString::<255>::new(username) else {
|
||||
return Ok(None);
|
||||
};
|
||||
cot::db::query!(User, $username == username).get(db).await
|
||||
}
|
||||
|
||||
/// Count all users in the database.
|
||||
pub async fn count_all(db: &Database) -> cot::db::Result<u64> {
|
||||
Self::objects().count(db).await
|
||||
}
|
||||
|
||||
/// Return a reference to the password hash, if set.
|
||||
pub fn password_ref(&self) -> Option<&PasswordHash> {
|
||||
self.password.as_ref()
|
||||
}
|
||||
|
||||
/// Parse the stored role code into a `Role`, defaulting to `User`.
|
||||
pub fn role(&self) -> crate::auth::Role {
|
||||
crate::auth::Role::from_code(&self.role).unwrap_or(crate::auth::Role::User)
|
||||
}
|
||||
|
||||
/// Delete this user by primary key.
|
||||
pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> {
|
||||
cot::db::query!(User, $id == Auto::Fixed(user_id)).delete(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Accessor helpers for templates
|
||||
pub fn id_val(&self) -> i64 {
|
||||
self.id.unwrap()
|
||||
}
|
||||
pub fn username_str(&self) -> &str {
|
||||
&self.username
|
||||
}
|
||||
pub fn email_str(&self) -> String {
|
||||
self.email.as_ref().map(|e| e.to_string()).unwrap_or_default()
|
||||
}
|
||||
pub fn display_name_str(&self) -> String {
|
||||
self.display_name.as_ref().map(|d| d.to_string()).unwrap_or_default()
|
||||
}
|
||||
pub fn role_str(&self) -> &str {
|
||||
&self.role
|
||||
}
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.is_active
|
||||
}
|
||||
|
||||
/// Create a user without a password (for OIDC-only accounts).
|
||||
pub async fn create_oidc(
|
||||
db: &Database,
|
||||
username: &str,
|
||||
email: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
role: &str,
|
||||
) -> cot::db::Result<Self> {
|
||||
let mut user = Self {
|
||||
id: Auto::auto(),
|
||||
username: LimitedString::new(username).unwrap(),
|
||||
password: None,
|
||||
email: email.map(|e| LimitedString::new(e).unwrap()),
|
||||
display_name: display_name.map(|d| LimitedString::new(d).unwrap()),
|
||||
avatar_url: None,
|
||||
role: LimitedString::new(role).unwrap(),
|
||||
is_active: true,
|
||||
};
|
||||
user.insert(db).await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Update the user's role and persist the change.
|
||||
pub async fn update_role(&mut self, db: &Database, role: &str) -> cot::db::Result<()> {
|
||||
self.role = LimitedString::new(role).unwrap();
|
||||
self.save(db).await
|
||||
}
|
||||
|
||||
/// Find a user by email address.
|
||||
pub async fn get_by_email(db: &Database, email: &str) -> cot::db::Result<Option<Self>> {
|
||||
let Ok(email) = LimitedString::<255>::new(email) else {
|
||||
return Ok(None);
|
||||
};
|
||||
cot::db::query!(User, $email == Some(email)).get(db).await
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OidcLink model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cot::db::model]
|
||||
pub struct OidcLink {
|
||||
#[model(primary_key)]
|
||||
id: Auto<i64>,
|
||||
user_id: i64,
|
||||
issuer: LimitedString<255>,
|
||||
sub: LimitedString<255>,
|
||||
email: Option<LimitedString<255>>,
|
||||
name: Option<LimitedString<255>>,
|
||||
avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OidcLink helper methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl OidcLink {
|
||||
/// Find an OIDC link by issuer + subject.
|
||||
pub async fn find_by_issuer_sub(
|
||||
db: &Database,
|
||||
issuer: &str,
|
||||
sub: &str,
|
||||
) -> cot::db::Result<Option<Self>> {
|
||||
let Ok(issuer) = LimitedString::<255>::new(issuer) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Ok(sub) = LimitedString::<255>::new(sub) else {
|
||||
return Ok(None);
|
||||
};
|
||||
cot::db::query!(OidcLink, $issuer == issuer && $sub == sub)
|
||||
.get(db)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a new OIDC link for a user.
|
||||
pub async fn create_link(
|
||||
db: &Database,
|
||||
user_id: i64,
|
||||
issuer: &str,
|
||||
sub: &str,
|
||||
email: Option<&str>,
|
||||
name: Option<&str>,
|
||||
) -> cot::db::Result<Self> {
|
||||
let mut link = Self {
|
||||
id: Auto::auto(),
|
||||
user_id,
|
||||
issuer: LimitedString::new(issuer).unwrap(),
|
||||
sub: LimitedString::new(sub).unwrap(),
|
||||
email: email.map(|e| LimitedString::new(e).unwrap()),
|
||||
name: name.map(|n| LimitedString::new(n).unwrap()),
|
||||
avatar_url: None,
|
||||
};
|
||||
link.insert(db).await?;
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
/// Update cached claims (email, name) on an existing link.
|
||||
pub async fn update_claims(
|
||||
&mut self,
|
||||
db: &Database,
|
||||
email: Option<&str>,
|
||||
name: Option<&str>,
|
||||
) -> cot::db::Result<()> {
|
||||
self.email = email.map(|e| LimitedString::new(e).unwrap());
|
||||
self.name = name.map(|n| LimitedString::new(n).unwrap());
|
||||
self.save(db).await
|
||||
}
|
||||
|
||||
/// Delete this OIDC link by primary key.
|
||||
pub async fn delete(self, db: &Database) -> cot::db::Result<()> {
|
||||
let link_id = self.id;
|
||||
cot::db::query!(OidcLink, $id == link_id).delete(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accessor for the linked user ID.
|
||||
pub fn user_id(&self) -> i64 {
|
||||
self.user_id
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub mod db_migrations {
|
||||
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
|
||||
use cot::db::{DatabaseField, Identifier, LimitedString};
|
||||
use cot::auth::PasswordHash;
|
||||
|
||||
// -- M0003: create furumusic__user -------------------------------------
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0003CreateUser;
|
||||
|
||||
impl migrations::Migration for M0003CreateUser {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0003_create_user";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0002_rename_config_table",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__user"))
|
||||
.fields(&[
|
||||
Field::new(
|
||||
Identifier::new("id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(
|
||||
Identifier::new("username"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
)
|
||||
.unique(),
|
||||
Field::new(
|
||||
Identifier::new("password"),
|
||||
<PasswordHash as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(
|
||||
Identifier::new("email"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(
|
||||
Identifier::new("display_name"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(
|
||||
Identifier::new("avatar_url"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(
|
||||
Identifier::new("role"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("is_active"),
|
||||
<bool as DatabaseField>::TYPE,
|
||||
),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
|
||||
// -- M0004: create furumusic__oidc_link --------------------------------
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0004CreateOidcLink;
|
||||
|
||||
impl migrations::Migration for M0004CreateOidcLink {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0004_create_oidc_link";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0003_create_user",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__oidc_link"))
|
||||
.fields(&[
|
||||
Field::new(
|
||||
Identifier::new("id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(
|
||||
Identifier::new("user_id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("issuer"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("sub"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("email"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(
|
||||
Identifier::new("name"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(
|
||||
Identifier::new("avatar_url"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
|
||||
// -- M0005: indexes on furumusic__oidc_link ----------------------------
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn create_oidc_link_indexes(
|
||||
ctx: migrations::MigrationContext<'_>,
|
||||
) -> cot::db::Result<()> {
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE UNIQUE INDEX idx_oidc_link_issuer_sub \
|
||||
ON furumusic__oidc_link (issuer, sub)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX idx_oidc_link_user_id \
|
||||
ON furumusic__oidc_link (user_id)",
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0005OidcLinkIndexes;
|
||||
|
||||
impl migrations::Migration for M0005OidcLinkIndexes {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0005_oidc_link_indexes";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0004_create_oidc_link",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::custom(create_oidc_link_indexes).build(),
|
||||
];
|
||||
}
|
||||
|
||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||
&M0003CreateUser,
|
||||
&M0004CreateOidcLink,
|
||||
&M0005OidcLinkIndexes,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
|
||||
{% block admin_title %}{{ t.nav_debug }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.cfg-key { position: relative; display: inline-flex; align-items: center; gap: .35rem; }
|
||||
.cfg-info-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: #ccc; color: #fff; font-size: 11px; font-weight: 700;
|
||||
cursor: help; flex-shrink: 0; line-height: 1;
|
||||
}
|
||||
.cfg-info-btn:hover { background: #888; }
|
||||
.cfg-popup {
|
||||
position: absolute; left: 100%; top: 50%;
|
||||
transform: translateY(-50%); z-index: 10;
|
||||
background: #1a1a2e; color: #eee; padding: .55rem .75rem;
|
||||
border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,.25);
|
||||
font-size: .8rem; white-space: nowrap; min-width: 220px;
|
||||
margin-left: 8px;
|
||||
opacity: 0; visibility: hidden; pointer-events: none;
|
||||
transition: opacity .15s, visibility 0s .3s;
|
||||
user-select: text;
|
||||
}
|
||||
/* invisible bridge so cursor can travel from icon to popup */
|
||||
.cfg-popup::before {
|
||||
content: ''; position: absolute; right: 100%; top: 0; bottom: 0;
|
||||
width: 12px;
|
||||
}
|
||||
.cfg-key:hover .cfg-popup {
|
||||
opacity: 1; visibility: visible; pointer-events: auto;
|
||||
transition: opacity .15s, visibility 0s;
|
||||
}
|
||||
.cfg-popup dt { color: #999; font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; margin-top: .35rem; }
|
||||
.cfg-popup dt:first-child { margin-top: 0; }
|
||||
.cfg-popup dd { margin: .1rem 0 0; }
|
||||
.cfg-popup code { background: rgba(255,255,255,.12); color: #fff; padding: .05rem .3rem; border-radius: 3px; font-size: .8rem; }
|
||||
</style>
|
||||
|
||||
<h1>{{ t.debug_heading }}</h1>
|
||||
|
||||
<h2>{{ t.debug_build_info }}</h2>
|
||||
<table>
|
||||
<tr><th>{{ t.debug_field }}</th><th>{{ t.debug_value }}</th></tr>
|
||||
<tr><td>Package</td><td><code>{{ build.pkg_name }}</code></td></tr>
|
||||
<tr><td>Version</td><td><code>{{ build.pkg_version }}</code></td></tr>
|
||||
<tr><td>Profile</td><td><code>{{ build.profile }}</code></td></tr>
|
||||
<tr><td>Target</td><td><code>{{ build.target }}</code></td></tr>
|
||||
<tr><td>Rustc</td><td><code>{{ build.rustc_version }}</code></td></tr>
|
||||
<tr><td>{{ t.debug_db_status }}</td><td><code>{{ db_status }}</code></td></tr>
|
||||
</table>
|
||||
|
||||
<h2>{{ t.debug_app_config }}</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ t.debug_field }}</th>
|
||||
<th>{{ t.debug_value }}</th>
|
||||
<th>{{ t.debug_source }}</th>
|
||||
</tr>
|
||||
{% for entry in config_entries %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="cfg-key">
|
||||
<code>{{ entry.key }}</code>
|
||||
<span class="cfg-info-btn">i</span>
|
||||
<dl class="cfg-popup">
|
||||
<dt>env var</dt>
|
||||
<dd><code>{{ entry.env_var }}</code></dd>
|
||||
<dt>default</dt>
|
||||
<dd><code>{% if entry.default_value == "" %}(empty){% else %}{{ entry.default_value }}{% endif %}</code></dd>
|
||||
<dt>source</dt>
|
||||
<dd><code>{{ entry.source }}</code></dd>
|
||||
</dl>
|
||||
</span>
|
||||
</td>
|
||||
<td><code>{{ entry.value }}</code></td>
|
||||
<td><span class="badge badge-{{ entry.source }}">{{ entry.source }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
|
||||
{% block admin_title %}{{ t.nav_dashboard }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ t.admin_heading }}</h1>
|
||||
<p><a href="/admin/debug">{{ t.admin_debug_link }}</a></p>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% block admin_title %}{{ t.nav_admin }}{% endblock admin_title %} | {{ t.site_name }}{% endblock title %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; min-height: 100vh; background: #f5f5f5; color: #333; }
|
||||
nav.sidebar { width: 220px; background: #1a1a2e; color: #eee; padding: 1rem; }
|
||||
nav.sidebar h2 { font-size: 1.1rem; margin-bottom: 1.5rem; }
|
||||
nav.sidebar a { color: #ccc; text-decoration: none; display: block; padding: .4rem .6rem; border-radius: 4px; }
|
||||
nav.sidebar a:hover { background: #16213e; color: #fff; }
|
||||
.main-wrap { flex: 1; display: flex; flex-direction: column; }
|
||||
.topbar { display: flex; justify-content: flex-end; align-items: center; gap: 1rem; padding: .5rem 1.5rem; background: #fff; border-bottom: 1px solid #e0e0e0; }
|
||||
.user-info { font-size: .85rem; color: #555; }
|
||||
.lang-switch { display: flex; gap: .25rem; }
|
||||
.lang-switch a { text-decoration: none; padding: .25rem .5rem; border-radius: 4px; font-size: .9rem; color: #555; }
|
||||
.lang-switch a:hover { background: #eee; color: #111; }
|
||||
.lang-switch a.active { font-weight: 700; color: #111; }
|
||||
.logout-link { text-decoration: none; font-size: .85rem; color: #555; padding: .25rem .5rem; border-radius: 4px; }
|
||||
.logout-link:hover { background: #eee; color: #111; }
|
||||
.main { flex: 1; padding: 2rem; overflow-x: auto; }
|
||||
table { border-collapse: collapse; width: 100%; margin-bottom: 1.5rem; background: #fff; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
|
||||
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid #eee; }
|
||||
th { background: #f0f0f0; font-weight: 600; }
|
||||
h1 { margin-bottom: 1rem; }
|
||||
h2 { margin: 1.5rem 0 .5rem; }
|
||||
code { background: #f4f4f4; padding: .1rem .35rem; border-radius: 3px; font-size: .85em; }
|
||||
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 4px; font-size: .8rem; font-weight: 600; letter-spacing: .02em; }
|
||||
.badge-default { background: #e9ecef; color: #555; }
|
||||
.badge-database { background: #d1ecf1; color: #0c5460; }
|
||||
.badge-env { background: #fff3cd; color: #856404; }
|
||||
</style>
|
||||
{% endblock head_extra %}
|
||||
|
||||
{% block body %}
|
||||
<nav class="sidebar">
|
||||
<h2>{{ t.site_name }} {{ t.nav_admin }}</h2>
|
||||
<a href="/admin/">{{ t.nav_dashboard }}</a>
|
||||
<a href="/admin/users">{{ t.nav_users }}</a>
|
||||
<a href="/admin/settings">{{ t.nav_settings }}</a>
|
||||
<a href="/admin/debug">{{ t.nav_debug }}</a>
|
||||
</nav>
|
||||
<div class="main-wrap">
|
||||
<div class="topbar">
|
||||
<span class="user-info">{{ user_name }} ({{ user_role }})</span>
|
||||
<div class="lang-switch">
|
||||
<a href="#"{% if t.lang.code() == "en" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=en&next='+encodeURIComponent(location.pathname);return false">EN</a>
|
||||
<a href="#"{% if t.lang.code() == "ru" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=ru&next='+encodeURIComponent(location.pathname);return false">RU</a>
|
||||
</div>
|
||||
<a class="logout-link" href="/logout">{{ t.nav_logout }}</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
{% block content %}{% endblock content %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock body %}
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{{ t.nav_settings }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ t.settings_heading }}</h1>
|
||||
|
||||
{% if saved %}
|
||||
<p style="color: green; font-weight: bold; margin-bottom: 1rem;">{{ t.settings_saved }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/settings">
|
||||
|
||||
<h2>{{ t.settings_auth }}</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ t.debug_field }}</th>
|
||||
<th>{{ t.debug_value }}</th>
|
||||
<th>{{ t.debug_source }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="auth_password_enabled">{{ t.settings_password_login }}</label></td>
|
||||
<td><input type="checkbox" name="auth_password_enabled" id="auth_password_enabled" value="on"{% if auth_password_enabled %} checked{% endif %}></td>
|
||||
<td><span class="badge badge-{{ auth_password_enabled_source }}">{{ auth_password_enabled_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="auth_sso_enabled">{{ t.settings_sso_login }}</label></td>
|
||||
<td><input type="checkbox" name="auth_sso_enabled" id="auth_sso_enabled" value="on"{% if auth_sso_enabled %} checked{% endif %}></td>
|
||||
<td><span class="badge badge-{{ auth_sso_enabled_source }}">{{ auth_sso_enabled_source }}</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2>{{ t.settings_oidc }}</h2>
|
||||
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.settings_oidc_help }}</p>
|
||||
<p style="font-size:.85rem;color:#666;margin-bottom:1rem;">
|
||||
<strong>{{ t.settings_oidc_callback }}:</strong>
|
||||
<code id="oidc-callback-url"></code>
|
||||
</p>
|
||||
<script>document.getElementById('oidc-callback-url').textContent=location.origin+'/auth/oidc/callback';</script>
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ t.debug_field }}</th>
|
||||
<th>{{ t.debug_value }}</th>
|
||||
<th>{{ t.debug_source }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_button_text">{{ t.settings_oidc_button }}</label></td>
|
||||
<td><input name="oidc_button_text" id="oidc_button_text" value="{{ oidc_button_text }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_button_text_source }}">{{ oidc_button_text_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_issuer">oidc_issuer</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_oidc_issuer_help }}</span></td>
|
||||
<td><input name="oidc_issuer" id="oidc_issuer" value="{{ oidc_issuer }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_issuer_source }}">{{ oidc_issuer_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_client_id">oidc_client_id</label></td>
|
||||
<td><input name="oidc_client_id" id="oidc_client_id" value="{{ oidc_client_id }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_client_id_source }}">{{ oidc_client_id_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_client_secret">oidc_client_secret</label></td>
|
||||
<td><input name="oidc_client_secret" id="oidc_client_secret" type="password" value="{{ oidc_client_secret }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_client_secret_source }}">{{ oidc_client_secret_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_admin_groups">{{ t.settings_oidc_admin_groups }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_oidc_admin_groups_help }}</span></td>
|
||||
<td><input name="oidc_admin_groups" id="oidc_admin_groups" value="{{ oidc_admin_groups }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_admin_groups_source }}">{{ oidc_admin_groups_source }}</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.setup_heading }} | {{ t.site_name }}{% endblock title %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; color: #333; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||
.setup-card { background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.12); padding: 2rem 2.5rem; width: 100%; max-width: 400px; }
|
||||
.setup-card h1 { margin-bottom: 1.5rem; font-size: 1.5rem; text-align: center; }
|
||||
.setup-card label { display: block; margin-bottom: .25rem; font-weight: 600; font-size: .9rem; }
|
||||
.setup-card input[type="text"],
|
||||
.setup-card input[type="password"] { width: 100%; padding: .5rem .7rem; margin-bottom: 1rem; border: 1px solid #ccc; border-radius: 4px; font-size: .95rem; }
|
||||
.setup-card button { display: block; width: 100%; padding: .6rem; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; background: #1a1a2e; color: #fff; }
|
||||
.setup-card button:hover { background: #16213e; }
|
||||
.setup-card .flash { color: #856404; background: #fff3cd; padding: .5rem .75rem; border-radius: 4px; margin-bottom: 1rem; text-align: center; font-size: .9rem; }
|
||||
</style>
|
||||
{% endblock head_extra %}
|
||||
|
||||
{% block body %}
|
||||
<div class="setup-card">
|
||||
<h1>{{ t.setup_heading }}</h1>
|
||||
|
||||
{% if !message.is_empty() %}
|
||||
<div class="flash">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/setup">
|
||||
<label for="username">{{ t.setup_username }}</label>
|
||||
<input type="text" name="username" id="username" required>
|
||||
<label for="password">{{ t.setup_password }}</label>
|
||||
<input type="password" name="password" id="password" required>
|
||||
<label for="confirm_password">{{ t.setup_confirm }}</label>
|
||||
<input type="password" name="confirm_password" id="confirm_password" required>
|
||||
<button type="submit">{{ t.setup_submit }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock body %}
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{% if is_edit %}{{ t.users_edit_heading }}{% else %}{{ t.users_new_heading }}{% endif %}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% if is_edit %}{{ t.users_edit_heading }}{% else %}{{ t.users_new_heading }}{% endif %}</h1>
|
||||
|
||||
<form method="post" action="{% if is_edit %}/admin/users/{{ form_user_id }}/edit{% else %}/admin/users/new{% endif %}">
|
||||
<table>
|
||||
<tr>
|
||||
<td><label for="username">{{ t.users_username }}</label></td>
|
||||
<td><input name="username" id="username" value="{{ form_username }}" required style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="email">{{ t.users_email }}</label></td>
|
||||
<td><input name="email" id="email" type="email" value="{{ form_email }}" style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="display_name">{{ t.users_display_name }}</label></td>
|
||||
<td><input name="display_name" id="display_name" value="{{ form_display_name }}" style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="password">{{ t.login_password }}</label></td>
|
||||
<td>
|
||||
<input name="password" id="password" type="password"{% if !is_edit %} required{% endif %} style="width:100%">
|
||||
{% if is_edit %}<br><small style="color:#888;">{{ t.users_password_hint }}</small>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="role">{{ t.users_role }}</label></td>
|
||||
<td>
|
||||
<select name="role" id="role" style="width:100%; padding:.4rem;">
|
||||
<option value="user"{% if form_role == "user" %} selected{% endif %}>user</option>
|
||||
<option value="admin"{% if form_role == "admin" %} selected{% endif %}>admin</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{{ t.nav_users }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ t.users_heading }}</h1>
|
||||
|
||||
<p style="margin-bottom: 1rem;">
|
||||
<a href="/admin/users/new" style="display:inline-block; padding:.5rem 1rem; background:#1a1a2e; color:#fff; text-decoration:none; border-radius:4px;">{{ t.users_add }}</a>
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ t.users_username }}</th>
|
||||
<th>{{ t.users_email }}</th>
|
||||
<th>{{ t.users_display_name }}</th>
|
||||
<th>{{ t.users_role }}</th>
|
||||
<th>{{ t.users_active }}</th>
|
||||
<th>{{ t.users_actions }}</th>
|
||||
</tr>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.username_str() }}</td>
|
||||
<td>{{ u.email_str() }}</td>
|
||||
<td>{{ u.display_name_str() }}</td>
|
||||
<td>{{ u.role_str() }}</td>
|
||||
<td>{{ u.is_active() }}</td>
|
||||
<td>
|
||||
<a href="/admin/users/{{ u.id_val() }}/edit">{{ t.users_edit }}</a>
|
||||
|
|
||||
<form method="post" action="/admin/users/{{ u.id_val() }}/delete" style="display:inline;" onsubmit="return confirm('{{ t.users_delete_confirm }}')">
|
||||
<button type="submit" style="background:none; border:none; color:#c00; cursor:pointer; padding:0; text-decoration:underline;">{{ t.users_delete }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ t.lang.code() }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ t.site_name }}{% endblock title %}</title>
|
||||
{% block head_extra %}{% endblock head_extra %}
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}
|
||||
{% block content %}{% endblock content %}
|
||||
{% endblock body %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.login_heading }} | {{ t.site_name }}{% endblock title %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; color: #333; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||
.login-card { background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.12); padding: 2rem 2.5rem; width: 100%; max-width: 400px; }
|
||||
.login-card h1 { margin-bottom: 1.5rem; font-size: 1.5rem; text-align: center; }
|
||||
.login-card label { display: block; margin-bottom: .25rem; font-weight: 600; font-size: .9rem; }
|
||||
.login-card input[type="text"],
|
||||
.login-card input[type="password"] { width: 100%; padding: .5rem .7rem; margin-bottom: 1rem; border: 1px solid #ccc; border-radius: 4px; font-size: .95rem; }
|
||||
.login-card button,
|
||||
.login-card .sso-btn { display: block; width: 100%; padding: .6rem; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; text-align: center; text-decoration: none; }
|
||||
.login-card button { background: #1a1a2e; color: #fff; }
|
||||
.login-card button:hover { background: #16213e; }
|
||||
.login-card .sso-btn { background: #4a90d9; color: #fff; margin-top: .75rem; }
|
||||
.login-card .sso-btn:hover { background: #357abd; }
|
||||
.login-card .divider { text-align: center; margin: 1rem 0; color: #999; font-size: .85rem; }
|
||||
.login-card .message { text-align: center; color: #666; margin-bottom: 1rem; }
|
||||
.login-card .flash { color: #856404; background: #fff3cd; padding: .5rem .75rem; border-radius: 4px; margin-bottom: 1rem; text-align: center; font-size: .9rem; }
|
||||
</style>
|
||||
{% endblock head_extra %}
|
||||
|
||||
{% block body %}
|
||||
<div class="login-card">
|
||||
<h1>{{ t.login_heading }}</h1>
|
||||
|
||||
{% if !message.is_empty() %}
|
||||
<div class="flash">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if !auth_password_enabled && !auth_sso_enabled %}
|
||||
<p class="message">{{ t.login_disabled }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if auth_password_enabled %}
|
||||
<form method="post" action="/login">
|
||||
<label for="username">{{ t.login_username }}</label>
|
||||
<input type="text" name="username" id="username" required>
|
||||
<label for="password">{{ t.login_password }}</label>
|
||||
<input type="password" name="password" id="password" required>
|
||||
<button type="submit">{{ t.login_submit }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if auth_password_enabled && auth_sso_enabled %}
|
||||
<div class="divider">— or —</div>
|
||||
{% endif %}
|
||||
|
||||
{% if auth_sso_enabled %}
|
||||
<a class="sso-btn" href="/auth/oidc/start">{{ oidc_button_text }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock body %}
|
||||
Reference in New Issue
Block a user