Init
This commit is contained in:
Generated
+55
@@ -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
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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
@@ -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"),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user