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:
2026-05-21 14:22:33 +03:00
commit 16abe754af
24 changed files with 7664 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/target
/nul
Generated
+4095
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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"] }
+193
View File
@@ -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)* |
+17
View File
@@ -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()
);
}
+252
View File
@@ -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
}
}
+431
View File
@@ -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/"))
}
+68
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+95
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
];
}
+82
View File
@@ -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 %}
+8
View File
@@ -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 %}
+57
View File
@@ -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 %}
+73
View File
@@ -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 %}
+38
View File
@@ -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 %}
+40
View File
@@ -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 %}
+37
View File
@@ -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>
&nbsp;|&nbsp;
<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 %}
+14
View File
@@ -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>
+56
View File
@@ -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">&mdash; or &mdash;</div>
{% endif %}
{% if auth_sso_enabled %}
<a class="sso-btn" href="/auth/oidc/start">{{ oidc_button_text }}</a>
{% endif %}
</div>
{% endblock body %}