This commit is contained in:
2026-05-21 16:21:42 +03:00
parent 16abe754af
commit b8afaa1864
9 changed files with 136 additions and 19 deletions
Generated
+55
View File
@@ -38,6 +38,21 @@ dependencies = [
"memchr",
]
[[package]]
name = "aide"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6966317188cdfe54c58c0900a195d021294afb3ece9b7073d09e4018dbb1e3a2"
dependencies = [
"cfg-if",
"indexmap 2.14.0",
"schemars 0.9.0",
"serde",
"serde_json",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -509,6 +524,7 @@ name = "cot"
version = "0.6.0"
dependencies = [
"ahash",
"aide",
"askama",
"async-trait",
"axum",
@@ -536,12 +552,14 @@ dependencies = [
"multer",
"password-auth",
"pin-project-lite",
"schemars 0.9.0",
"sea-query",
"sea-query-binder",
"serde",
"serde_json",
"sqlx",
"subtle",
"swagger-ui-redist",
"thiserror 2.0.18",
"time",
"tokio",
@@ -580,6 +598,7 @@ dependencies = [
"http-body",
"http-body-util",
"indexmap 2.14.0",
"schemars 0.9.0",
"serde",
"serde_html_form",
"serde_json",
@@ -1066,6 +1085,7 @@ dependencies = [
"cot",
"openidconnect",
"reqwest",
"schemars 0.9.0",
"serde",
"serde_json",
"tokio",
@@ -2524,7 +2544,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"indexmap 2.14.0",
"ref-cast",
"schemars_derive",
"serde",
"serde_json",
]
@@ -2541,6 +2563,18 @@ dependencies = [
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5016d94c77c6d32f0b8e08b781f7dc8a90c2007d4e77472cc2807bc10a8438fe"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -2628,6 +2662,17 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_derive_internals"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_html_form"
version = "0.4.0"
@@ -3053,6 +3098,16 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swagger-ui-redist"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cfaaf9b731f41f15b1a7d01419e6ff9ff59eef7bc6c04eae04911e3dbe5e17a"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "syn"
version = "2.0.117"
+2 -1
View File
@@ -5,7 +5,8 @@ edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
[dependencies]
cot = { path = "../cot/cot", features = ["postgres", "json"] }
cot = { path = "../cot/cot", features = ["postgres", "json", "openapi", "swagger-ui"] }
schemars = { version = "0.9", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
openidconnect = "4.0"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
+8 -1
View File
@@ -126,7 +126,13 @@ Helpers in `api/mod.rs`:
|-------|--------|-------------|
| `/api/me` | GET | Current user (id, name, role) or 401 |
To add a new API endpoint: write an async handler returning `cot::Result<cot::response::Response>`, use `json_ok`/`json_error`, add a `Route` in `ApiApp::router()`.
**Swagger UI** is available at `/swagger/` when `FURU_SWAGGER_ENABLED=true`. The OpenAPI spec is auto-generated from handler types.
To add a new API endpoint:
1. Define request/response structs with `#[derive(Serialize, JsonSchema)]`
2. Write an async handler, return `Json(response).into_response()`
3. Add a `Route::with_api_handler_and_name(…, api_get(handler), …)` in `ApiApp::router()`
4. The endpoint appears automatically in Swagger UI
### Admin panel (`src/admin/`)
@@ -191,3 +197,4 @@ All prefixed with `FURU_`. Priority: env var > DB override > compiled default.
| `FURU_OIDC_CLIENT_SECRET` | OIDC client secret | *(empty)* |
| `FURU_OIDC_BUTTON_TEXT` | SSO button label | `Sign in with SSO` |
| `FURU_OIDC_ADMIN_GROUPS` | Comma-separated OIDC groups that grant admin | *(empty)* |
| `FURU_SWAGGER_ENABLED` | Serve Swagger UI at `/swagger/` | `false` |
+9 -1
View File
@@ -86,6 +86,7 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<Co
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()),
entry!(swagger_enabled, config.swagger_enabled.to_string(), defaults.swagger_enabled.to_string()),
]
}
@@ -155,6 +156,8 @@ struct SettingsTemplate {
oidc_client_secret_source: &'static str,
oidc_admin_groups: String,
oidc_admin_groups_source: &'static str,
swagger_enabled: bool,
swagger_enabled_source: &'static str,
}
pub async fn settings_handler(
@@ -185,6 +188,8 @@ pub async fn settings_handler(
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(),
swagger_enabled: config.swagger_enabled,
swagger_enabled_source: sources.swagger_enabled.code(),
};
Ok(Html::new(template.render()?))
}
@@ -198,6 +203,7 @@ pub struct OidcSettingsForm {
oidc_client_id: String,
oidc_client_secret: String,
oidc_admin_groups: String,
swagger_enabled: Option<String>,
}
pub async fn settings_submit(
@@ -212,7 +218,8 @@ pub async fn settings_submit(
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] = [
let swagger = if data.swagger_enabled.is_some() { "true" } else { "false" };
let fields: [(&str, &str); 8] = [
("auth_password_enabled", pw_enabled),
("auth_sso_enabled", sso_enabled),
("oidc_button_text", &data.oidc_button_text),
@@ -220,6 +227,7 @@ pub async fn settings_submit(
("oidc_client_id", &data.oidc_client_id),
("oidc_client_secret", &data.oidc_client_secret),
("oidc_admin_groups", &data.oidc_admin_groups),
("swagger_enabled", swagger),
];
for (key, value) in fields {
let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned());
+20 -16
View File
@@ -1,23 +1,19 @@
use cot::db::Database;
use cot::router::method::get;
use cot::json::Json;
use cot::response::IntoResponse;
use cot::router::method::openapi::api_get;
use cot::router::{Route, Router};
use cot::session::Session;
use cot::{App, Body};
use schemars::JsonSchema;
use serde::Serialize;
use crate::auth;
// ---------------------------------------------------------------------------
// JSON response helpers
// JSON error helper
// ---------------------------------------------------------------------------
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()
@@ -31,6 +27,13 @@ fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Re
// GET /api/me
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize, JsonSchema)]
struct MeResponse {
id: i64,
name: String,
role: String,
}
async fn me_handler(
session: Session,
db: Database,
@@ -42,11 +45,12 @@ async fn me_handler(
));
};
Ok(json_ok(&serde_json::json!({
"id": user.id,
"name": user.name,
"role": user.role.code(),
})))
Json(MeResponse {
id: user.id,
name: user.name,
role: user.role.code().to_owned(),
})
.into_response()
}
// ---------------------------------------------------------------------------
@@ -62,7 +66,7 @@ impl App for ApiApp {
fn router(&self) -> Router {
Router::with_urls([
Route::with_handler_and_name("/me", get(me_handler), "api_me"),
Route::with_api_handler_and_name("/me", api_get(me_handler), "api_me"),
])
}
}
+7
View File
@@ -127,6 +127,7 @@ pub struct ConfigSources {
pub auth_sso_enabled: ConfigSource,
pub oidc_button_text: ConfigSource,
pub oidc_admin_groups: ConfigSource,
pub swagger_enabled: ConfigSource,
}
impl Default for ConfigSources {
@@ -141,6 +142,7 @@ impl Default for ConfigSources {
auth_sso_enabled: ConfigSource::Default,
oidc_button_text: ConfigSource::Default,
oidc_admin_groups: ConfigSource::Default,
swagger_enabled: ConfigSource::Default,
}
}
}
@@ -223,6 +225,8 @@ pub struct AppConfig {
pub oidc_button_text: String,
/// Comma-separated list of OIDC group names that grant admin role.
pub oidc_admin_groups: String,
/// Whether the Swagger UI is served at /swagger/.
pub swagger_enabled: bool,
}
impl Default for AppConfig {
@@ -237,6 +241,7 @@ impl Default for AppConfig {
auth_sso_enabled: false,
oidc_button_text: "Sign in with SSO".into(),
oidc_admin_groups: String::new(),
swagger_enabled: false,
}
}
}
@@ -252,6 +257,7 @@ impl_env_overrides!(
auth_sso_enabled,
oidc_button_text,
oidc_admin_groups,
swagger_enabled,
);
impl AppConfig {
@@ -317,6 +323,7 @@ impl AppConfig {
apply_db_field!(auth_sso_enabled);
apply_db_field!(oidc_button_text);
apply_db_field!(oidc_admin_groups);
apply_db_field!(swagger_enabled);
}
}
+5
View File
@@ -89,6 +89,11 @@ translations! {
users_password_hint: "Leave blank to keep current" , "Оставьте пустым, чтобы не менять";
users_saved: "User saved." , "Пользователь сохранён.";
// API settings
settings_api: "API" , "API";
settings_swagger: "Swagger UI" , "Swagger UI";
settings_swagger_help: "Serves interactive API docs at /swagger/ (requires restart)" , "Интерактивная документация API на /swagger/ (требуется перезапуск)";
// OIDC login errors
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
login_sso_disabled: "SSO login is not configured." , "Вход через SSO не настроен.";
+16
View File
@@ -19,6 +19,7 @@ use cot::db::Database;
use cot::form::{Form, FormResult};
use cot::html::Html;
use cot::middleware::SessionMiddleware;
use cot::static_files::StaticFilesMiddleware;
use cot::project::RegisterAppsContext;
use cot::request::extractors::{RequestForm, UrlQuery};
use cot::response::IntoResponse;
@@ -148,6 +149,11 @@ impl App for FuruApp {
get(|| async { Ok::<_, cot::Error>(auth::redirect("/admin/")) }),
"admin_redirect",
),
Route::with_handler_and_name(
"/swagger",
get(|| async { Ok::<_, cot::Error>(auth::redirect("/swagger/")) }),
"swagger_redirect",
),
Route::with_handler_and_name("/", index, "index"),
Route::with_handler_and_name(
"/login",
@@ -258,6 +264,9 @@ impl Project for FuruProject {
" 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",
" API:\n",
" FURU_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n",
"\n",
"QUICK START\n",
" export FURU_DATABASE_URL=postgres://user:pass@localhost/furumusic\n",
" furumusic run",
@@ -300,6 +309,7 @@ impl Project for FuruProject {
context: &cot::project::MiddlewareContext,
) -> cot::project::RootHandler {
handler
.middleware(StaticFilesMiddleware::from_context(context))
.middleware(
SessionMiddleware::from_context(context)
.same_site(cot::config::SameSite::Lax),
@@ -320,6 +330,12 @@ impl Project for FuruProject {
"/admin",
);
apps.register_with_views(api::ApiApp, "/api");
if self.app_config.swagger_enabled {
apps.register_with_views(
cot::openapi::swagger_ui::SwaggerUi::new(),
"/swagger",
);
}
}
}
+14
View File
@@ -68,6 +68,20 @@
<td><span class="badge badge-{{ oidc_admin_groups_source }}">{{ oidc_admin_groups_source }}</span></td>
</tr>
</table>
<h2>{{ t.settings_api }}</h2>
<table>
<tr>
<th>{{ t.debug_field }}</th>
<th>{{ t.debug_value }}</th>
<th>{{ t.debug_source }}</th>
</tr>
<tr>
<td><label for="swagger_enabled">{{ t.settings_swagger }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_swagger_help }}</span></td>
<td><input type="checkbox" name="swagger_enabled" id="swagger_enabled" value="on"{% if swagger_enabled %} checked{% endif %}></td>
<td><span class="badge badge-{{ swagger_enabled_source }}">{{ swagger_enabled_source }}</span></td>
</tr>
</table>
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
</form>
{% endblock content %}