Initial commit: web-app boilerplate with auth, OIDC/SSO, admin panel, i18n
Rust (cot framework) + PostgreSQL boilerplate providing: - Session-based auth with Admin/User roles - OIDC/SSO login with PKCE, group-to-role mapping, auto-provisioning - Admin panel: user management, settings, debug/config inspector - 3-tier config system (compiled default → DB → FURU_* env vars) - i18n (English + Russian) with compile-time translations macro - JSON API skeleton (GET /api/me) - DB-backed sessions (survive server restarts) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
pub mod views;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cot::db::Database;
|
||||
use cot::db::migrations::SyncDynMigration;
|
||||
use cot::request::extractors::{Path, RequestForm, UrlQuery};
|
||||
use cot::response::IntoResponse;
|
||||
use cot::router::method::get;
|
||||
use cot::router::{Route, Router};
|
||||
use cot::session::Session;
|
||||
use cot::App;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth;
|
||||
use crate::config::AppConfig;
|
||||
use crate::i18n::I18n;
|
||||
use crate::user::User;
|
||||
use views::{OidcSettingsForm, SetupForm, UserForm};
|
||||
|
||||
/// Build-time metadata baked in by `build.rs` and Cargo env vars.
|
||||
#[derive(Debug)]
|
||||
pub struct BuildInfo {
|
||||
pub pkg_name: &'static str,
|
||||
pub pkg_version: &'static str,
|
||||
pub profile: &'static str,
|
||||
pub target: &'static str,
|
||||
pub rustc_version: &'static str,
|
||||
}
|
||||
|
||||
pub static BUILD_INFO: BuildInfo = BuildInfo {
|
||||
pkg_name: env!("CARGO_PKG_NAME"),
|
||||
pkg_version: env!("CARGO_PKG_VERSION"),
|
||||
profile: if cfg!(debug_assertions) {
|
||||
"debug"
|
||||
} else {
|
||||
"release"
|
||||
},
|
||||
target: env!("FURU_TARGET"),
|
||||
rustc_version: env!("FURU_RUSTC_VERSION"),
|
||||
};
|
||||
|
||||
pub struct AdminApp {
|
||||
config: Arc<AppConfig>,
|
||||
}
|
||||
|
||||
impl AdminApp {
|
||||
pub fn new(config: Arc<AppConfig>) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SettingsQuery {
|
||||
saved: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PathId {
|
||||
id: i64,
|
||||
}
|
||||
|
||||
impl App for AdminApp {
|
||||
fn name(&self) -> &'static str {
|
||||
"admin"
|
||||
}
|
||||
|
||||
fn router(&self) -> Router {
|
||||
Router::with_urls([
|
||||
// -- Setup (first-run, no auth required) --------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/setup",
|
||||
get(|i18n: I18n, db: Database| async move {
|
||||
let count = User::count_all(&db).await.unwrap_or(1);
|
||||
if count > 0 {
|
||||
return Ok(auth::redirect("/admin/"));
|
||||
}
|
||||
views::setup_page(i18n, String::new())
|
||||
.await?
|
||||
.into_response()
|
||||
})
|
||||
.post(
|
||||
|i18n: I18n, db: Database, session: Session,
|
||||
form: RequestForm<SetupForm>| async move {
|
||||
let count = User::count_all(&db).await.unwrap_or(1);
|
||||
if count > 0 {
|
||||
return Ok(auth::redirect("/admin/"));
|
||||
}
|
||||
views::setup_submit(i18n, &db, &session, form).await
|
||||
},
|
||||
),
|
||||
"admin_setup",
|
||||
),
|
||||
// -- Dashboard ----------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/",
|
||||
|session: Session, db: Database, i18n: I18n| async move {
|
||||
// First-run redirect
|
||||
let count = User::count_all(&db).await.unwrap_or(0);
|
||||
if count == 0 {
|
||||
return Ok(auth::redirect("/admin/setup"));
|
||||
}
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::admin_index(admin, i18n).await?.into_response()
|
||||
},
|
||||
"admin_index",
|
||||
),
|
||||
// -- Debug --------------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/debug",
|
||||
{
|
||||
let config = Arc::clone(&self.config);
|
||||
move |session: Session, db: Database, i18n: I18n| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
let admin =
|
||||
match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::debug_handler(admin, i18n, &config, &db)
|
||||
.await?
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin_debug",
|
||||
),
|
||||
// -- Settings -----------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/settings",
|
||||
get({
|
||||
let config = Arc::clone(&self.config);
|
||||
move |session: Session, db: Database, i18n: I18n,
|
||||
query: UrlQuery<SettingsQuery>| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
let admin =
|
||||
match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
let saved = query.0.saved.as_deref() == Some("1");
|
||||
views::settings_handler(admin, i18n, &config, &db, saved)
|
||||
.await?
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
})
|
||||
.post({
|
||||
let config = Arc::clone(&self.config);
|
||||
move |session: Session, db: Database, i18n: I18n,
|
||||
form: RequestForm<OidcSettingsForm>| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
let admin =
|
||||
match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::settings_submit(admin, i18n, &config, &db, form).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_settings",
|
||||
),
|
||||
// -- Users --------------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/users",
|
||||
|session: Session, db: Database, i18n: I18n| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_list(admin, i18n, &db).await?.into_response()
|
||||
},
|
||||
"admin_users",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/users/new",
|
||||
get(|session: Session, db: Database, i18n: I18n| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_new(admin, i18n).await?.into_response()
|
||||
})
|
||||
.post(
|
||||
|session: Session, db: Database, form: RequestForm<UserForm>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_create(admin, &db, form).await
|
||||
},
|
||||
),
|
||||
"admin_users_new",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/users/{id}/edit",
|
||||
get(
|
||||
|session: Session, db: Database, i18n: I18n,
|
||||
path: Path<PathId>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_edit(admin, i18n, &db, path.0.id)
|
||||
.await?
|
||||
.into_response()
|
||||
},
|
||||
)
|
||||
.post(
|
||||
|session: Session, db: Database, path: Path<PathId>,
|
||||
form: RequestForm<UserForm>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_update(admin, &db, path.0.id, form).await
|
||||
},
|
||||
),
|
||||
"admin_users_edit",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/users/{id}/delete",
|
||||
cot::router::method::post(
|
||||
|session: Session, db: Database, path: Path<PathId>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_delete(admin, &db, path.0.id).await
|
||||
},
|
||||
),
|
||||
"admin_users_delete",
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn migrations(&self) -> Vec<Box<SyncDynMigration>> {
|
||||
let mut all =
|
||||
cot::db::migrations::wrap_migrations(crate::config::db_migrations::MIGRATIONS);
|
||||
all.extend(cot::db::migrations::wrap_migrations(
|
||||
crate::user::db_migrations::MIGRATIONS,
|
||||
));
|
||||
all
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
use cot::db::{Database, Model};
|
||||
use cot::form::{Form, FormResult};
|
||||
use cot::html::Html;
|
||||
use cot::request::extractors::RequestForm;
|
||||
use cot::response::IntoResponse;
|
||||
use cot::session::Session;
|
||||
use cot::{Body, Template};
|
||||
|
||||
use crate::auth::{self, AuthenticatedUser};
|
||||
use crate::config::{AppConfig, ConfigEntry, ConfigSources};
|
||||
use crate::i18n::{I18n, Translations};
|
||||
use crate::user::User;
|
||||
use super::BUILD_INFO;
|
||||
|
||||
/// A config entry for display in the unified debug table.
|
||||
#[derive(Debug)]
|
||||
pub struct ConfigDisplayEntry {
|
||||
pub key: String,
|
||||
pub env_var: String,
|
||||
pub value: String,
|
||||
pub default_value: String,
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
/// Secret field names that should be redacted in the debug view.
|
||||
const SECRET_FIELDS: &[&str] = &[
|
||||
"database_url",
|
||||
"oidc_client_secret",
|
||||
];
|
||||
|
||||
fn is_secret(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
SECRET_FIELDS.iter().any(|s| lower.contains(s))
|
||||
|| lower.contains("secret")
|
||||
|| lower.contains("token")
|
||||
}
|
||||
|
||||
fn redact(value: &str) -> String {
|
||||
if value.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
"********".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/debug.html")]
|
||||
struct DebugTemplate<'a> {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
build: &'a super::BuildInfo,
|
||||
config_entries: Vec<ConfigDisplayEntry>,
|
||||
db_status: String,
|
||||
}
|
||||
|
||||
fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<ConfigDisplayEntry> {
|
||||
let defaults = AppConfig::default();
|
||||
|
||||
macro_rules! entry {
|
||||
($field:ident, $value:expr, $default:expr) => {
|
||||
{
|
||||
let raw = $value;
|
||||
let default_raw = $default;
|
||||
let secret = is_secret(stringify!($field));
|
||||
let display = if secret { redact(&raw) } else { raw };
|
||||
let default_display = if secret { redact(&default_raw) } else { default_raw };
|
||||
ConfigDisplayEntry {
|
||||
key: stringify!($field).into(),
|
||||
env_var: format!("FURU_{}", stringify!($field).to_ascii_uppercase()),
|
||||
value: display,
|
||||
default_value: default_display,
|
||||
source: sources.$field.code(),
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
vec![
|
||||
entry!(database_url, config.database_url.clone(), defaults.database_url.clone()),
|
||||
entry!(oidc_issuer, config.oidc_issuer.clone(), defaults.oidc_issuer.clone()),
|
||||
entry!(oidc_client_id, config.oidc_client_id.clone(), defaults.oidc_client_id.clone()),
|
||||
entry!(oidc_client_secret, config.oidc_client_secret.clone(), defaults.oidc_client_secret.clone()),
|
||||
entry!(log_level, config.log_level.clone(), defaults.log_level.clone()),
|
||||
entry!(auth_password_enabled, config.auth_password_enabled.to_string(), defaults.auth_password_enabled.to_string()),
|
||||
entry!(auth_sso_enabled, config.auth_sso_enabled.to_string(), defaults.auth_sso_enabled.to_string()),
|
||||
entry!(oidc_button_text, config.oidc_button_text.clone(), defaults.oidc_button_text.clone()),
|
||||
entry!(oidc_admin_groups, config.oidc_admin_groups.clone(), defaults.oidc_admin_groups.clone()),
|
||||
]
|
||||
}
|
||||
|
||||
pub async fn debug_handler(
|
||||
admin: AuthenticatedUser,
|
||||
i18n: I18n,
|
||||
_startup_config: &AppConfig,
|
||||
db: &Database,
|
||||
) -> cot::Result<Html> {
|
||||
let (config, sources) = AppConfig::load_with_db(db).await;
|
||||
|
||||
let db_status = match db.raw("SELECT 1").await {
|
||||
Ok(_) => i18n.t.debug_db_connected.to_owned(),
|
||||
Err(e) => format!("{}: {e}", i18n.t.debug_db_error),
|
||||
};
|
||||
|
||||
let template = DebugTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
build: &BUILD_INFO,
|
||||
config_entries: config_display_entries(&config, &sources),
|
||||
db_status,
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/index.html")]
|
||||
struct AdminIndexTemplate {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
}
|
||||
|
||||
pub async fn admin_index(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> {
|
||||
let template = AdminIndexTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/settings.html")]
|
||||
struct SettingsTemplate {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
saved: bool,
|
||||
auth_password_enabled: bool,
|
||||
auth_password_enabled_source: &'static str,
|
||||
auth_sso_enabled: bool,
|
||||
auth_sso_enabled_source: &'static str,
|
||||
oidc_button_text: String,
|
||||
oidc_button_text_source: &'static str,
|
||||
oidc_issuer: String,
|
||||
oidc_issuer_source: &'static str,
|
||||
oidc_client_id: String,
|
||||
oidc_client_id_source: &'static str,
|
||||
oidc_client_secret: String,
|
||||
oidc_client_secret_source: &'static str,
|
||||
oidc_admin_groups: String,
|
||||
oidc_admin_groups_source: &'static str,
|
||||
}
|
||||
|
||||
pub async fn settings_handler(
|
||||
admin: AuthenticatedUser,
|
||||
i18n: I18n,
|
||||
_startup_config: &AppConfig,
|
||||
db: &Database,
|
||||
saved: bool,
|
||||
) -> cot::Result<Html> {
|
||||
let (config, sources) = AppConfig::load_with_db(db).await;
|
||||
|
||||
let template = SettingsTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
saved,
|
||||
auth_password_enabled: config.auth_password_enabled,
|
||||
auth_password_enabled_source: sources.auth_password_enabled.code(),
|
||||
auth_sso_enabled: config.auth_sso_enabled,
|
||||
auth_sso_enabled_source: sources.auth_sso_enabled.code(),
|
||||
oidc_button_text: config.oidc_button_text,
|
||||
oidc_button_text_source: sources.oidc_button_text.code(),
|
||||
oidc_issuer: config.oidc_issuer,
|
||||
oidc_issuer_source: sources.oidc_issuer.code(),
|
||||
oidc_client_id: config.oidc_client_id,
|
||||
oidc_client_id_source: sources.oidc_client_id.code(),
|
||||
oidc_client_secret: config.oidc_client_secret,
|
||||
oidc_client_secret_source: sources.oidc_client_secret.code(),
|
||||
oidc_admin_groups: config.oidc_admin_groups,
|
||||
oidc_admin_groups_source: sources.oidc_admin_groups.code(),
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Form)]
|
||||
pub struct OidcSettingsForm {
|
||||
auth_password_enabled: Option<String>,
|
||||
auth_sso_enabled: Option<String>,
|
||||
oidc_button_text: String,
|
||||
oidc_issuer: String,
|
||||
oidc_client_id: String,
|
||||
oidc_client_secret: String,
|
||||
oidc_admin_groups: String,
|
||||
}
|
||||
|
||||
pub async fn settings_submit(
|
||||
_admin: AuthenticatedUser,
|
||||
_i18n: I18n,
|
||||
_startup_config: &AppConfig,
|
||||
db: &Database,
|
||||
form: RequestForm<OidcSettingsForm>,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
let RequestForm(result) = form;
|
||||
match result {
|
||||
FormResult::Ok(data) => {
|
||||
let pw_enabled = if data.auth_password_enabled.is_some() { "true" } else { "false" };
|
||||
let sso_enabled = if data.auth_sso_enabled.is_some() { "true" } else { "false" };
|
||||
let fields: [(&str, &str); 7] = [
|
||||
("auth_password_enabled", pw_enabled),
|
||||
("auth_sso_enabled", sso_enabled),
|
||||
("oidc_button_text", &data.oidc_button_text),
|
||||
("oidc_issuer", &data.oidc_issuer),
|
||||
("oidc_client_id", &data.oidc_client_id),
|
||||
("oidc_client_secret", &data.oidc_client_secret),
|
||||
("oidc_admin_groups", &data.oidc_admin_groups),
|
||||
];
|
||||
for (key, value) in fields {
|
||||
let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned());
|
||||
if let Err(e) = entry.save(db).await {
|
||||
tracing::error!(key, error = %e, "failed to save config entry");
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(auth::redirect("/admin/settings?saved=1"))
|
||||
}
|
||||
FormResult::ValidationError(_ctx) => {
|
||||
Ok(auth::redirect("/admin/settings"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/users.html")]
|
||||
struct UsersTemplate {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
users: Vec<User>,
|
||||
}
|
||||
|
||||
pub async fn users_list(admin: AuthenticatedUser, i18n: I18n, db: &Database) -> cot::Result<Html> {
|
||||
let users = User::list_all(db).await.unwrap_or_default();
|
||||
let template = UsersTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
users,
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/user_form.html")]
|
||||
struct UserFormTemplate {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
is_edit: bool,
|
||||
form_user_id: i64,
|
||||
form_username: String,
|
||||
form_email: String,
|
||||
form_display_name: String,
|
||||
form_role: String,
|
||||
}
|
||||
|
||||
pub async fn users_new(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> {
|
||||
let template = UserFormTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
is_edit: false,
|
||||
form_user_id: 0,
|
||||
form_username: String::new(),
|
||||
form_email: String::new(),
|
||||
form_display_name: String::new(),
|
||||
form_role: "user".into(),
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Form)]
|
||||
pub struct UserForm {
|
||||
username: String,
|
||||
email: String,
|
||||
display_name: String,
|
||||
password: String,
|
||||
role: String,
|
||||
}
|
||||
|
||||
pub async fn users_create(
|
||||
_admin: AuthenticatedUser,
|
||||
db: &Database,
|
||||
form: RequestForm<UserForm>,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
let RequestForm(result) = form;
|
||||
match result {
|
||||
FormResult::Ok(data) => {
|
||||
let email = if data.email.is_empty() { None } else { Some(data.email.as_str()) };
|
||||
let display_name = if data.display_name.is_empty() { None } else { Some(data.display_name.as_str()) };
|
||||
User::create(db, &data.username, email, display_name, &data.password, &data.role).await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to create user: {e}")))?;
|
||||
Ok(auth::redirect("/admin/users"))
|
||||
}
|
||||
FormResult::ValidationError(_) => {
|
||||
Ok(auth::redirect("/admin/users/new"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn users_edit(
|
||||
admin: AuthenticatedUser,
|
||||
i18n: I18n,
|
||||
db: &Database,
|
||||
user_id: i64,
|
||||
) -> cot::Result<Html> {
|
||||
let target = User::get_by_id(db, user_id).await
|
||||
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
|
||||
.ok_or_else(|| cot::Error::internal("user not found"))?;
|
||||
let template = UserFormTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
is_edit: true,
|
||||
form_user_id: target.id_val(),
|
||||
form_username: target.username_str().to_owned(),
|
||||
form_email: target.email_str(),
|
||||
form_display_name: target.display_name_str(),
|
||||
form_role: target.role_str().to_owned(),
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
pub async fn users_update(
|
||||
_admin: AuthenticatedUser,
|
||||
db: &Database,
|
||||
user_id: i64,
|
||||
form: RequestForm<UserForm>,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
let RequestForm(result) = form;
|
||||
match result {
|
||||
FormResult::Ok(data) => {
|
||||
let mut target = User::get_by_id(db, user_id).await
|
||||
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
|
||||
.ok_or_else(|| cot::Error::internal("user not found"))?;
|
||||
let email = if data.email.is_empty() { None } else { Some(data.email.as_str()) };
|
||||
let display_name = if data.display_name.is_empty() { None } else { Some(data.display_name.as_str()) };
|
||||
let new_password = if data.password.is_empty() { None } else { Some(data.password.as_str()) };
|
||||
target.update_fields(db, &data.username, email, display_name, new_password, &data.role).await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to update user: {e}")))?;
|
||||
Ok(auth::redirect("/admin/users"))
|
||||
}
|
||||
FormResult::ValidationError(_) => {
|
||||
Ok(auth::redirect(&format!("/admin/users/{user_id}/edit")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn users_delete(
|
||||
_admin: AuthenticatedUser,
|
||||
db: &Database,
|
||||
user_id: i64,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
User::delete_by_id(db, user_id).await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to delete user: {e}")))?;
|
||||
Ok(auth::redirect("/admin/users"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// First-run setup page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/setup.html")]
|
||||
struct SetupTemplate {
|
||||
t: &'static Translations,
|
||||
message: String,
|
||||
}
|
||||
|
||||
pub async fn setup_page(i18n: I18n, message: String) -> cot::Result<Html> {
|
||||
let template = SetupTemplate {
|
||||
t: i18n.t,
|
||||
message,
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Form)]
|
||||
pub struct SetupForm {
|
||||
username: String,
|
||||
password: String,
|
||||
confirm_password: String,
|
||||
}
|
||||
|
||||
pub async fn setup_submit(
|
||||
i18n: I18n,
|
||||
db: &Database,
|
||||
session: &Session,
|
||||
form: RequestForm<SetupForm>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let RequestForm(result) = form;
|
||||
let data = match result {
|
||||
FormResult::Ok(data) => data,
|
||||
FormResult::ValidationError(_) => {
|
||||
return setup_page(i18n, String::new()).await?.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if data.password != data.confirm_password {
|
||||
let msg = i18n.t.setup_mismatch.to_owned();
|
||||
return setup_page(i18n, msg).await?.into_response();
|
||||
}
|
||||
|
||||
let user = User::create(db, &data.username, None, None, &data.password, "admin")
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to create admin: {e}")))?;
|
||||
|
||||
auth::login(session, user.id_val()).await?;
|
||||
Ok(auth::redirect("/admin/"))
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use cot::db::Database;
|
||||
use cot::router::method::get;
|
||||
use cot::router::{Route, Router};
|
||||
use cot::session::Session;
|
||||
use cot::{App, Body};
|
||||
|
||||
use crate::auth;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON response helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn json_ok(value: &serde_json::Value) -> cot::response::Response {
|
||||
cot::http::Response::builder()
|
||||
.status(cot::http::StatusCode::OK)
|
||||
.header(cot::http::header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::fixed(value.to_string()))
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response {
|
||||
let body = serde_json::json!({ "error": message });
|
||||
cot::http::Response::builder()
|
||||
.status(status)
|
||||
.header(cot::http::header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::fixed(body.to_string()))
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/me
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn me_handler(
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let Some(user) = auth::get_session_user(&session, &db).await else {
|
||||
return Ok(json_error(
|
||||
cot::http::StatusCode::UNAUTHORIZED,
|
||||
"not authenticated",
|
||||
));
|
||||
};
|
||||
|
||||
Ok(json_ok(&serde_json::json!({
|
||||
"id": user.id,
|
||||
"name": user.name,
|
||||
"role": user.role.code(),
|
||||
})))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct ApiApp;
|
||||
|
||||
impl App for ApiApp {
|
||||
fn name(&self) -> &'static str {
|
||||
"api"
|
||||
}
|
||||
|
||||
fn router(&self) -> Router {
|
||||
Router::with_urls([
|
||||
Route::with_handler_and_name("/me", get(me_handler), "api_me"),
|
||||
])
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
use cot::db::Database;
|
||||
use cot::response::IntoResponse;
|
||||
use cot::session::Session;
|
||||
use cot::Body;
|
||||
|
||||
use crate::user::User;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Role enum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
Admin,
|
||||
User,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Role::Admin => "admin",
|
||||
Role::User => "user",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_code(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"admin" => Some(Role::Admin),
|
||||
"user" => Some(Role::User),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session-based auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SESSION_USER_ID: &str = "user_id";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthenticatedUser {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
/// Read `user_id` from the session, fetch the `User` from DB, return
|
||||
/// `AuthenticatedUser` if the user exists and is active.
|
||||
pub async fn get_session_user(session: &Session, db: &Database) -> Option<AuthenticatedUser> {
|
||||
let user_id: i64 = session.get(SESSION_USER_ID).await.ok()??;
|
||||
let user = User::get_by_id(db, user_id).await.ok()??;
|
||||
if !user.is_active() {
|
||||
return None;
|
||||
}
|
||||
let name = {
|
||||
let display = user.display_name_str();
|
||||
if display.is_empty() {
|
||||
user.username_str().to_owned()
|
||||
} else {
|
||||
display
|
||||
}
|
||||
};
|
||||
Some(AuthenticatedUser {
|
||||
id: user.id_val(),
|
||||
name,
|
||||
role: user.role(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Return `Ok(user)` if the session belongs to an active admin, otherwise
|
||||
/// `Err(response)` — a redirect to `/login` or a 403.
|
||||
pub async fn require_admin_or_redirect(
|
||||
session: &Session,
|
||||
db: &Database,
|
||||
) -> Result<AuthenticatedUser, cot::response::Response> {
|
||||
let Some(user) = get_session_user(session, db).await else {
|
||||
return Err(redirect("/login"));
|
||||
};
|
||||
if user.role != Role::Admin {
|
||||
return Err(
|
||||
"Forbidden"
|
||||
.with_status(cot::http::StatusCode::FORBIDDEN)
|
||||
.into_response()
|
||||
.expect("valid response"),
|
||||
);
|
||||
}
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Insert user_id into the session and cycle the session ID.
|
||||
pub async fn login(session: &Session, user_id: i64) -> cot::Result<()> {
|
||||
session
|
||||
.cycle_id()
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
session
|
||||
.insert(SESSION_USER_ID, user_id)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush (destroy) the session.
|
||||
pub async fn logout(session: &Session) -> cot::Result<()> {
|
||||
session
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a 303 See Other redirect response.
|
||||
pub fn redirect(location: &str) -> cot::response::Response {
|
||||
cot::http::Response::builder()
|
||||
.status(cot::http::StatusCode::SEE_OTHER)
|
||||
.header(cot::http::header::LOCATION, location)
|
||||
.body(Body::fixed(""))
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn role_roundtrip() {
|
||||
assert_eq!(Role::from_code("admin"), Some(Role::Admin));
|
||||
assert_eq!(Role::from_code("user"), Some(Role::User));
|
||||
assert_eq!(Role::from_code("other"), None);
|
||||
assert_eq!(Role::Admin.code(), "admin");
|
||||
assert_eq!(Role::User.code(), "user");
|
||||
}
|
||||
}
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
/// Application-level configuration for furumusic.
|
||||
///
|
||||
/// Every field is available both as a `FURU_`-prefixed environment variable
|
||||
/// and through the admin UI. The resolution order is:
|
||||
///
|
||||
/// env var > DB override > compiled default
|
||||
///
|
||||
/// Adding a new field to [`AppConfig`] automatically makes it settable via
|
||||
/// the `FURU_<FIELD_NAME>` env var thanks to the [`impl_env_overrides`] macro.
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
|
||||
use cot::db::{Database, DatabaseField, Identifier, LimitedString, Model};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfigSource — tracks where each field's effective value came from
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConfigSource {
|
||||
Default,
|
||||
Database,
|
||||
Env,
|
||||
}
|
||||
|
||||
impl ConfigSource {
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::Default => "default",
|
||||
Self::Database => "database",
|
||||
Self::Env => "env",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfigEntry — DB model for the furu__config table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cot::db::model]
|
||||
pub struct ConfigEntry {
|
||||
#[model(primary_key)]
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl ConfigEntry {
|
||||
pub fn new(key: String, value: String) -> Self {
|
||||
Self { key, value }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub mod db_migrations {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0001CreateConfig;
|
||||
|
||||
impl migrations::Migration for M0001CreateConfig {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0001_create_config";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("furu__config"))
|
||||
.fields(&[
|
||||
Field::new(
|
||||
Identifier::new("key"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
)
|
||||
.primary_key()
|
||||
.set_null(<LimitedString<255> as DatabaseField>::NULLABLE),
|
||||
Field::new(
|
||||
Identifier::new("value"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(<String as DatabaseField>::NULLABLE),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
|
||||
// -- M0002: rename furu__config → furumusic__config_entry ---------------
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn rename_config_table(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
ctx.db
|
||||
.raw("ALTER TABLE furu__config RENAME TO furumusic__config_entry")
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0002RenameConfigTable;
|
||||
|
||||
impl migrations::Migration for M0002RenameConfigTable {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0002_rename_config_table";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration("furumusic", "m_0001_create_config"),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::custom(rename_config_table).build(),
|
||||
];
|
||||
}
|
||||
|
||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[&M0001CreateConfig, &M0002RenameConfigTable];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfigSources — parallel struct tracking the source of each field
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct ConfigSources {
|
||||
pub database_url: ConfigSource,
|
||||
pub oidc_issuer: ConfigSource,
|
||||
pub oidc_client_id: ConfigSource,
|
||||
pub oidc_client_secret: ConfigSource,
|
||||
pub log_level: ConfigSource,
|
||||
pub auth_password_enabled: ConfigSource,
|
||||
pub auth_sso_enabled: ConfigSource,
|
||||
pub oidc_button_text: ConfigSource,
|
||||
pub oidc_admin_groups: ConfigSource,
|
||||
}
|
||||
|
||||
impl Default for ConfigSources {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
database_url: ConfigSource::Default,
|
||||
oidc_issuer: ConfigSource::Default,
|
||||
oidc_client_id: ConfigSource::Default,
|
||||
oidc_client_secret: ConfigSource::Default,
|
||||
log_level: ConfigSource::Default,
|
||||
auth_password_enabled: ConfigSource::Default,
|
||||
auth_sso_enabled: ConfigSource::Default,
|
||||
oidc_button_text: ConfigSource::Default,
|
||||
oidc_admin_groups: ConfigSource::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Env-var helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read a single env var with the `FURU_` prefix, returning `None` when the
|
||||
/// variable is absent and logging a warning when it is present but cannot be
|
||||
/// parsed.
|
||||
fn env_override<T: std::str::FromStr>(field: &str) -> Option<T> {
|
||||
let key = format!("FURU_{}", field.to_ascii_uppercase());
|
||||
match std::env::var(&key) {
|
||||
Ok(val) => match val.parse::<T>() {
|
||||
Ok(v) => Some(v),
|
||||
Err(_) => {
|
||||
tracing::warn!("ignoring invalid value for {key}: {val:?}");
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Macro: generates apply_env_overrides + apply_env_overrides_tracked
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generates two methods on [`AppConfig`]:
|
||||
///
|
||||
/// - `apply_env_overrides`: overwrites fields from `FURU_*` env vars (no source tracking).
|
||||
/// - `apply_env_overrides_tracked`: same but also marks sources as [`ConfigSource::Env`].
|
||||
macro_rules! impl_env_overrides {
|
||||
($($field:ident),* $(,)?) => {
|
||||
impl AppConfig {
|
||||
/// Apply `FURU_*` environment variable overrides to self.
|
||||
pub fn apply_env_overrides(&mut self) {
|
||||
$(
|
||||
if let Some(v) = env_override(stringify!($field)) {
|
||||
self.$field = v;
|
||||
}
|
||||
)*
|
||||
}
|
||||
|
||||
/// Apply `FURU_*` environment variable overrides and record sources.
|
||||
pub fn apply_env_overrides_tracked(&mut self, sources: &mut ConfigSources) {
|
||||
$(
|
||||
if let Some(v) = env_override(stringify!($field)) {
|
||||
self.$field = v;
|
||||
sources.$field = ConfigSource::Env;
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
/// PostgreSQL connection URL.
|
||||
pub database_url: String,
|
||||
/// OIDC issuer URL.
|
||||
pub oidc_issuer: String,
|
||||
/// OIDC client ID.
|
||||
pub oidc_client_id: String,
|
||||
/// OIDC client secret.
|
||||
pub oidc_client_secret: String,
|
||||
/// Tracing log level filter (e.g. "info", "debug", "warn,furumusic=debug").
|
||||
pub log_level: String,
|
||||
/// Whether password-based login is enabled.
|
||||
pub auth_password_enabled: bool,
|
||||
/// Whether SSO (OIDC) login is enabled.
|
||||
pub auth_sso_enabled: bool,
|
||||
/// Label shown on the SSO login button.
|
||||
pub oidc_button_text: String,
|
||||
/// Comma-separated list of OIDC group names that grant admin role.
|
||||
pub oidc_admin_groups: String,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
database_url: String::new(),
|
||||
oidc_issuer: String::new(),
|
||||
oidc_client_id: String::new(),
|
||||
oidc_client_secret: String::new(),
|
||||
log_level: "info".into(),
|
||||
auth_password_enabled: true,
|
||||
auth_sso_enabled: false,
|
||||
oidc_button_text: "Sign in with SSO".into(),
|
||||
oidc_admin_groups: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register every field that should be overridable via FURU_* env vars.
|
||||
impl_env_overrides!(
|
||||
database_url,
|
||||
oidc_issuer,
|
||||
oidc_client_id,
|
||||
oidc_client_secret,
|
||||
log_level,
|
||||
auth_password_enabled,
|
||||
auth_sso_enabled,
|
||||
oidc_button_text,
|
||||
oidc_admin_groups,
|
||||
);
|
||||
|
||||
impl AppConfig {
|
||||
/// Build config: start from defaults, then overlay env vars.
|
||||
/// Used at startup before the DB is available (to get `database_url`).
|
||||
pub fn load() -> Self {
|
||||
let mut cfg = Self::default();
|
||||
cfg.apply_env_overrides();
|
||||
cfg
|
||||
}
|
||||
|
||||
/// Build config with full 3-layer resolution (default → DB → env) and
|
||||
/// track the source of each field.
|
||||
pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) {
|
||||
let mut cfg = Self::default();
|
||||
let mut sources = ConfigSources::default();
|
||||
cfg.apply_db_overrides(db, &mut sources).await;
|
||||
cfg.apply_env_overrides_tracked(&mut sources);
|
||||
(cfg, sources)
|
||||
}
|
||||
|
||||
/// Query all rows from `furu__config` and overlay matching fields.
|
||||
async fn apply_db_overrides(&mut self, db: &Database, sources: &mut ConfigSources) {
|
||||
let rows = match ConfigEntry::objects().all(db).await {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to read furu__config: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let map: HashMap<String, String> = rows
|
||||
.into_iter()
|
||||
.map(|entry| (entry.key.to_string(), entry.value))
|
||||
.collect();
|
||||
|
||||
macro_rules! apply_db_field {
|
||||
($field:ident) => {
|
||||
if let Some(val) = map.get(stringify!($field)) {
|
||||
match val.parse() {
|
||||
Ok(v) => {
|
||||
self.$field = v;
|
||||
sources.$field = ConfigSource::Database;
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
"ignoring invalid DB config value for {}: {:?}",
|
||||
stringify!($field),
|
||||
val,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
apply_db_field!(database_url);
|
||||
apply_db_field!(oidc_issuer);
|
||||
apply_db_field!(oidc_client_id);
|
||||
apply_db_field!(oidc_client_secret);
|
||||
apply_db_field!(log_level);
|
||||
apply_db_field!(auth_password_enabled);
|
||||
apply_db_field!(auth_sso_enabled);
|
||||
apply_db_field!(oidc_button_text);
|
||||
apply_db_field!(oidc_admin_groups);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_are_sane() {
|
||||
let cfg = AppConfig::default();
|
||||
assert!(cfg.database_url.is_empty());
|
||||
assert_eq!(cfg.log_level, "info");
|
||||
}
|
||||
|
||||
// SAFETY: tests run with --test-threads=1 so no concurrent env access.
|
||||
unsafe fn set(k: &str, v: &str) { unsafe { std::env::set_var(k, v) }; }
|
||||
unsafe fn unset(k: &str) { unsafe { std::env::remove_var(k) }; }
|
||||
|
||||
#[test]
|
||||
fn env_override_string_field() {
|
||||
unsafe { set("FURU_OIDC_ISSUER", "https://example.com"); }
|
||||
let cfg = AppConfig::load();
|
||||
assert_eq!(cfg.oidc_issuer, "https://example.com");
|
||||
unsafe { unset("FURU_OIDC_ISSUER"); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_bool_field() {
|
||||
unsafe { set("FURU_AUTH_SSO_ENABLED", "true"); }
|
||||
let cfg = AppConfig::load();
|
||||
assert!(cfg.auth_sso_enabled);
|
||||
unsafe { unset("FURU_AUTH_SSO_ENABLED"); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_tracking_env() {
|
||||
unsafe { set("FURU_OIDC_ISSUER", "https://tracked.example.com"); }
|
||||
let mut cfg = AppConfig::default();
|
||||
let mut sources = ConfigSources::default();
|
||||
cfg.apply_env_overrides_tracked(&mut sources);
|
||||
assert_eq!(cfg.oidc_issuer, "https://tracked.example.com");
|
||||
assert_eq!(sources.oidc_issuer, ConfigSource::Env);
|
||||
assert_eq!(sources.database_url, ConfigSource::Default);
|
||||
unsafe { unset("FURU_OIDC_ISSUER"); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_source_codes() {
|
||||
assert_eq!(ConfigSource::Default.code(), "default");
|
||||
assert_eq!(ConfigSource::Database.code(), "database");
|
||||
assert_eq!(ConfigSource::Env.code(), "env");
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
mod phrases;
|
||||
|
||||
pub use phrases::Translations;
|
||||
|
||||
use cot::request::extractors::FromRequestHead;
|
||||
use cot::request::RequestHead;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lang enum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Lang {
|
||||
En,
|
||||
Ru,
|
||||
}
|
||||
|
||||
impl Lang {
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Lang::En => "en",
|
||||
Lang::Ru => "ru",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_code(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"en" => Some(Lang::En),
|
||||
"ru" => Some(Lang::Ru),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// translations! macro
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
macro_rules! translations {
|
||||
( $( $key:ident : $en:expr , $ru:expr );* $(;)? ) => {
|
||||
#[derive(Debug)]
|
||||
pub struct Translations {
|
||||
pub lang: $crate::i18n::Lang,
|
||||
$( pub $key: &'static str, )*
|
||||
}
|
||||
|
||||
static EN: Translations = Translations {
|
||||
lang: $crate::i18n::Lang::En,
|
||||
$( $key: $en, )*
|
||||
};
|
||||
|
||||
static RU: Translations = Translations {
|
||||
lang: $crate::i18n::Lang::Ru,
|
||||
$( $key: $ru, )*
|
||||
};
|
||||
|
||||
impl Translations {
|
||||
pub fn for_lang(lang: $crate::i18n::Lang) -> &'static Self {
|
||||
match lang {
|
||||
$crate::i18n::Lang::En => &EN,
|
||||
$crate::i18n::Lang::Ru => &RU,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use translations;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cookie helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COOKIE_NAME: &str = "furu_lang";
|
||||
|
||||
/// Build a `Set-Cookie` header value that persists the language choice for 1 year.
|
||||
pub fn lang_cookie(lang: Lang) -> String {
|
||||
format!("{COOKIE_NAME}={}; Path=/; SameSite=Lax; Max-Age=31536000", lang.code())
|
||||
}
|
||||
|
||||
/// Parse `furu_lang` from the `Cookie` request header.
|
||||
fn lang_from_cookie(headers: &cot::http::HeaderMap) -> Option<Lang> {
|
||||
let raw = headers.get(cot::http::header::COOKIE)?.to_str().ok()?;
|
||||
for part in raw.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(value) = part.strip_prefix("furu_lang=") {
|
||||
return Lang::from_code(value.trim());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accept-Language parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse the Accept-Language header and return the best matching `Lang`.
|
||||
fn parse_accept_language(header: &str) -> Option<Lang> {
|
||||
let mut langs: Vec<(&str, u16)> = header
|
||||
.split(',')
|
||||
.filter_map(|part| {
|
||||
let part = part.trim();
|
||||
let (tag, quality) = if let Some((tag, q)) = part.split_once(";q=") {
|
||||
let q = q.trim().parse::<f32>().ok()?;
|
||||
(tag.trim(), (q * 1000.0) as u16)
|
||||
} else {
|
||||
(part, 1000)
|
||||
};
|
||||
Some((tag, quality))
|
||||
})
|
||||
.collect();
|
||||
|
||||
langs.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
for (tag, _) in langs {
|
||||
let primary = tag.split('-').next().unwrap_or(tag);
|
||||
if let Some(lang) = Lang::from_code(primary) {
|
||||
return Some(lang);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Language resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn resolve_lang(headers: &cot::http::HeaderMap) -> Lang {
|
||||
// 1. Explicit cookie override.
|
||||
if let Some(lang) = lang_from_cookie(headers) {
|
||||
return lang;
|
||||
}
|
||||
|
||||
// 2. Accept-Language header.
|
||||
if let Some(value) = headers.get(cot::http::header::ACCEPT_LANGUAGE) {
|
||||
if let Ok(s) = value.to_str() {
|
||||
if let Some(lang) = parse_accept_language(s) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Default.
|
||||
Lang::En
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I18n extractor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct I18n {
|
||||
pub lang: Lang,
|
||||
pub t: &'static Translations,
|
||||
}
|
||||
|
||||
impl FromRequestHead for I18n {
|
||||
async fn from_request_head(head: &RequestHead) -> cot::Result<Self> {
|
||||
let lang = resolve_lang(&head.headers);
|
||||
Ok(I18n {
|
||||
lang,
|
||||
t: Translations::for_lang(lang),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lang_roundtrip() {
|
||||
assert_eq!(Lang::from_code("en"), Some(Lang::En));
|
||||
assert_eq!(Lang::from_code("ru"), Some(Lang::Ru));
|
||||
assert_eq!(Lang::from_code("de"), None);
|
||||
assert_eq!(Lang::En.code(), "en");
|
||||
assert_eq!(Lang::Ru.code(), "ru");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_simple_accept_language() {
|
||||
assert_eq!(parse_accept_language("ru"), Some(Lang::Ru));
|
||||
assert_eq!(parse_accept_language("en-US"), Some(Lang::En));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_weighted_accept_language() {
|
||||
assert_eq!(
|
||||
parse_accept_language("en-US,en;q=0.9,ru;q=0.8"),
|
||||
Some(Lang::En)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_accept_language("ru-RU,ru;q=0.9,en;q=0.5"),
|
||||
Some(Lang::Ru)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unknown_falls_through() {
|
||||
assert_eq!(
|
||||
parse_accept_language("de;q=1.0,ru;q=0.5"),
|
||||
Some(Lang::Ru)
|
||||
);
|
||||
assert_eq!(parse_accept_language("de,fr,ja"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_parsing() {
|
||||
let mut headers = cot::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
cot::http::header::COOKIE,
|
||||
"other=x; furu_lang=ru; foo=bar".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(lang_from_cookie(&headers), Some(Lang::Ru));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_missing() {
|
||||
let headers = cot::http::HeaderMap::new();
|
||||
assert_eq!(lang_from_cookie(&headers), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use super::translations;
|
||||
|
||||
translations! {
|
||||
// Global
|
||||
site_name: "furumusic" , "furumusic";
|
||||
|
||||
// Navigation / sidebar
|
||||
nav_admin: "admin" , "админка";
|
||||
nav_dashboard: "Dashboard" , "Панель управления";
|
||||
nav_debug: "Debug" , "Отладка";
|
||||
|
||||
// Index page
|
||||
index_heading: "furumusic" , "furumusic";
|
||||
index_status: "server is running" , "сервер запущен";
|
||||
|
||||
// Admin index
|
||||
admin_heading: "Admin" , "Админка";
|
||||
admin_debug_link: "Debug info" , "Отладочная информация";
|
||||
|
||||
// Debug page
|
||||
debug_heading: "Debug Information" , "Отладочная информация";
|
||||
debug_build_info: "Build Info" , "Информация о сборке";
|
||||
debug_app_config: "App Config" , "Конфигурация";
|
||||
debug_field: "Field" , "Поле";
|
||||
debug_value: "Value" , "Значение";
|
||||
debug_source: "Source" , "Источник";
|
||||
|
||||
// Navigation (settings)
|
||||
nav_settings: "Settings" , "Настройки";
|
||||
|
||||
// Debug page — DB status
|
||||
debug_db_status: "Database" , "База данных";
|
||||
debug_db_connected: "connected" , "подключена";
|
||||
debug_db_error: "error" , "ошибка";
|
||||
|
||||
// Settings page
|
||||
settings_heading: "Settings" , "Настройки";
|
||||
settings_oidc: "OIDC Configuration" , "Настройки OIDC";
|
||||
settings_save: "Save" , "Сохранить";
|
||||
settings_saved: "Settings saved." , "Настройки сохранены.";
|
||||
|
||||
// Auth settings
|
||||
settings_auth: "Authentication" , "Аутентификация";
|
||||
settings_password_login: "Password login" , "Вход по паролю";
|
||||
settings_sso_login: "SSO login" , "Вход через SSO";
|
||||
settings_oidc_button: "SSO button text" , "Текст кнопки SSO";
|
||||
|
||||
// Login page
|
||||
login_heading: "Sign in" , "Вход";
|
||||
login_username: "Username" , "Имя пользователя";
|
||||
login_password: "Password" , "Пароль";
|
||||
login_submit: "Sign in" , "Войти";
|
||||
login_disabled: "Login is currently disabled." , "Вход сейчас отключён.";
|
||||
login_invalid: "Invalid username or password." , "Неверное имя пользователя или пароль.";
|
||||
|
||||
// Logout
|
||||
nav_logout: "Logout" , "Выход";
|
||||
|
||||
// Setup page
|
||||
setup_heading: "Create Admin Account" , "Создание аккаунта администратора";
|
||||
setup_username: "Username" , "Имя пользователя";
|
||||
setup_password: "Password" , "Пароль";
|
||||
setup_confirm: "Confirm password" , "Подтверждение пароля";
|
||||
setup_submit: "Create" , "Создать";
|
||||
setup_mismatch: "Passwords do not match." , "Пароли не совпадают.";
|
||||
|
||||
// OIDC help
|
||||
settings_oidc_help: "Register this application with your identity provider. Use the callback URL shown below as the Redirect URI." , "Зарегистрируйте это приложение у вашего провайдера идентификации. Используйте указанный ниже callback URL в качестве Redirect URI.";
|
||||
settings_oidc_callback: "Callback URL" , "Callback URL";
|
||||
settings_oidc_issuer_help: "Base URL of the OIDC provider (e.g. https://accounts.google.com)" , "Базовый URL провайдера OIDC (напр. https://accounts.google.com)";
|
||||
settings_oidc_admin_groups: "Admin groups" , "Группы администраторов";
|
||||
settings_oidc_admin_groups_help: "Comma-separated OIDC group names that grant admin role (e.g. /admin,/furumusic-admins)" , "OIDC группы через запятую, дающие роль администратора (напр. /admin,/furumusic-admins)";
|
||||
|
||||
// User management
|
||||
nav_users: "Users" , "Пользователи";
|
||||
users_heading: "Users" , "Пользователи";
|
||||
users_add: "Add user" , "Добавить пользователя";
|
||||
users_username: "Username" , "Имя пользователя";
|
||||
users_email: "Email" , "Email";
|
||||
users_display_name: "Display name" , "Отображаемое имя";
|
||||
users_role: "Role" , "Роль";
|
||||
users_active: "Active" , "Активен";
|
||||
users_actions: "Actions" , "Действия";
|
||||
users_edit: "Edit" , "Редактировать";
|
||||
users_delete: "Delete" , "Удалить";
|
||||
users_delete_confirm: "Are you sure?" , "Вы уверены?";
|
||||
users_new_heading: "New user" , "Новый пользователь";
|
||||
users_edit_heading: "Edit user" , "Редактирование пользователя";
|
||||
users_password_hint: "Leave blank to keep current" , "Оставьте пустым, чтобы не менять";
|
||||
users_saved: "User saved." , "Пользователь сохранён.";
|
||||
|
||||
// OIDC login errors
|
||||
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
|
||||
login_sso_disabled: "SSO login is not configured." , "Вход через SSO не настроен.";
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
mod admin;
|
||||
mod api;
|
||||
mod auth;
|
||||
mod config;
|
||||
mod i18n;
|
||||
mod oidc;
|
||||
mod user;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cot::auth::PasswordVerificationResult;
|
||||
use cot::cli::CliMetadata;
|
||||
use cot::common_types::Password;
|
||||
use cot::config::{
|
||||
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig,
|
||||
SessionStoreTypeConfig,
|
||||
};
|
||||
use cot::db::Database;
|
||||
use cot::form::{Form, FormResult};
|
||||
use cot::html::Html;
|
||||
use cot::middleware::SessionMiddleware;
|
||||
use cot::project::RegisterAppsContext;
|
||||
use cot::request::extractors::{RequestForm, UrlQuery};
|
||||
use cot::response::IntoResponse;
|
||||
use cot::router::method::get;
|
||||
use cot::router::{Route, Router};
|
||||
use cot::session::Session;
|
||||
use cot::{App, AppBuilder, Body, Project, Template};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::i18n::{I18n, Translations};
|
||||
use crate::user::User;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn index(
|
||||
session: Session,
|
||||
db: Database,
|
||||
i18n: I18n,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let user = match auth::get_session_user(&session, &db).await {
|
||||
Some(u) => u,
|
||||
None => return Ok(auth::redirect("/login")),
|
||||
};
|
||||
let role_label = match user.role {
|
||||
auth::Role::Admin => format!(
|
||||
r#"{} | <a href="/admin/">{}</a>"#,
|
||||
user.role.code(),
|
||||
i18n.t.nav_admin
|
||||
),
|
||||
_ => user.role.code().to_owned(),
|
||||
};
|
||||
Html::new(format!(
|
||||
"<h1>{}</h1><p>{}</p><p>{}: {}</p>",
|
||||
i18n.t.index_heading, i18n.t.index_status, user.name, role_label
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetLangQuery {
|
||||
lang: String,
|
||||
next: Option<String>,
|
||||
}
|
||||
|
||||
async fn set_lang(
|
||||
UrlQuery(query): UrlQuery<SetLangQuery>,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
let lang = i18n::Lang::from_code(&query.lang).unwrap_or(i18n::Lang::En);
|
||||
let next = query.next.as_deref().unwrap_or("/");
|
||||
|
||||
let response = cot::http::Response::builder()
|
||||
.status(cot::http::StatusCode::SEE_OTHER)
|
||||
.header(cot::http::header::LOCATION, next)
|
||||
.header(cot::http::header::SET_COOKIE, i18n::lang_cookie(lang))
|
||||
.body(Body::fixed(""))
|
||||
.expect("valid response");
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "login.html")]
|
||||
struct LoginTemplate {
|
||||
t: &'static Translations,
|
||||
auth_password_enabled: bool,
|
||||
auth_sso_enabled: bool,
|
||||
oidc_button_text: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn login_page_handler(
|
||||
i18n: I18n,
|
||||
_startup_config: &AppConfig,
|
||||
db: Database,
|
||||
message: String,
|
||||
) -> cot::Result<Html> {
|
||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||
let template = LoginTemplate {
|
||||
t: i18n.t,
|
||||
auth_password_enabled: config.auth_password_enabled,
|
||||
auth_sso_enabled: config.auth_sso_enabled,
|
||||
oidc_button_text: config.oidc_button_text,
|
||||
message,
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Form)]
|
||||
struct LoginForm {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn logout_handler(session: Session) -> cot::Result<cot::response::Response> {
|
||||
auth::logout(&session).await?;
|
||||
Ok(auth::redirect("/login"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct FuruApp {
|
||||
config: Arc<AppConfig>,
|
||||
}
|
||||
|
||||
impl App for FuruApp {
|
||||
fn name(&self) -> &'static str {
|
||||
env!("CARGO_PKG_NAME")
|
||||
}
|
||||
|
||||
fn router(&self) -> Router {
|
||||
Router::with_urls([
|
||||
Route::with_handler_and_name(
|
||||
"/admin",
|
||||
get(|| async { Ok::<_, cot::Error>(auth::redirect("/admin/")) }),
|
||||
"admin_redirect",
|
||||
),
|
||||
Route::with_handler_and_name("/", index, "index"),
|
||||
Route::with_handler_and_name(
|
||||
"/login",
|
||||
get({
|
||||
let config = Arc::clone(&self.config);
|
||||
move |i18n: I18n, db: Database| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
// No users at all → redirect to first-run setup
|
||||
if User::count_all(&db).await.unwrap_or(0) == 0 {
|
||||
return Ok(auth::redirect("/admin/setup"));
|
||||
}
|
||||
login_page_handler(i18n, &config, db, String::new())
|
||||
.await?
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}).post({
|
||||
let config = Arc::clone(&self.config);
|
||||
move |i18n: I18n, db: Database, session: Session,
|
||||
form: RequestForm<LoginForm>| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
let RequestForm(result) = form;
|
||||
let data = match result {
|
||||
FormResult::Ok(data) => data,
|
||||
FormResult::ValidationError(_) => {
|
||||
let msg = i18n.t.login_invalid.to_owned();
|
||||
return login_page_handler(i18n, &config, db, msg)
|
||||
.await?
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Try to authenticate
|
||||
if let Ok(Some(user)) =
|
||||
User::get_by_username(&db, &data.username).await
|
||||
{
|
||||
if let Some(hash) = user.password_ref() {
|
||||
let password = Password::new(&data.password);
|
||||
match hash.verify(&password) {
|
||||
PasswordVerificationResult::Ok
|
||||
| PasswordVerificationResult::OkObsolete(_) => {
|
||||
auth::login(&session, user.id_val()).await?;
|
||||
return Ok(auth::redirect("/"));
|
||||
}
|
||||
PasswordVerificationResult::Invalid => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let msg = i18n.t.login_invalid.to_owned();
|
||||
login_page_handler(i18n, &config, db, msg)
|
||||
.await?
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}),
|
||||
"login",
|
||||
),
|
||||
Route::with_handler_and_name("/logout", get(logout_handler), "logout"),
|
||||
Route::with_handler_and_name("/set-lang", set_lang, "set_lang"),
|
||||
Route::with_handler_and_name(
|
||||
"/auth/oidc/start",
|
||||
get(oidc::oidc_start_handler),
|
||||
"oidc_start",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/auth/oidc/callback",
|
||||
get(oidc::oidc_callback_handler),
|
||||
"oidc_callback",
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct FuruProject {
|
||||
app_config: Arc<AppConfig>,
|
||||
}
|
||||
|
||||
impl Project for FuruProject {
|
||||
fn cli_metadata(&self) -> CliMetadata {
|
||||
CliMetadata {
|
||||
description: concat!(
|
||||
env!("CARGO_PKG_DESCRIPTION"),
|
||||
"\n\n",
|
||||
"CONFIGURATION\n",
|
||||
" All settings are available as FURU_-prefixed environment variables.\n",
|
||||
" Priority: env var > DB override > compiled default.\n",
|
||||
"\n",
|
||||
" Database (required for most features):\n",
|
||||
" FURU_DATABASE_URL PostgreSQL connection URL\n",
|
||||
" Example: postgres://user:pass@localhost/furumusic\n",
|
||||
"\n",
|
||||
" Server:\n",
|
||||
" FURU_LOG_LEVEL Tracing filter (default: info)\n",
|
||||
"\n",
|
||||
" Authentication:\n",
|
||||
" FURU_AUTH_PASSWORD_ENABLED Enable password login (default: true)\n",
|
||||
" FURU_AUTH_SSO_ENABLED Enable SSO/OIDC login (default: false)\n",
|
||||
" FURU_OIDC_ISSUER OIDC issuer URL\n",
|
||||
" FURU_OIDC_CLIENT_ID OIDC client ID\n",
|
||||
" FURU_OIDC_CLIENT_SECRET OIDC client secret\n",
|
||||
" FURU_OIDC_BUTTON_TEXT SSO button label (default: Sign in with SSO)\n",
|
||||
" FURU_OIDC_ADMIN_GROUPS OIDC groups that grant admin role\n",
|
||||
"\n",
|
||||
"QUICK START\n",
|
||||
" export FURU_DATABASE_URL=postgres://user:pass@localhost/furumusic\n",
|
||||
" furumusic run",
|
||||
),
|
||||
..cot::cli::metadata!()
|
||||
}
|
||||
}
|
||||
|
||||
fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
|
||||
let mut builder = ProjectConfig::builder();
|
||||
builder.debug(cfg!(debug_assertions));
|
||||
|
||||
if !self.app_config.database_url.is_empty() {
|
||||
builder.database(
|
||||
DatabaseConfig::builder()
|
||||
.url(self.app_config.database_url.as_str())
|
||||
.build(),
|
||||
);
|
||||
builder.middlewares(
|
||||
MiddlewareConfig::builder()
|
||||
.session(
|
||||
SessionMiddlewareConfig::builder()
|
||||
.store(
|
||||
SessionStoreConfig::builder()
|
||||
.store_type(SessionStoreTypeConfig::Database)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(builder.build())
|
||||
}
|
||||
|
||||
fn middlewares(
|
||||
&self,
|
||||
handler: cot::project::RootHandlerBuilder,
|
||||
context: &cot::project::MiddlewareContext,
|
||||
) -> cot::project::RootHandler {
|
||||
handler
|
||||
.middleware(
|
||||
SessionMiddleware::from_context(context)
|
||||
.same_site(cot::config::SameSite::Lax),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) {
|
||||
apps.register(cot::session::db::SessionApp::new());
|
||||
apps.register_with_views(
|
||||
FuruApp {
|
||||
config: Arc::clone(&self.app_config),
|
||||
},
|
||||
"",
|
||||
);
|
||||
apps.register_with_views(
|
||||
admin::AdminApp::new(Arc::clone(&self.app_config)),
|
||||
"/admin",
|
||||
);
|
||||
apps.register_with_views(api::ApiApp, "/api");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entrypoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cot::main]
|
||||
fn main() -> impl Project {
|
||||
let app_config = Arc::new(AppConfig::load());
|
||||
|
||||
// Initialise tracing subscriber with the configured log level.
|
||||
// FURU_LOG_LEVEL (or the default "info") is parsed as an EnvFilter
|
||||
// directive, so values like "debug", "warn,furumusic=trace" all work.
|
||||
let filter = tracing_subscriber::EnvFilter::try_new(&app_config.log_level)
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"WARNING: invalid FURU_LOG_LEVEL {:?}: {e}; falling back to \"info\"",
|
||||
app_config.log_level,
|
||||
);
|
||||
tracing_subscriber::EnvFilter::new("info")
|
||||
});
|
||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||
|
||||
tracing::info!("loaded config: {:?}", app_config);
|
||||
|
||||
FuruProject { app_config }
|
||||
}
|
||||
+578
@@ -0,0 +1,578 @@
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Instant;
|
||||
|
||||
use cot::db::Database;
|
||||
use cot::session::Session;
|
||||
use openidconnect::core::{CoreClient, CoreProviderMetadata};
|
||||
use openidconnect::{
|
||||
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
|
||||
EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
|
||||
};
|
||||
|
||||
use cot::request::RequestHead;
|
||||
use cot::request::extractors::FromRequestHead;
|
||||
|
||||
use crate::auth;
|
||||
use crate::config::AppConfig;
|
||||
use crate::i18n::I18n;
|
||||
use crate::user::{OidcLink, User};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request origin extractor (scheme + host from headers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extracts the origin (e.g. "http://127.0.0.1:3001") from the request so we
|
||||
/// can build the correct OIDC redirect URI.
|
||||
pub struct RequestOrigin(pub String);
|
||||
|
||||
impl FromRequestHead for RequestOrigin {
|
||||
async fn from_request_head(head: &RequestHead) -> cot::Result<Self> {
|
||||
let scheme = head
|
||||
.headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("http");
|
||||
|
||||
let host = head
|
||||
.headers
|
||||
.get(cot::http::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("localhost");
|
||||
|
||||
Ok(RequestOrigin(format!("{scheme}://{host}")))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session keys for OIDC flow state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SESSION_CSRF_STATE: &str = "oidc_csrf_state";
|
||||
const SESSION_NONCE: &str = "oidc_nonce";
|
||||
const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier";
|
||||
const SESSION_REDIRECT_URI: &str = "oidc_redirect_uri";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Concrete client type returned by `from_provider_metadata` + `set_redirect_uri`.
|
||||
/// The provider metadata discovery sets auth URL to EndpointSet, and token/userinfo
|
||||
/// endpoints to EndpointMaybeSet. The remaining endpoints stay EndpointNotSet.
|
||||
type ConfiguredClient = CoreClient<
|
||||
EndpointSet,
|
||||
EndpointNotSet,
|
||||
EndpointNotSet,
|
||||
EndpointNotSet,
|
||||
EndpointMaybeSet,
|
||||
EndpointMaybeSet,
|
||||
>;
|
||||
|
||||
struct CachedProvider {
|
||||
client: ConfiguredClient,
|
||||
fetched_at: Instant,
|
||||
config_hash: u64,
|
||||
}
|
||||
|
||||
static PROVIDER_CACHE: LazyLock<tokio::sync::RwLock<Option<CachedProvider>>> =
|
||||
LazyLock::new(|| tokio::sync::RwLock::new(None));
|
||||
|
||||
/// TTL for cached provider metadata (1 hour).
|
||||
const PROVIDER_TTL_SECS: u64 = 3600;
|
||||
|
||||
/// Compute a hash of the OIDC configuration values so we can detect changes.
|
||||
fn config_hash(issuer: &str, client_id: &str, client_secret: &str) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
issuer.hash(&mut hasher);
|
||||
client_id.hash(&mut hasher);
|
||||
client_secret.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn oidc_http_client() -> reqwest::Client {
|
||||
reqwest::ClientBuilder::new()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("valid reqwest client")
|
||||
}
|
||||
|
||||
/// Get or refresh the cached OIDC provider. Returns a cloned `ConfiguredClient`.
|
||||
async fn get_or_refresh_provider(
|
||||
config: &AppConfig,
|
||||
http: &reqwest::Client,
|
||||
) -> Result<ConfiguredClient, String> {
|
||||
let hash = config_hash(
|
||||
&config.oidc_issuer,
|
||||
&config.oidc_client_id,
|
||||
&config.oidc_client_secret,
|
||||
);
|
||||
|
||||
// Fast path: check if we have a valid cached provider.
|
||||
{
|
||||
let cache = PROVIDER_CACHE.read().await;
|
||||
if let Some(ref cached) = *cache {
|
||||
if cached.config_hash == hash
|
||||
&& cached.fetched_at.elapsed().as_secs() < PROVIDER_TTL_SECS
|
||||
{
|
||||
return Ok(cached.client.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: discover provider metadata + JWKS.
|
||||
// Strip /.well-known/openid-configuration suffix if the user pasted the
|
||||
// full discovery URL, so discover_async doesn't double-append it.
|
||||
let issuer = config
|
||||
.oidc_issuer
|
||||
.trim_end_matches('/')
|
||||
.strip_suffix("/.well-known/openid-configuration")
|
||||
.unwrap_or(config.oidc_issuer.trim_end_matches('/'))
|
||||
.to_owned();
|
||||
|
||||
let issuer_url = IssuerUrl::new(issuer)
|
||||
.map_err(|e| format!("invalid issuer URL: {e}"))?;
|
||||
|
||||
let metadata = CoreProviderMetadata::discover_async(issuer_url, http)
|
||||
.await
|
||||
.map_err(|e| format!("OIDC discovery failed: {e}"))?;
|
||||
|
||||
let client = CoreClient::from_provider_metadata(
|
||||
metadata,
|
||||
ClientId::new(config.oidc_client_id.clone()),
|
||||
Some(ClientSecret::new(config.oidc_client_secret.clone())),
|
||||
);
|
||||
|
||||
let mut cache = PROVIDER_CACHE.write().await;
|
||||
*cache = Some(CachedProvider {
|
||||
client: client.clone(),
|
||||
fetched_at: Instant::now(),
|
||||
config_hash: hash,
|
||||
});
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/oidc/start
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn oidc_start_handler(
|
||||
origin: RequestOrigin,
|
||||
i18n: I18n,
|
||||
db: Database,
|
||||
session: Session,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||
|
||||
// Validate SSO is enabled and configured.
|
||||
if !config.auth_sso_enabled
|
||||
|| config.oidc_issuer.is_empty()
|
||||
|| config.oidc_client_id.is_empty()
|
||||
|| config.oidc_client_secret.is_empty()
|
||||
{
|
||||
tracing::warn!("OIDC start requested but SSO is not configured");
|
||||
return redirect_login_with_error(i18n.t.login_sso_disabled);
|
||||
}
|
||||
|
||||
let http = oidc_http_client();
|
||||
let client = match get_or_refresh_provider(&config, &http).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC provider error: {e}");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
|
||||
// Build redirect URI from the actual request origin.
|
||||
let redirect_uri_str = format!("{}/auth/oidc/callback", origin.0);
|
||||
let redirect_url = RedirectUrl::new(redirect_uri_str.clone())
|
||||
.map_err(|e| cot::Error::internal(format!("bad redirect URI: {e}")))?;
|
||||
let client = client.set_redirect_uri(redirect_url);
|
||||
|
||||
// Build PKCE challenge.
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
// Build authorization URL.
|
||||
// The openid scope is added automatically by the crate; only add email + profile.
|
||||
let (auth_url, csrf_state, nonce) = client
|
||||
.authorize_url(
|
||||
openidconnect::AuthenticationFlow::<openidconnect::core::CoreResponseType>::AuthorizationCode,
|
||||
CsrfToken::new_random,
|
||||
Nonce::new_random,
|
||||
)
|
||||
.add_scope(Scope::new("email".to_string()))
|
||||
.add_scope(Scope::new("profile".to_string()))
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.url();
|
||||
|
||||
// Store OIDC flow state in the session.
|
||||
session
|
||||
.insert(SESSION_CSRF_STATE, csrf_state.secret().clone())
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
session
|
||||
.insert(SESSION_NONCE, nonce.secret().clone())
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
session
|
||||
.insert(SESSION_PKCE_VERIFIER, pkce_verifier.secret().clone())
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
session
|
||||
.insert(SESSION_REDIRECT_URI, redirect_uri_str)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
Ok(auth::redirect(auth_url.as_str()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/oidc/callback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct OidcCallbackQuery {
|
||||
code: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
pub async fn oidc_callback_handler(
|
||||
i18n: I18n,
|
||||
db: Database,
|
||||
session: Session,
|
||||
cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery<OidcCallbackQuery>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||
|
||||
// Retrieve OIDC flow state from the session.
|
||||
let saved_csrf: Option<String> = session
|
||||
.get(SESSION_CSRF_STATE)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let saved_nonce: Option<String> = session
|
||||
.get(SESSION_NONCE)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let saved_pkce: Option<String> = session
|
||||
.get(SESSION_PKCE_VERIFIER)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let saved_redirect_uri: Option<String> = session
|
||||
.get(SESSION_REDIRECT_URI)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
// Validate CSRF state.
|
||||
let Some(saved_csrf) = saved_csrf else {
|
||||
tracing::warn!("OIDC callback: no CSRF state in session");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
};
|
||||
if query.state != saved_csrf {
|
||||
tracing::warn!("OIDC callback: CSRF state mismatch");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
|
||||
let Some(nonce_str) = saved_nonce else {
|
||||
tracing::warn!("OIDC callback: no nonce in session");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
};
|
||||
let Some(pkce_str) = saved_pkce else {
|
||||
tracing::warn!("OIDC callback: no PKCE verifier in session");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
};
|
||||
|
||||
let nonce = Nonce::new(nonce_str);
|
||||
let pkce_verifier = PkceCodeVerifier::new(pkce_str);
|
||||
|
||||
let http = oidc_http_client();
|
||||
let client = match get_or_refresh_provider(&config, &http).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC provider error during callback: {e}");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
|
||||
// Restore the redirect URI that was used in the authorization request.
|
||||
let client = if let Some(ref uri) = saved_redirect_uri {
|
||||
let redirect_url = RedirectUrl::new(uri.clone())
|
||||
.map_err(|e| cot::Error::internal(format!("bad redirect URI from session: {e}")))?;
|
||||
client.set_redirect_uri(redirect_url)
|
||||
} else {
|
||||
client
|
||||
};
|
||||
|
||||
// Exchange code for tokens.
|
||||
let token_request = match client
|
||||
.exchange_code(AuthorizationCode::new(query.code.clone()))
|
||||
{
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC token endpoint not configured: {e}");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
let token_response = token_request
|
||||
.set_pkce_verifier(pkce_verifier)
|
||||
.request_async(&http)
|
||||
.await;
|
||||
|
||||
let token_response = match token_response {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC token exchange failed: {e}");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
|
||||
// Verify and extract ID token claims.
|
||||
use openidconnect::TokenResponse;
|
||||
let id_token = match token_response.id_token() {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
tracing::error!("OIDC response missing ID token");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
|
||||
let claims = match id_token.claims(&client.id_token_verifier(), &nonce) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC ID token verification failed: {e}");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
|
||||
let sub = claims.subject().to_string();
|
||||
let issuer = claims.issuer().to_string();
|
||||
let email = claims.email().map(|e| e.to_string());
|
||||
let name = claims
|
||||
.name()
|
||||
.and_then(|n| n.get(None))
|
||||
.map(|n| n.to_string());
|
||||
|
||||
// Extract groups from the raw JWT payload (second dot-separated segment).
|
||||
// The token is already signature-verified above, so we only need to decode
|
||||
// the payload to read the non-standard `groups` claim.
|
||||
let groups: Vec<String> = (|| {
|
||||
use base64::Engine;
|
||||
let raw = id_token.to_string();
|
||||
let payload_b64 = raw.split('.').nth(1)?;
|
||||
// JWT payloads use URL-safe base64; try without padding first, then
|
||||
// fall back to the padded variant (some providers add trailing '=').
|
||||
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload_b64)
|
||||
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64))
|
||||
.ok()?;
|
||||
let value: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||||
let arr = value.get("groups")?.as_array()?;
|
||||
Some(
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect(),
|
||||
)
|
||||
})()
|
||||
.unwrap_or_default();
|
||||
|
||||
tracing::info!(
|
||||
"OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}",
|
||||
config.oidc_admin_groups,
|
||||
);
|
||||
|
||||
// User provisioning logic.
|
||||
let user = match provision_user(
|
||||
&db,
|
||||
&issuer,
|
||||
&sub,
|
||||
email.as_deref(),
|
||||
name.as_deref(),
|
||||
&groups,
|
||||
&config.oidc_admin_groups,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC user provisioning failed: {e}");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
|
||||
// Log the user in.
|
||||
auth::login(&session, user.id_val()).await?;
|
||||
|
||||
// Clear OIDC session keys.
|
||||
let _: Option<String> = session
|
||||
.remove(SESSION_CSRF_STATE)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let _: Option<String> = session
|
||||
.remove(SESSION_NONCE)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let _: Option<String> = session
|
||||
.remove(SESSION_PKCE_VERIFIER)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let _: Option<String> = session
|
||||
.remove(SESSION_REDIRECT_URI)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
Ok(auth::redirect("/"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User provisioning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve the role based on OIDC group membership.
|
||||
/// If `admin_groups` is non-empty and any user group matches, return "admin";
|
||||
/// otherwise return "user".
|
||||
fn resolve_role(groups: &[String], admin_groups: &str) -> &'static str {
|
||||
if admin_groups.is_empty() {
|
||||
return auth::Role::User.code();
|
||||
}
|
||||
let admin_set: std::collections::HashSet<&str> = admin_groups
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if admin_set.is_empty() {
|
||||
return auth::Role::User.code();
|
||||
}
|
||||
for g in groups {
|
||||
if admin_set.contains(g.as_str()) {
|
||||
return auth::Role::Admin.code();
|
||||
}
|
||||
}
|
||||
auth::Role::User.code()
|
||||
}
|
||||
|
||||
async fn provision_user(
|
||||
db: &Database,
|
||||
issuer: &str,
|
||||
sub: &str,
|
||||
email: Option<&str>,
|
||||
name: Option<&str>,
|
||||
groups: &[String],
|
||||
admin_groups: &str,
|
||||
) -> Result<User, String> {
|
||||
let role = resolve_role(groups, admin_groups);
|
||||
|
||||
// 1. Check for existing OIDC link.
|
||||
if let Some(mut link) = OidcLink::find_by_issuer_sub(db, issuer, sub)
|
||||
.await
|
||||
.map_err(|e| format!("DB error finding OIDC link: {e}"))?
|
||||
{
|
||||
// Fetch the linked user.
|
||||
match User::get_by_id(db, link.user_id()).await {
|
||||
Ok(Some(mut user)) => {
|
||||
// Update cached claims.
|
||||
link.update_claims(db, email, name)
|
||||
.await
|
||||
.map_err(|e| format!("DB error updating OIDC link: {e}"))?;
|
||||
|
||||
// Always update role on login.
|
||||
user.update_role(db, role)
|
||||
.await
|
||||
.map_err(|e| format!("DB error updating user role: {e}"))?;
|
||||
|
||||
return Ok(user);
|
||||
}
|
||||
Ok(None) => {
|
||||
// User was deleted but the OIDC link is stale — remove it
|
||||
// and fall through to re-create the user below.
|
||||
tracing::warn!(
|
||||
"OIDC link points to deleted user {}; removing stale link",
|
||||
link.user_id(),
|
||||
);
|
||||
link.delete(db)
|
||||
.await
|
||||
.map_err(|e| format!("DB error deleting stale OIDC link: {e}"))?;
|
||||
}
|
||||
Err(e) => return Err(format!("DB error fetching user: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// 2. No existing link — try to find a user by email.
|
||||
if let Some(email_str) = email {
|
||||
if let Some(mut user) = User::get_by_email(db, email_str)
|
||||
.await
|
||||
.map_err(|e| format!("DB error finding user by email: {e}"))?
|
||||
{
|
||||
// Create OIDC link for existing user.
|
||||
OidcLink::create_link(db, user.id_val(), issuer, sub, email, name)
|
||||
.await
|
||||
.map_err(|e| format!("DB error creating OIDC link: {e}"))?;
|
||||
|
||||
user.update_role(db, role)
|
||||
.await
|
||||
.map_err(|e| format!("DB error updating user role: {e}"))?;
|
||||
|
||||
return Ok(user);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create a brand-new user + OIDC link.
|
||||
// Generate a unique username from the sub or email.
|
||||
let username = if let Some(email_str) = email {
|
||||
email_str.split('@').next().unwrap_or(sub).to_owned()
|
||||
} else {
|
||||
sub.to_owned()
|
||||
};
|
||||
|
||||
// Ensure username uniqueness by appending a suffix if needed.
|
||||
let mut candidate = username.clone();
|
||||
let mut suffix = 0u32;
|
||||
loop {
|
||||
match User::get_by_username(db, &candidate).await {
|
||||
Ok(None) => break,
|
||||
Ok(Some(_)) => {
|
||||
suffix += 1;
|
||||
candidate = format!("{username}_{suffix}");
|
||||
}
|
||||
Err(e) => return Err(format!("DB error checking username: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
let user = User::create_oidc(db, &candidate, email, name, role)
|
||||
.await
|
||||
.map_err(|e| format!("DB error creating user: {e}"))?;
|
||||
|
||||
OidcLink::create_link(db, user.id_val(), issuer, sub, email, name)
|
||||
.await
|
||||
.map_err(|e| format!("DB error creating OIDC link: {e}"))?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn redirect_login_with_error(message: &str) -> cot::Result<cot::response::Response> {
|
||||
let encoded = urlencoded(message);
|
||||
Ok(auth::redirect(&format!("/login?error={encoded}")))
|
||||
}
|
||||
|
||||
/// Minimal percent-encoding for query parameter values.
|
||||
fn urlencoded(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() * 2);
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
_ => {
|
||||
out.push('%');
|
||||
out.push_str(&format!("{b:02X}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
use cot::auth::PasswordHash;
|
||||
use cot::common_types::Password;
|
||||
use cot::db::{Auto, Database, LimitedString, Model};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cot::db::model]
|
||||
pub struct User {
|
||||
#[model(primary_key)]
|
||||
id: Auto<i64>,
|
||||
#[model(unique)]
|
||||
username: LimitedString<255>,
|
||||
password: Option<PasswordHash>,
|
||||
email: Option<LimitedString<255>>,
|
||||
display_name: Option<LimitedString<255>>,
|
||||
avatar_url: Option<String>,
|
||||
role: LimitedString<32>,
|
||||
is_active: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User helper methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl User {
|
||||
/// List all users.
|
||||
pub async fn list_all(db: &Database) -> cot::db::Result<Vec<Self>> {
|
||||
Self::objects().all(db).await
|
||||
}
|
||||
|
||||
/// Get a user by primary key.
|
||||
pub async fn get_by_id(db: &Database, user_id: i64) -> cot::db::Result<Option<Self>> {
|
||||
Self::get_by_primary_key(db, Auto::Fixed(user_id)).await
|
||||
}
|
||||
|
||||
/// Create a new user and insert it into the database.
|
||||
pub async fn create(
|
||||
db: &Database,
|
||||
username: &str,
|
||||
email: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
password: &str,
|
||||
role: &str,
|
||||
) -> cot::db::Result<Self> {
|
||||
let hash = PasswordHash::from_password(&Password::new(password));
|
||||
let mut user = Self {
|
||||
id: Auto::auto(),
|
||||
username: LimitedString::new(username).unwrap(),
|
||||
password: Some(hash),
|
||||
email: email.map(|e| LimitedString::new(e).unwrap()),
|
||||
display_name: display_name.map(|d| LimitedString::new(d).unwrap()),
|
||||
avatar_url: None,
|
||||
role: LimitedString::new(role).unwrap(),
|
||||
is_active: true,
|
||||
};
|
||||
user.insert(db).await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Update an existing user. If `new_password` is `Some`, the password hash
|
||||
/// is replaced; otherwise the existing hash is kept.
|
||||
pub async fn update_fields(
|
||||
&mut self,
|
||||
db: &Database,
|
||||
username: &str,
|
||||
email: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
new_password: Option<&str>,
|
||||
role: &str,
|
||||
) -> cot::db::Result<()> {
|
||||
self.username = LimitedString::new(username).unwrap();
|
||||
self.email = email.map(|e| LimitedString::new(e).unwrap());
|
||||
self.display_name = display_name.map(|d| LimitedString::new(d).unwrap());
|
||||
if let Some(pw) = new_password {
|
||||
self.password = Some(PasswordHash::from_password(&Password::new(pw)));
|
||||
}
|
||||
self.role = LimitedString::new(role).unwrap();
|
||||
self.save(db).await
|
||||
}
|
||||
|
||||
/// Look up a user by username.
|
||||
pub async fn get_by_username(db: &Database, username: &str) -> cot::db::Result<Option<Self>> {
|
||||
let Ok(username) = LimitedString::<255>::new(username) else {
|
||||
return Ok(None);
|
||||
};
|
||||
cot::db::query!(User, $username == username).get(db).await
|
||||
}
|
||||
|
||||
/// Count all users in the database.
|
||||
pub async fn count_all(db: &Database) -> cot::db::Result<u64> {
|
||||
Self::objects().count(db).await
|
||||
}
|
||||
|
||||
/// Return a reference to the password hash, if set.
|
||||
pub fn password_ref(&self) -> Option<&PasswordHash> {
|
||||
self.password.as_ref()
|
||||
}
|
||||
|
||||
/// Parse the stored role code into a `Role`, defaulting to `User`.
|
||||
pub fn role(&self) -> crate::auth::Role {
|
||||
crate::auth::Role::from_code(&self.role).unwrap_or(crate::auth::Role::User)
|
||||
}
|
||||
|
||||
/// Delete this user by primary key.
|
||||
pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> {
|
||||
cot::db::query!(User, $id == Auto::Fixed(user_id)).delete(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Accessor helpers for templates
|
||||
pub fn id_val(&self) -> i64 {
|
||||
self.id.unwrap()
|
||||
}
|
||||
pub fn username_str(&self) -> &str {
|
||||
&self.username
|
||||
}
|
||||
pub fn email_str(&self) -> String {
|
||||
self.email.as_ref().map(|e| e.to_string()).unwrap_or_default()
|
||||
}
|
||||
pub fn display_name_str(&self) -> String {
|
||||
self.display_name.as_ref().map(|d| d.to_string()).unwrap_or_default()
|
||||
}
|
||||
pub fn role_str(&self) -> &str {
|
||||
&self.role
|
||||
}
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.is_active
|
||||
}
|
||||
|
||||
/// Create a user without a password (for OIDC-only accounts).
|
||||
pub async fn create_oidc(
|
||||
db: &Database,
|
||||
username: &str,
|
||||
email: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
role: &str,
|
||||
) -> cot::db::Result<Self> {
|
||||
let mut user = Self {
|
||||
id: Auto::auto(),
|
||||
username: LimitedString::new(username).unwrap(),
|
||||
password: None,
|
||||
email: email.map(|e| LimitedString::new(e).unwrap()),
|
||||
display_name: display_name.map(|d| LimitedString::new(d).unwrap()),
|
||||
avatar_url: None,
|
||||
role: LimitedString::new(role).unwrap(),
|
||||
is_active: true,
|
||||
};
|
||||
user.insert(db).await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Update the user's role and persist the change.
|
||||
pub async fn update_role(&mut self, db: &Database, role: &str) -> cot::db::Result<()> {
|
||||
self.role = LimitedString::new(role).unwrap();
|
||||
self.save(db).await
|
||||
}
|
||||
|
||||
/// Find a user by email address.
|
||||
pub async fn get_by_email(db: &Database, email: &str) -> cot::db::Result<Option<Self>> {
|
||||
let Ok(email) = LimitedString::<255>::new(email) else {
|
||||
return Ok(None);
|
||||
};
|
||||
cot::db::query!(User, $email == Some(email)).get(db).await
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OidcLink model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cot::db::model]
|
||||
pub struct OidcLink {
|
||||
#[model(primary_key)]
|
||||
id: Auto<i64>,
|
||||
user_id: i64,
|
||||
issuer: LimitedString<255>,
|
||||
sub: LimitedString<255>,
|
||||
email: Option<LimitedString<255>>,
|
||||
name: Option<LimitedString<255>>,
|
||||
avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OidcLink helper methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl OidcLink {
|
||||
/// Find an OIDC link by issuer + subject.
|
||||
pub async fn find_by_issuer_sub(
|
||||
db: &Database,
|
||||
issuer: &str,
|
||||
sub: &str,
|
||||
) -> cot::db::Result<Option<Self>> {
|
||||
let Ok(issuer) = LimitedString::<255>::new(issuer) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Ok(sub) = LimitedString::<255>::new(sub) else {
|
||||
return Ok(None);
|
||||
};
|
||||
cot::db::query!(OidcLink, $issuer == issuer && $sub == sub)
|
||||
.get(db)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a new OIDC link for a user.
|
||||
pub async fn create_link(
|
||||
db: &Database,
|
||||
user_id: i64,
|
||||
issuer: &str,
|
||||
sub: &str,
|
||||
email: Option<&str>,
|
||||
name: Option<&str>,
|
||||
) -> cot::db::Result<Self> {
|
||||
let mut link = Self {
|
||||
id: Auto::auto(),
|
||||
user_id,
|
||||
issuer: LimitedString::new(issuer).unwrap(),
|
||||
sub: LimitedString::new(sub).unwrap(),
|
||||
email: email.map(|e| LimitedString::new(e).unwrap()),
|
||||
name: name.map(|n| LimitedString::new(n).unwrap()),
|
||||
avatar_url: None,
|
||||
};
|
||||
link.insert(db).await?;
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
/// Update cached claims (email, name) on an existing link.
|
||||
pub async fn update_claims(
|
||||
&mut self,
|
||||
db: &Database,
|
||||
email: Option<&str>,
|
||||
name: Option<&str>,
|
||||
) -> cot::db::Result<()> {
|
||||
self.email = email.map(|e| LimitedString::new(e).unwrap());
|
||||
self.name = name.map(|n| LimitedString::new(n).unwrap());
|
||||
self.save(db).await
|
||||
}
|
||||
|
||||
/// Delete this OIDC link by primary key.
|
||||
pub async fn delete(self, db: &Database) -> cot::db::Result<()> {
|
||||
let link_id = self.id;
|
||||
cot::db::query!(OidcLink, $id == link_id).delete(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accessor for the linked user ID.
|
||||
pub fn user_id(&self) -> i64 {
|
||||
self.user_id
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub mod db_migrations {
|
||||
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
|
||||
use cot::db::{DatabaseField, Identifier, LimitedString};
|
||||
use cot::auth::PasswordHash;
|
||||
|
||||
// -- M0003: create furumusic__user -------------------------------------
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0003CreateUser;
|
||||
|
||||
impl migrations::Migration for M0003CreateUser {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0003_create_user";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0002_rename_config_table",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__user"))
|
||||
.fields(&[
|
||||
Field::new(
|
||||
Identifier::new("id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(
|
||||
Identifier::new("username"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
)
|
||||
.unique(),
|
||||
Field::new(
|
||||
Identifier::new("password"),
|
||||
<PasswordHash as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(
|
||||
Identifier::new("email"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(
|
||||
Identifier::new("display_name"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(
|
||||
Identifier::new("avatar_url"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(
|
||||
Identifier::new("role"),
|
||||
<LimitedString<32> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("is_active"),
|
||||
<bool as DatabaseField>::TYPE,
|
||||
),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
|
||||
// -- M0004: create furumusic__oidc_link --------------------------------
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0004CreateOidcLink;
|
||||
|
||||
impl migrations::Migration for M0004CreateOidcLink {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0004_create_oidc_link";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0003_create_user",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::create_model()
|
||||
.table_name(Identifier::new("furumusic__oidc_link"))
|
||||
.fields(&[
|
||||
Field::new(
|
||||
Identifier::new("id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
)
|
||||
.primary_key()
|
||||
.auto(),
|
||||
Field::new(
|
||||
Identifier::new("user_id"),
|
||||
<i64 as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("issuer"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("sub"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
),
|
||||
Field::new(
|
||||
Identifier::new("email"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(
|
||||
Identifier::new("name"),
|
||||
<LimitedString<255> as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
Field::new(
|
||||
Identifier::new("avatar_url"),
|
||||
<String as DatabaseField>::TYPE,
|
||||
)
|
||||
.set_null(true),
|
||||
])
|
||||
.build(),
|
||||
];
|
||||
}
|
||||
|
||||
// -- M0005: indexes on furumusic__oidc_link ----------------------------
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn create_oidc_link_indexes(
|
||||
ctx: migrations::MigrationContext<'_>,
|
||||
) -> cot::db::Result<()> {
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE UNIQUE INDEX idx_oidc_link_issuer_sub \
|
||||
ON furumusic__oidc_link (issuer, sub)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX idx_oidc_link_user_id \
|
||||
ON furumusic__oidc_link (user_id)",
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0005OidcLinkIndexes;
|
||||
|
||||
impl migrations::Migration for M0005OidcLinkIndexes {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0005_oidc_link_indexes";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[
|
||||
migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0004_create_oidc_link",
|
||||
),
|
||||
];
|
||||
const OPERATIONS: &'static [Operation] = &[
|
||||
Operation::custom(create_oidc_link_indexes).build(),
|
||||
];
|
||||
}
|
||||
|
||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||
&M0003CreateUser,
|
||||
&M0004CreateOidcLink,
|
||||
&M0005OidcLinkIndexes,
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user