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:
+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 не настроен.";
|
||||
}
|
||||
Reference in New Issue
Block a user