Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b69cc0fc0 | |||
| 624cadab64 | |||
| a8756c95de | |||
| 952d11e6f5 |
Generated
+1
-1
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.2.15"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.3.1"
|
version = "0.4.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||||
|
|
||||||
|
|||||||
@@ -1324,6 +1324,9 @@ impl App for AdminApp {
|
|||||||
all.extend(cot::db::migrations::wrap_migrations(
|
all.extend(cot::db::migrations::wrap_migrations(
|
||||||
crate::scheduler::db_migrations::MIGRATIONS,
|
crate::scheduler::db_migrations::MIGRATIONS,
|
||||||
));
|
));
|
||||||
|
all.extend(cot::db::migrations::wrap_migrations(
|
||||||
|
crate::auth::db_migrations::MIGRATIONS,
|
||||||
|
));
|
||||||
all
|
all
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,17 +96,11 @@ fn generate_missing_variants_sync(
|
|||||||
image::ExtendedColorType::Rgb8,
|
image::ExtendedColorType::Rgb8,
|
||||||
);
|
);
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => crate::metrics::record_agent_cover_variant(
|
Ok(()) => {
|
||||||
variant.name,
|
crate::metrics::record_agent_cover_variant(variant.name, "ok", start.elapsed())
|
||||||
"ok",
|
}
|
||||||
start.elapsed(),
|
|
||||||
),
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
crate::metrics::record_agent_cover_variant(
|
crate::metrics::record_agent_cover_variant(variant.name, "error", start.elapsed());
|
||||||
variant.name,
|
|
||||||
"error",
|
|
||||||
start.elapsed(),
|
|
||||||
);
|
|
||||||
return Err(err.into());
|
return Err(err.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+525
-11
@@ -1,14 +1,27 @@
|
|||||||
|
use std::marker::PhantomData;
|
||||||
|
|
||||||
|
use cot::aide::openapi::{
|
||||||
|
MediaType, Operation, ReferenceOr, RequestBody, Response as OpenApiResponse, SchemaObject,
|
||||||
|
StatusCode as OpenApiStatusCode,
|
||||||
|
};
|
||||||
|
use cot::auth::PasswordVerificationResult;
|
||||||
|
use cot::common_types::Password;
|
||||||
use cot::db::Database;
|
use cot::db::Database;
|
||||||
|
use cot::http::StatusCode;
|
||||||
|
use cot::http::header::CONTENT_TYPE;
|
||||||
use cot::json::Json;
|
use cot::json::Json;
|
||||||
|
use cot::openapi::{AsApiOperation, RouteContext};
|
||||||
use cot::response::IntoResponse;
|
use cot::response::IntoResponse;
|
||||||
use cot::router::method::openapi::api_get;
|
use cot::router::method::openapi::{api_get, api_post};
|
||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
use cot::{App, Body};
|
use cot::{App, Body, RequestHandler};
|
||||||
use schemars::JsonSchema;
|
use schemars::{JsonSchema, SchemaGenerator};
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::user::User;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// JSON error helper
|
// JSON error helper
|
||||||
@@ -23,6 +36,199 @@ fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Re
|
|||||||
.expect("valid response")
|
.expect("valid response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct DocumentedJsonHandler<H, Req, Res> {
|
||||||
|
handler: H,
|
||||||
|
summary: &'static str,
|
||||||
|
_marker: PhantomData<fn(Req) -> Res>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct DocumentedResponseHandler<H, Res> {
|
||||||
|
handler: H,
|
||||||
|
summary: &'static str,
|
||||||
|
_marker: PhantomData<fn() -> Res>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn documented_json_handler<Req, Res, H>(
|
||||||
|
handler: H,
|
||||||
|
summary: &'static str,
|
||||||
|
) -> DocumentedJsonHandler<H, Req, Res> {
|
||||||
|
DocumentedJsonHandler {
|
||||||
|
handler,
|
||||||
|
summary,
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn documented_response_handler<Res, H>(
|
||||||
|
handler: H,
|
||||||
|
summary: &'static str,
|
||||||
|
) -> DocumentedResponseHandler<H, Res> {
|
||||||
|
DocumentedResponseHandler {
|
||||||
|
handler,
|
||||||
|
summary,
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<HandlerParams, H, Req, Res> RequestHandler<HandlerParams>
|
||||||
|
for DocumentedJsonHandler<H, Req, Res>
|
||||||
|
where
|
||||||
|
H: RequestHandler<HandlerParams> + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
async fn handle(&self, request: cot::request::Request) -> cot::Result<cot::response::Response> {
|
||||||
|
self.handler.handle(request).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<HandlerParams, H, Res> RequestHandler<HandlerParams> for DocumentedResponseHandler<H, Res>
|
||||||
|
where
|
||||||
|
H: RequestHandler<HandlerParams> + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
async fn handle(&self, request: cot::request::Request) -> cot::Result<cot::response::Response> {
|
||||||
|
self.handler.handle(request).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<H, Req, Res> AsApiOperation for DocumentedJsonHandler<H, Req, Res>
|
||||||
|
where
|
||||||
|
Req: JsonSchema,
|
||||||
|
Res: JsonSchema,
|
||||||
|
{
|
||||||
|
fn as_api_operation(
|
||||||
|
&self,
|
||||||
|
_route_context: &RouteContext<'_>,
|
||||||
|
schema_generator: &mut SchemaGenerator,
|
||||||
|
) -> Option<Operation> {
|
||||||
|
let mut operation = Operation {
|
||||||
|
summary: Some(self.summary.to_owned()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut request_body = RequestBody {
|
||||||
|
required: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
request_body.content.insert(
|
||||||
|
"application/json".to_owned(),
|
||||||
|
MediaType {
|
||||||
|
schema: Some(SchemaObject {
|
||||||
|
json_schema: Req::json_schema(schema_generator),
|
||||||
|
external_docs: None,
|
||||||
|
example: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
operation.request_body = Some(ReferenceOr::Item(request_body));
|
||||||
|
|
||||||
|
let responses = operation.responses.get_or_insert_default();
|
||||||
|
let mut ok = OpenApiResponse {
|
||||||
|
description: "OK".to_owned(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
ok.content.insert(
|
||||||
|
"application/json".to_owned(),
|
||||||
|
MediaType {
|
||||||
|
schema: Some(SchemaObject {
|
||||||
|
json_schema: Res::json_schema(schema_generator),
|
||||||
|
external_docs: None,
|
||||||
|
example: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
responses
|
||||||
|
.responses
|
||||||
|
.insert(OpenApiStatusCode::Code(200), ReferenceOr::Item(ok));
|
||||||
|
|
||||||
|
Some(operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<H, Res> AsApiOperation for DocumentedResponseHandler<H, Res>
|
||||||
|
where
|
||||||
|
Res: JsonSchema,
|
||||||
|
{
|
||||||
|
fn as_api_operation(
|
||||||
|
&self,
|
||||||
|
_route_context: &RouteContext<'_>,
|
||||||
|
schema_generator: &mut SchemaGenerator,
|
||||||
|
) -> Option<Operation> {
|
||||||
|
let mut operation = Operation {
|
||||||
|
summary: Some(self.summary.to_owned()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
add_json_response::<Res>(&mut operation, schema_generator);
|
||||||
|
Some(operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_json_response<Res: JsonSchema>(
|
||||||
|
operation: &mut Operation,
|
||||||
|
schema_generator: &mut SchemaGenerator,
|
||||||
|
) {
|
||||||
|
let responses = operation.responses.get_or_insert_default();
|
||||||
|
let mut ok = OpenApiResponse {
|
||||||
|
description: "OK".to_owned(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
ok.content.insert(
|
||||||
|
"application/json".to_owned(),
|
||||||
|
MediaType {
|
||||||
|
schema: Some(SchemaObject {
|
||||||
|
json_schema: Res::json_schema(schema_generator),
|
||||||
|
external_docs: None,
|
||||||
|
example: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
responses
|
||||||
|
.responses
|
||||||
|
.insert(OpenApiStatusCode::Code(200), ReferenceOr::Item(ok));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_json_content_type(value: &str) -> bool {
|
||||||
|
value
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|media_type| media_type.eq_ignore_ascii_case("application/json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_json_request<T>(
|
||||||
|
request: cot::request::Request,
|
||||||
|
) -> cot::Result<Result<T, cot::response::Response>>
|
||||||
|
where
|
||||||
|
T: for<'de> Deserialize<'de>,
|
||||||
|
{
|
||||||
|
let content_type = request
|
||||||
|
.headers()
|
||||||
|
.get(CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !is_json_content_type(content_type) {
|
||||||
|
return Ok(Err(json_error(
|
||||||
|
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
"expected application/json",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = request.into_body().into_bytes().await?;
|
||||||
|
let body = match serde_json::from_slice::<T>(&bytes) {
|
||||||
|
Ok(body) => body,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(Err(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid JSON body",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(Ok(body))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /api/me
|
// GET /api/me
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -34,8 +240,85 @@ struct MeResponse {
|
|||||||
role: String,
|
role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response::Response> {
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
let Some(user) = auth::get_session_user(&session, &db).await else {
|
struct AuthUserResponse {
|
||||||
|
id: i64,
|
||||||
|
name: String,
|
||||||
|
role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct AuthTokenResponse {
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: String,
|
||||||
|
token_type: String,
|
||||||
|
expires_in_seconds: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct AuthLoginResponse {
|
||||||
|
user: AuthUserResponse,
|
||||||
|
tokens: AuthTokenResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct PasswordLoginRequest {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
device_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct RefreshRequest {
|
||||||
|
refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct SsoExchangeRequest {
|
||||||
|
code: String,
|
||||||
|
device_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct LogoutRequest {
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct LogoutResponse {
|
||||||
|
revoked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_response(user: auth::AuthenticatedUser) -> AuthUserResponse {
|
||||||
|
AuthUserResponse {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role.code().to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_response(tokens: auth::ApiTokenPair) -> AuthTokenResponse {
|
||||||
|
AuthTokenResponse {
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
token_type: tokens.token_type.to_owned(),
|
||||||
|
expires_in_seconds: tokens.expires_in_seconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login_response(user: auth::AuthenticatedUser, tokens: auth::ApiTokenPair) -> AuthLoginResponse {
|
||||||
|
AuthLoginResponse {
|
||||||
|
user: user_response(user),
|
||||||
|
tokens: token_response(tokens),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn me_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
return Ok(json_error(
|
return Ok(json_error(
|
||||||
cot::http::StatusCode::UNAUTHORIZED,
|
cot::http::StatusCode::UNAUTHORIZED,
|
||||||
"not authenticated",
|
"not authenticated",
|
||||||
@@ -50,6 +333,146 @@ async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn password_login_handler(
|
||||||
|
db: Database,
|
||||||
|
raw_request: cot::request::Request,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let request = match parse_json_request::<PasswordLoginRequest>(raw_request).await? {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if !config.auth_password_enabled {
|
||||||
|
crate::metrics::record_auth_attempt("api_password", "failure", "disabled");
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"password login is disabled",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = match User::get_by_username(&db, request.username.trim()).await {
|
||||||
|
Ok(Some(user)) if user.is_active() => user,
|
||||||
|
_ => {
|
||||||
|
crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials");
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid username or password",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(hash) = user.password_ref() else {
|
||||||
|
crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials");
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid username or password",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
match hash.verify(&Password::new(&request.password)) {
|
||||||
|
PasswordVerificationResult::Ok | PasswordVerificationResult::OkObsolete(_) => {
|
||||||
|
let auth_user = auth::AuthenticatedUser {
|
||||||
|
id: user.id_val(),
|
||||||
|
name: {
|
||||||
|
let display = user.display_name_str();
|
||||||
|
if display.is_empty() {
|
||||||
|
user.username_str().to_owned()
|
||||||
|
} else {
|
||||||
|
display
|
||||||
|
}
|
||||||
|
},
|
||||||
|
role: user.role(),
|
||||||
|
};
|
||||||
|
let tokens =
|
||||||
|
auth::create_api_session(&db, user.id_val(), request.device_name.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
crate::metrics::record_auth_attempt("api_password", "success", "ok");
|
||||||
|
crate::metrics::record_session_created("api_password");
|
||||||
|
Json(login_response(auth_user, tokens)).into_response()
|
||||||
|
}
|
||||||
|
PasswordVerificationResult::Invalid => {
|
||||||
|
crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials");
|
||||||
|
Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid username or password",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_handler(
|
||||||
|
db: Database,
|
||||||
|
raw_request: cot::request::Request,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let request = match parse_json_request::<RefreshRequest>(raw_request).await? {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
|
match auth::refresh_api_session(&db, request.refresh_token.trim()).await {
|
||||||
|
Ok(Some(tokens)) => Json(token_response(tokens)).into_response(),
|
||||||
|
Ok(None) => Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid refresh token",
|
||||||
|
)),
|
||||||
|
Err(err) => Err(cot::Error::internal(err.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sso_exchange_handler(
|
||||||
|
db: Database,
|
||||||
|
raw_request: cot::request::Request,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let request = match parse_json_request::<SsoExchangeRequest>(raw_request).await? {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
|
match auth::exchange_mobile_code_for_api_session(
|
||||||
|
&db,
|
||||||
|
request.code.trim(),
|
||||||
|
request.device_name.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some((user, tokens))) => {
|
||||||
|
crate::metrics::record_auth_attempt("api_sso_exchange", "success", "ok");
|
||||||
|
crate::metrics::record_session_created("api_sso_exchange");
|
||||||
|
Json(login_response(user, tokens)).into_response()
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
crate::metrics::record_auth_attempt("api_sso_exchange", "failure", "bad_code");
|
||||||
|
Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"invalid SSO exchange code",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(err) => Err(cot::Error::internal(err.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logout_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
db: Database,
|
||||||
|
raw_request: cot::request::Request,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let request = match parse_json_request::<LogoutRequest>(raw_request).await? {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
|
let revoked = auth::revoke_api_session(
|
||||||
|
&db,
|
||||||
|
auth_ctx.bearer_token(),
|
||||||
|
request.refresh_token.as_deref().map(str::trim),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Json(LogoutResponse { revoked }).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// App
|
// App
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -62,10 +485,101 @@ impl App for ApiApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn router(&self) -> Router {
|
fn router(&self) -> Router {
|
||||||
Router::with_urls([Route::with_api_handler_and_name(
|
Router::with_urls([
|
||||||
"/me",
|
Route::with_api_handler_and_name(
|
||||||
api_get(me_handler),
|
"/me",
|
||||||
"api_me",
|
api_get(documented_response_handler::<MeResponse, _>(
|
||||||
)])
|
me_handler,
|
||||||
|
"Get the current authenticated user",
|
||||||
|
)),
|
||||||
|
"api_me",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/auth/password",
|
||||||
|
api_post(documented_json_handler::<
|
||||||
|
PasswordLoginRequest,
|
||||||
|
AuthLoginResponse,
|
||||||
|
_,
|
||||||
|
>(
|
||||||
|
password_login_handler,
|
||||||
|
"Log in with username and password",
|
||||||
|
)),
|
||||||
|
"api_auth_password",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/auth/refresh",
|
||||||
|
api_post(documented_json_handler::<
|
||||||
|
RefreshRequest,
|
||||||
|
AuthTokenResponse,
|
||||||
|
_,
|
||||||
|
>(
|
||||||
|
refresh_handler, "Refresh an API token pair"
|
||||||
|
)),
|
||||||
|
"api_auth_refresh",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/auth/sso/exchange",
|
||||||
|
api_post(documented_json_handler::<
|
||||||
|
SsoExchangeRequest,
|
||||||
|
AuthLoginResponse,
|
||||||
|
_,
|
||||||
|
>(
|
||||||
|
sso_exchange_handler,
|
||||||
|
"Exchange a mobile SSO code for API tokens",
|
||||||
|
)),
|
||||||
|
"api_auth_sso_exchange",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/auth/logout",
|
||||||
|
api_post(documented_json_handler::<LogoutRequest, LogoutResponse, _>(
|
||||||
|
logout_handler,
|
||||||
|
"Revoke an API session",
|
||||||
|
)),
|
||||||
|
"api_auth_logout",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use cot::aide::openapi::{PathItem, ReferenceOr};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn assert_get_path(paths: &cot::aide::openapi::Paths, path: &str) {
|
||||||
|
assert!(matches!(
|
||||||
|
paths.paths.get(path),
|
||||||
|
Some(ReferenceOr::Item(PathItem { get: Some(_), .. }))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_post_path(paths: &cot::aide::openapi::Paths, path: &str) {
|
||||||
|
assert!(matches!(
|
||||||
|
paths.paths.get(path),
|
||||||
|
Some(ReferenceOr::Item(PathItem { post: Some(_), .. }))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openapi_includes_auth_routes() {
|
||||||
|
let openapi = ApiApp.router().as_api();
|
||||||
|
let paths = openapi.paths.expect("OpenAPI paths");
|
||||||
|
|
||||||
|
assert_get_path(&paths, "/me");
|
||||||
|
assert_post_path(&paths, "/auth/password");
|
||||||
|
assert_post_path(&paths, "/auth/refresh");
|
||||||
|
assert_post_path(&paths, "/auth/sso/exchange");
|
||||||
|
assert_post_path(&paths, "/auth/logout");
|
||||||
|
|
||||||
|
let Some(ReferenceOr::Item(PathItem {
|
||||||
|
post: Some(operation),
|
||||||
|
..
|
||||||
|
})) = paths.paths.get("/auth/password")
|
||||||
|
else {
|
||||||
|
panic!("password auth path should be documented as POST");
|
||||||
|
};
|
||||||
|
assert!(operation.request_body.is_some());
|
||||||
|
assert!(operation.responses.is_some());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+550
-6
@@ -1,7 +1,13 @@
|
|||||||
|
use chrono::{Duration, Utc};
|
||||||
use cot::Body;
|
use cot::Body;
|
||||||
use cot::db::Database;
|
use cot::db::{Auto, Database, LimitedString, Model};
|
||||||
|
use cot::http::header::AUTHORIZATION;
|
||||||
|
use cot::request::RequestHead;
|
||||||
|
use cot::request::extractors::FromRequestHead;
|
||||||
use cot::response::IntoResponse;
|
use cot::response::IntoResponse;
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::user::User;
|
use crate::user::User;
|
||||||
|
|
||||||
@@ -46,11 +52,7 @@ pub struct AuthenticatedUser {
|
|||||||
pub role: Role,
|
pub role: Role,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read `user_id` from the session, fetch the `User` from DB, return
|
fn authenticated_user_from_user(user: User) -> Option<AuthenticatedUser> {
|
||||||
/// `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() {
|
if !user.is_active() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -70,6 +72,362 @@ pub async fn get_session_user(session: &Session, db: &Database) -> Option<Authen
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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()??;
|
||||||
|
authenticated_user_from_user(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API bearer-token auth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_PREFIX: &str = "furu_at_";
|
||||||
|
const REFRESH_TOKEN_PREFIX: &str = "furu_rt_";
|
||||||
|
const MOBILE_EXCHANGE_CODE_PREFIX: &str = "furu_mx_";
|
||||||
|
const ACCESS_TOKEN_TTL_MINUTES: i64 = 15;
|
||||||
|
const REFRESH_TOKEN_TTL_DAYS: i64 = 60;
|
||||||
|
const MOBILE_EXCHANGE_CODE_TTL_MINUTES: i64 = 3;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AuthContext {
|
||||||
|
bearer_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthContext {
|
||||||
|
pub fn bearer_token(&self) -> Option<&str> {
|
||||||
|
self.bearer_token.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequestHead for AuthContext {
|
||||||
|
async fn from_request_head(head: &RequestHead) -> cot::Result<Self> {
|
||||||
|
let bearer_token = head
|
||||||
|
.headers
|
||||||
|
.get(AUTHORIZATION)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(parse_bearer_token)
|
||||||
|
.map(str::to_owned);
|
||||||
|
Ok(Self { bearer_token })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bearer_token(header: &str) -> Option<&str> {
|
||||||
|
let header = header.trim();
|
||||||
|
let (scheme, token) = header.split_once(' ')?;
|
||||||
|
if !scheme.eq_ignore_ascii_case("Bearer") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token = token.trim();
|
||||||
|
if token.is_empty() || token.len() > 512 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ApiTokenPair {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub token_type: &'static str,
|
||||||
|
pub expires_in_seconds: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[cot::db::model]
|
||||||
|
pub struct ApiSession {
|
||||||
|
#[model(primary_key)]
|
||||||
|
id: Auto<i64>,
|
||||||
|
user_id: i64,
|
||||||
|
device_name: Option<String>,
|
||||||
|
access_token_hash: LimitedString<128>,
|
||||||
|
refresh_token_hash: LimitedString<128>,
|
||||||
|
access_expires_at: String,
|
||||||
|
refresh_expires_at: String,
|
||||||
|
created_at: String,
|
||||||
|
last_used_at: Option<String>,
|
||||||
|
revoked_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[cot::db::model]
|
||||||
|
pub struct MobileExchangeCode {
|
||||||
|
#[model(primary_key)]
|
||||||
|
id: Auto<i64>,
|
||||||
|
code_hash: LimitedString<128>,
|
||||||
|
user_id: i64,
|
||||||
|
created_at: String,
|
||||||
|
expires_at: String,
|
||||||
|
consumed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiSession {
|
||||||
|
pub async fn create_for_user(
|
||||||
|
db: &Database,
|
||||||
|
user_id: i64,
|
||||||
|
device_name: Option<&str>,
|
||||||
|
) -> cot::db::Result<ApiTokenPair> {
|
||||||
|
let tokens = fresh_token_pair();
|
||||||
|
let now = now_iso();
|
||||||
|
let mut session = Self {
|
||||||
|
id: Auto::auto(),
|
||||||
|
user_id,
|
||||||
|
device_name: device_name.and_then(normalize_device_name),
|
||||||
|
access_token_hash: LimitedString::new(&token_hash(&tokens.access_token)).unwrap(),
|
||||||
|
refresh_token_hash: LimitedString::new(&token_hash(&tokens.refresh_token)).unwrap(),
|
||||||
|
access_expires_at: access_expires_at(),
|
||||||
|
refresh_expires_at: refresh_expires_at(),
|
||||||
|
created_at: now.clone(),
|
||||||
|
last_used_at: Some(now),
|
||||||
|
revoked_at: None,
|
||||||
|
};
|
||||||
|
session.insert(db).await?;
|
||||||
|
Ok(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_access_token(db: &Database, token: &str) -> cot::db::Result<Option<Self>> {
|
||||||
|
let Ok(hash) = LimitedString::<128>::new(&token_hash(token)) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
cot::db::query!(ApiSession, $access_token_hash == hash)
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_refresh_token(db: &Database, token: &str) -> cot::db::Result<Option<Self>> {
|
||||||
|
let Ok(hash) = LimitedString::<128>::new(&token_hash(token)) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
cot::db::query!(ApiSession, $refresh_token_hash == hash)
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_revoked(&self) -> bool {
|
||||||
|
self.revoked_at.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn access_token_valid(&self) -> bool {
|
||||||
|
!self.is_revoked() && self.access_expires_at > now_iso()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_token_valid(&self) -> bool {
|
||||||
|
!self.is_revoked() && self.refresh_expires_at > now_iso()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rotate(&mut self, db: &Database) -> cot::db::Result<ApiTokenPair> {
|
||||||
|
let tokens = fresh_token_pair();
|
||||||
|
self.access_token_hash = LimitedString::new(&token_hash(&tokens.access_token)).unwrap();
|
||||||
|
self.refresh_token_hash = LimitedString::new(&token_hash(&tokens.refresh_token)).unwrap();
|
||||||
|
self.access_expires_at = access_expires_at();
|
||||||
|
self.refresh_expires_at = refresh_expires_at();
|
||||||
|
self.last_used_at = Some(now_iso());
|
||||||
|
self.save(db).await?;
|
||||||
|
Ok(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke(&mut self, db: &Database) -> cot::db::Result<()> {
|
||||||
|
if self.revoked_at.is_none() {
|
||||||
|
self.revoked_at = Some(now_iso());
|
||||||
|
self.save(db).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_api_session(
|
||||||
|
db: &Database,
|
||||||
|
user_id: i64,
|
||||||
|
device_name: Option<&str>,
|
||||||
|
) -> cot::db::Result<ApiTokenPair> {
|
||||||
|
ApiSession::create_for_user(db, user_id, device_name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_bearer_user(db: &Database, token: &str) -> Option<AuthenticatedUser> {
|
||||||
|
let session = ApiSession::find_by_access_token(db, token).await.ok()??;
|
||||||
|
if !session.access_token_valid() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let user = User::get_by_id(db, session.user_id).await.ok()??;
|
||||||
|
authenticated_user_from_user(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_request_user(
|
||||||
|
auth: &AuthContext,
|
||||||
|
session: &Session,
|
||||||
|
db: &Database,
|
||||||
|
) -> Option<AuthenticatedUser> {
|
||||||
|
if let Some(token) = auth.bearer_token() {
|
||||||
|
return get_bearer_user(db, token).await;
|
||||||
|
}
|
||||||
|
get_session_user(session, db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh_api_session(
|
||||||
|
db: &Database,
|
||||||
|
refresh_token: &str,
|
||||||
|
) -> cot::db::Result<Option<ApiTokenPair>> {
|
||||||
|
let Some(mut session) = ApiSession::find_by_refresh_token(db, refresh_token).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !session.refresh_token_valid() {
|
||||||
|
session.revoke(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let Some(user) = User::get_by_id(db, session.user_id).await? else {
|
||||||
|
session.revoke(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !user.is_active() {
|
||||||
|
session.revoke(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(session.rotate(db).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_api_session(
|
||||||
|
db: &Database,
|
||||||
|
access_token: Option<&str>,
|
||||||
|
refresh_token: Option<&str>,
|
||||||
|
) -> cot::db::Result<bool> {
|
||||||
|
let mut session = if let Some(token) = access_token {
|
||||||
|
ApiSession::find_by_access_token(db, token).await?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if session.is_none() {
|
||||||
|
if let Some(token) = refresh_token {
|
||||||
|
session = ApiSession::find_by_refresh_token(db, token).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(mut session) = session else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
session.revoke(db).await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileExchangeCode {
|
||||||
|
pub async fn create_for_user(db: &Database, user_id: i64) -> cot::db::Result<String> {
|
||||||
|
let code = random_token(MOBILE_EXCHANGE_CODE_PREFIX);
|
||||||
|
let now = now_iso();
|
||||||
|
let mut row = Self {
|
||||||
|
id: Auto::auto(),
|
||||||
|
code_hash: LimitedString::new(&token_hash(&code)).unwrap(),
|
||||||
|
user_id,
|
||||||
|
created_at: now,
|
||||||
|
expires_at: mobile_exchange_code_expires_at(),
|
||||||
|
consumed_at: None,
|
||||||
|
};
|
||||||
|
row.insert(db).await?;
|
||||||
|
Ok(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_code(db: &Database, code: &str) -> cot::db::Result<Option<Self>> {
|
||||||
|
let Ok(hash) = LimitedString::<128>::new(&token_hash(code)) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
cot::db::query!(MobileExchangeCode, $code_hash == hash)
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_valid(&self) -> bool {
|
||||||
|
self.consumed_at.is_none() && self.expires_at > now_iso()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn consume(&mut self, db: &Database) -> cot::db::Result<()> {
|
||||||
|
self.consumed_at = Some(now_iso());
|
||||||
|
self.save(db).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_mobile_exchange_code(db: &Database, user_id: i64) -> cot::db::Result<String> {
|
||||||
|
MobileExchangeCode::create_for_user(db, user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn exchange_mobile_code_for_api_session(
|
||||||
|
db: &Database,
|
||||||
|
code: &str,
|
||||||
|
device_name: Option<&str>,
|
||||||
|
) -> cot::db::Result<Option<(AuthenticatedUser, ApiTokenPair)>> {
|
||||||
|
let Some(mut exchange_code) = MobileExchangeCode::find_by_code(db, code).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !exchange_code.is_valid() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let Some(user) = User::get_by_id(db, exchange_code.user_id).await? else {
|
||||||
|
exchange_code.consume(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(auth_user) = authenticated_user_from_user(user) else {
|
||||||
|
exchange_code.consume(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
exchange_code.consume(db).await?;
|
||||||
|
let tokens = ApiSession::create_for_user(db, auth_user.id, device_name).await?;
|
||||||
|
Ok(Some((auth_user, tokens)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fresh_token_pair() -> ApiTokenPair {
|
||||||
|
ApiTokenPair {
|
||||||
|
access_token: random_token(ACCESS_TOKEN_PREFIX),
|
||||||
|
refresh_token: random_token(REFRESH_TOKEN_PREFIX),
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in_seconds: ACCESS_TOKEN_TTL_MINUTES * 60,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_token(prefix: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{prefix}{}{}",
|
||||||
|
uuid::Uuid::new_v4().simple(),
|
||||||
|
uuid::Uuid::new_v4().simple()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_hash(token: &str) -> String {
|
||||||
|
let digest = Sha256::digest(token.as_bytes());
|
||||||
|
let mut out = String::with_capacity(digest.len() * 2);
|
||||||
|
for byte in digest {
|
||||||
|
out.push_str(&format!("{byte:02x}"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_device_name(name: &str) -> Option<String> {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(trimmed.chars().take(255).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_iso() -> String {
|
||||||
|
Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn access_expires_at() -> String {
|
||||||
|
(Utc::now() + Duration::minutes(ACCESS_TOKEN_TTL_MINUTES))
|
||||||
|
.format("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_expires_at() -> String {
|
||||||
|
(Utc::now() + Duration::days(REFRESH_TOKEN_TTL_DAYS))
|
||||||
|
.format("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_exchange_code_expires_at() -> String {
|
||||||
|
(Utc::now() + Duration::minutes(MOBILE_EXCHANGE_CODE_TTL_MINUTES))
|
||||||
|
.format("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Return `Ok(user)` if the session belongs to an active admin, otherwise
|
/// Return `Ok(user)` if the session belongs to an active admin, otherwise
|
||||||
/// `Err(response)` — a redirect to `/login` or a 403.
|
/// `Err(response)` — a redirect to `/login` or a 403.
|
||||||
pub async fn require_admin_or_redirect(
|
pub async fn require_admin_or_redirect(
|
||||||
@@ -159,6 +517,192 @@ pub fn redirect(location: &str) -> cot::response::Response {
|
|||||||
.expect("valid response")
|
.expect("valid response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Migrations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub mod db_migrations {
|
||||||
|
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
|
||||||
|
use cot::db::{DatabaseField, Identifier, LimitedString};
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0038CreateApiSession;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0038CreateApiSession {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0038_create_api_session";
|
||||||
|
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__api_session"))
|
||||||
|
.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("device_name"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.set_null(true),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("access_token_hash"),
|
||||||
|
<LimitedString<128> as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("refresh_token_hash"),
|
||||||
|
<LimitedString<128> as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("access_expires_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("refresh_expires_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("created_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("last_used_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.set_null(true),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("revoked_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.set_null(true),
|
||||||
|
])
|
||||||
|
.build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_api_session_indexes(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX idx_api_session_access_token_hash \
|
||||||
|
ON furumusic__api_session (access_token_hash)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX idx_api_session_refresh_token_hash \
|
||||||
|
ON furumusic__api_session (refresh_token_hash)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX idx_api_session_user_id \
|
||||||
|
ON furumusic__api_session (user_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0039CreateApiSessionIndexes;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0039CreateApiSessionIndexes {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0039_create_api_session_indexes";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0038_create_api_session",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_api_session_indexes).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0040CreateMobileExchangeCode;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0040CreateMobileExchangeCode {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0040_create_mobile_exchange_code";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0039_create_api_session_indexes",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
|
||||||
|
.table_name(Identifier::new("furumusic__mobile_exchange_code"))
|
||||||
|
.fields(&[
|
||||||
|
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
|
||||||
|
.primary_key()
|
||||||
|
.auto(),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("code_hash"),
|
||||||
|
<LimitedString<128> as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("created_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("expires_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
),
|
||||||
|
Field::new(
|
||||||
|
Identifier::new("consumed_at"),
|
||||||
|
<String as DatabaseField>::TYPE,
|
||||||
|
)
|
||||||
|
.set_null(true),
|
||||||
|
])
|
||||||
|
.build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cot::db::migrations::migration_op]
|
||||||
|
async fn create_mobile_exchange_code_indexes(
|
||||||
|
ctx: migrations::MigrationContext<'_>,
|
||||||
|
) -> cot::db::Result<()> {
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE UNIQUE INDEX idx_mobile_exchange_code_hash \
|
||||||
|
ON furumusic__mobile_exchange_code (code_hash)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ctx.db
|
||||||
|
.raw(
|
||||||
|
"CREATE INDEX idx_mobile_exchange_code_user_id \
|
||||||
|
ON furumusic__mobile_exchange_code (user_id)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct M0041CreateMobileExchangeCodeIndexes;
|
||||||
|
|
||||||
|
impl migrations::Migration for M0041CreateMobileExchangeCodeIndexes {
|
||||||
|
const APP_NAME: &'static str = "furumusic";
|
||||||
|
const MIGRATION_NAME: &'static str = "m_0041_create_mobile_exchange_code_indexes";
|
||||||
|
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||||
|
&[migrations::MigrationDependency::migration(
|
||||||
|
"furumusic",
|
||||||
|
"m_0040_create_mobile_exchange_code",
|
||||||
|
)];
|
||||||
|
const OPERATIONS: &'static [Operation] =
|
||||||
|
&[Operation::custom(create_mobile_exchange_code_indexes).build()];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
|
&M0038CreateApiSession,
|
||||||
|
&M0039CreateApiSessionIndexes,
|
||||||
|
&M0040CreateMobileExchangeCode,
|
||||||
|
&M0041CreateMobileExchangeCodeIndexes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -338,10 +338,52 @@ impl AppConfig {
|
|||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
let mut cfg = Self::default();
|
let mut cfg = Self::default();
|
||||||
cfg.apply_env_overrides();
|
cfg.apply_env_overrides();
|
||||||
|
cfg.apply_startup_db_overrides();
|
||||||
|
cfg.apply_env_overrides();
|
||||||
cfg.normalize_host_paths();
|
cfg.normalize_host_paths();
|
||||||
cfg
|
cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_startup_db_overrides(&mut self) {
|
||||||
|
if self.database_url.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if tokio::runtime::Handle::try_current().is_ok() {
|
||||||
|
tracing::warn!("skipping startup DB config load from inside an existing Tokio runtime");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let database_url = self.database_url.clone();
|
||||||
|
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
else {
|
||||||
|
tracing::warn!("failed to create runtime for startup DB config load");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = runtime.block_on(async move {
|
||||||
|
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect(&database_url)
|
||||||
|
.await?;
|
||||||
|
sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT value FROM furumusic__config_entry WHERE key = 'swagger_enabled'",
|
||||||
|
)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Some(value)) => match value.parse::<bool>() {
|
||||||
|
Ok(value) => self.swagger_enabled = value,
|
||||||
|
Err(_) => tracing::warn!("ignoring invalid DB config value for swagger_enabled"),
|
||||||
|
},
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(err) => tracing::warn!("failed to read startup DB config overrides: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build config with full 3-layer resolution (default → DB → env) and
|
/// Build config with full 3-layer resolution (default → DB → env) and
|
||||||
/// track the source of each field.
|
/// track the source of each field.
|
||||||
pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) {
|
pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) {
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::scheduler::{Job, JobContext, JobLog};
|
||||||
|
|
||||||
|
const SAMPLE_LOG_LIMIT: usize = 50;
|
||||||
|
|
||||||
|
pub struct ArchiveCleanupJob;
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct TrackFileRow {
|
||||||
|
track_id: i64,
|
||||||
|
track_title: String,
|
||||||
|
release_id: i64,
|
||||||
|
release_title: Option<String>,
|
||||||
|
media_file_id: Option<i64>,
|
||||||
|
file_type: Option<String>,
|
||||||
|
file_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct MissingTrack {
|
||||||
|
track_id: i64,
|
||||||
|
track_title: String,
|
||||||
|
release_id: i64,
|
||||||
|
release_title: Option<String>,
|
||||||
|
media_file_id: Option<i64>,
|
||||||
|
file_path: Option<String>,
|
||||||
|
reason: MissingReason,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum MissingReason {
|
||||||
|
MissingMediaRow,
|
||||||
|
InvalidMediaType(String),
|
||||||
|
EmptyPath,
|
||||||
|
MissingFile,
|
||||||
|
NotRegularFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct DeleteStats {
|
||||||
|
playback_states_cleared: u64,
|
||||||
|
playlist_entries_deleted: u64,
|
||||||
|
likes_deleted: u64,
|
||||||
|
play_history_deleted: u64,
|
||||||
|
popularity_history_deleted: u64,
|
||||||
|
scrobble_outbox_deleted: u64,
|
||||||
|
track_genres_deleted: u64,
|
||||||
|
entity_tags_deleted: u64,
|
||||||
|
external_ids_deleted: u64,
|
||||||
|
track_artists_deleted: u64,
|
||||||
|
tracks_deleted: u64,
|
||||||
|
media_files_deleted: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Job for ArchiveCleanupJob {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"archive_cleanup"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Clean stale archive records, starting with tracks whose audio files are missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_cron(&self) -> &'static str {
|
||||||
|
// Daily at 04:45.
|
||||||
|
"0 45 4 * * *"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
||||||
|
run_missing_audio_cleanup(ctx, log).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_missing_audio_cleanup(ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
||||||
|
let storage_dir = ctx.config.agent_storage_dir.trim();
|
||||||
|
if storage_dir.is_empty() {
|
||||||
|
log.warn("Archive cleanup: agent_storage_dir is not configured, skipping file checks");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, TrackFileRow>(
|
||||||
|
r#"SELECT t.id AS track_id,
|
||||||
|
t.title::text AS track_title,
|
||||||
|
t.release_id,
|
||||||
|
r.title::text AS release_title,
|
||||||
|
mf.id AS media_file_id,
|
||||||
|
mf.file_type::text AS file_type,
|
||||||
|
mf.file_path::text AS file_path
|
||||||
|
FROM furumusic__track t
|
||||||
|
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
|
ORDER BY t.id"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&ctx.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
log.info("Archive cleanup: no tracks found");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(&format!(
|
||||||
|
"Archive cleanup: checking {} track audio reference(s)",
|
||||||
|
rows.len()
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut missing_tracks = Vec::new();
|
||||||
|
let mut skipped_io_errors = 0u64;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let Some(media_file_id) = row.media_file_id else {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(row, MissingReason::MissingMediaRow));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_type = row.file_type.clone();
|
||||||
|
match file_type.as_deref() {
|
||||||
|
Some("audio") => {}
|
||||||
|
Some(file_type) => {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(
|
||||||
|
row,
|
||||||
|
MissingReason::InvalidMediaType(file_type.to_owned()),
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(row, MissingReason::MissingMediaRow));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(file_path) = row
|
||||||
|
.file_path
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|path| !path.is_empty())
|
||||||
|
else {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(row, MissingReason::EmptyPath));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let absolute_path = crate::media_paths::resolve_media_file_path(storage_dir, file_path);
|
||||||
|
match tokio::fs::metadata(&absolute_path).await {
|
||||||
|
Ok(meta) if meta.is_file() => {}
|
||||||
|
Ok(_) => {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(row, MissingReason::NotRegularFile));
|
||||||
|
}
|
||||||
|
Err(err) if err.kind() == ErrorKind::NotFound => {
|
||||||
|
missing_tracks.push(MissingTrack::from_row(row, MissingReason::MissingFile));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
skipped_io_errors += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Archive cleanup: skipping track {} media_file_id={media_file_id}; cannot inspect {}: {err}",
|
||||||
|
row.track_id,
|
||||||
|
absolute_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if missing_tracks.is_empty() {
|
||||||
|
log.info(&format!(
|
||||||
|
"Archive cleanup: all checked tracks have readable audio files; skipped_io_errors={skipped_io_errors}"
|
||||||
|
));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, track) in missing_tracks.iter().take(SAMPLE_LOG_LIMIT).enumerate() {
|
||||||
|
log.warn(&format!(
|
||||||
|
"Archive cleanup: deleting stale track {} \"{}\"{}{} ({})",
|
||||||
|
track.track_id,
|
||||||
|
track.track_title,
|
||||||
|
track
|
||||||
|
.release_title
|
||||||
|
.as_deref()
|
||||||
|
.map(|title| format!(" from \"{title}\""))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
track
|
||||||
|
.file_path
|
||||||
|
.as_deref()
|
||||||
|
.map(|path| format!(", path={path}"))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
track.reason
|
||||||
|
));
|
||||||
|
if index + 1 == SAMPLE_LOG_LIMIT && missing_tracks.len() > SAMPLE_LOG_LIMIT {
|
||||||
|
log.warn(&format!(
|
||||||
|
"Archive cleanup: suppressing per-track logs for remaining {} stale track(s)",
|
||||||
|
missing_tracks.len() - SAMPLE_LOG_LIMIT
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let track_ids = unique_sorted(
|
||||||
|
missing_tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.track_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
let media_file_ids = unique_sorted(
|
||||||
|
missing_tracks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|track| track.media_file_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
let release_ids = unique_sorted(
|
||||||
|
missing_tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.release_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let stats =
|
||||||
|
delete_tracks_and_unreferenced_audio_media(&ctx.pool, &track_ids, &media_file_ids).await?;
|
||||||
|
let empty_release_count = count_empty_releases(&ctx.pool, &release_ids).await?;
|
||||||
|
|
||||||
|
log.info(&format!(
|
||||||
|
"Archive cleanup: deleted {} track(s), {} unreferenced audio media_file row(s); cleared playback_states={}, playlist_entries={}, likes={}, play_history={}, popularity_history={}, scrobble_outbox={}, track_genres={}, entity_tags={}, external_ids={}, track_artists={}; skipped_io_errors={skipped_io_errors}; empty_releases_left={empty_release_count}",
|
||||||
|
stats.tracks_deleted,
|
||||||
|
stats.media_files_deleted,
|
||||||
|
stats.playback_states_cleared,
|
||||||
|
stats.playlist_entries_deleted,
|
||||||
|
stats.likes_deleted,
|
||||||
|
stats.play_history_deleted,
|
||||||
|
stats.popularity_history_deleted,
|
||||||
|
stats.scrobble_outbox_deleted,
|
||||||
|
stats.track_genres_deleted,
|
||||||
|
stats.entity_tags_deleted,
|
||||||
|
stats.external_ids_deleted,
|
||||||
|
stats.track_artists_deleted,
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MissingTrack {
|
||||||
|
fn from_row(row: TrackFileRow, reason: MissingReason) -> Self {
|
||||||
|
Self {
|
||||||
|
track_id: row.track_id,
|
||||||
|
track_title: row.track_title,
|
||||||
|
release_id: row.release_id,
|
||||||
|
release_title: row.release_title,
|
||||||
|
media_file_id: row.media_file_id,
|
||||||
|
file_path: row.file_path,
|
||||||
|
reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for MissingReason {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::MissingMediaRow => f.write_str("missing media_file row"),
|
||||||
|
Self::InvalidMediaType(file_type) => write!(f, "invalid media_file type {file_type:?}"),
|
||||||
|
Self::EmptyPath => f.write_str("empty media file path"),
|
||||||
|
Self::MissingFile => f.write_str("audio file not found on disk"),
|
||||||
|
Self::NotRegularFile => f.write_str("audio path is not a regular file"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_sorted(values: Vec<i64>) -> Vec<i64> {
|
||||||
|
values
|
||||||
|
.into_iter()
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_tracks_and_unreferenced_audio_media(
|
||||||
|
pool: &PgPool,
|
||||||
|
track_ids: &[i64],
|
||||||
|
media_file_ids: &[i64],
|
||||||
|
) -> anyhow::Result<DeleteStats> {
|
||||||
|
if track_ids.is_empty() {
|
||||||
|
return Ok(DeleteStats::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let mut stats = DeleteStats::default();
|
||||||
|
|
||||||
|
stats.playback_states_cleared = sqlx::query(
|
||||||
|
r#"UPDATE furumusic__playback_state
|
||||||
|
SET current_track_id = NULL
|
||||||
|
WHERE current_track_id = ANY($1)"#,
|
||||||
|
)
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
stats.playlist_entries_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__playlist_track", track_ids).await?;
|
||||||
|
stats.likes_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__user_liked_track", track_ids).await?;
|
||||||
|
stats.play_history_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__play_history", track_ids).await?;
|
||||||
|
stats.popularity_history_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__track_popularity_history", track_ids).await?;
|
||||||
|
stats.scrobble_outbox_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__lastfm_scrobble_outbox", track_ids).await?;
|
||||||
|
stats.track_genres_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__track_genre", track_ids).await?;
|
||||||
|
|
||||||
|
stats.entity_tags_deleted = sqlx::query(
|
||||||
|
r#"DELETE FROM furumusic__entity_genre_tag
|
||||||
|
WHERE entity_kind = 'track'
|
||||||
|
AND entity_id = ANY($1)"#,
|
||||||
|
)
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
stats.external_ids_deleted = sqlx::query(
|
||||||
|
r#"DELETE FROM furumusic__external_metadata_id
|
||||||
|
WHERE entity_kind = 'track'
|
||||||
|
AND entity_id = ANY($1)"#,
|
||||||
|
)
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
stats.track_artists_deleted =
|
||||||
|
delete_track_rows(&mut tx, "furumusic__track_artist", track_ids).await?;
|
||||||
|
|
||||||
|
stats.tracks_deleted = sqlx::query("DELETE FROM furumusic__track WHERE id = ANY($1)")
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if !media_file_ids.is_empty() {
|
||||||
|
stats.media_files_deleted = sqlx::query(
|
||||||
|
r#"DELETE FROM furumusic__media_file mf
|
||||||
|
WHERE mf.id = ANY($1)
|
||||||
|
AND mf.file_type = 'audio'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__track t
|
||||||
|
WHERE t.audio_file_id = mf.id
|
||||||
|
OR t.cover_file_id = mf.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__release r
|
||||||
|
WHERE r.cover_file_id = mf.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__artist a
|
||||||
|
WHERE a.image_file_id = mf.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__playlist p
|
||||||
|
WHERE p.cover_file_id = mf.id
|
||||||
|
)"#,
|
||||||
|
)
|
||||||
|
.bind(media_file_ids)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_track_rows(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
table: &str,
|
||||||
|
track_ids: &[i64],
|
||||||
|
) -> anyhow::Result<u64> {
|
||||||
|
let sql = format!("DELETE FROM {table} WHERE track_id = ANY($1)");
|
||||||
|
Ok(sqlx::query(&sql)
|
||||||
|
.bind(track_ids)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?
|
||||||
|
.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_empty_releases(pool: &PgPool, release_ids: &[i64]) -> anyhow::Result<i64> {
|
||||||
|
if release_ids.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = sqlx::query_scalar::<_, i64>(
|
||||||
|
r#"SELECT COUNT(*)
|
||||||
|
FROM furumusic__release r
|
||||||
|
WHERE r.id = ANY($1)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM furumusic__track t
|
||||||
|
WHERE t.release_id = r.id
|
||||||
|
)"#,
|
||||||
|
)
|
||||||
|
.bind(release_ids)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unique_sorted_deduplicates_ids() {
|
||||||
|
assert_eq!(unique_sorted(vec![3, 1, 3, 2, 1]), vec![1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_reason_display_is_stable() {
|
||||||
|
assert_eq!(
|
||||||
|
MissingReason::InvalidMediaType("cover_art".to_owned()).to_string(),
|
||||||
|
"invalid media_file type \"cover_art\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MissingReason::MissingFile.to_string(),
|
||||||
|
"audio file not found on disk"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,7 +128,11 @@ impl Job for InboxDiscoverJob {
|
|||||||
v
|
v
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
crate::metrics::record_agent_file_hash(hash_start.elapsed(), 0, "error");
|
crate::metrics::record_agent_file_hash(
|
||||||
|
hash_start.elapsed(),
|
||||||
|
0,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
log.warn(&format!("Failed to hash {}: {e}", file_path.display()));
|
log.warn(&format!("Failed to hash {}: {e}", file_path.display()));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -494,7 +494,12 @@ async fn process_folder_batch(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Ok(results)) => {
|
Ok(Ok(results)) => {
|
||||||
crate::metrics::record_agent_rag("artist", "ok", rag_start.elapsed(), results.len());
|
crate::metrics::record_agent_rag(
|
||||||
|
"artist",
|
||||||
|
"ok",
|
||||||
|
rag_start.elapsed(),
|
||||||
|
results.len(),
|
||||||
|
);
|
||||||
for a in results {
|
for a in results {
|
||||||
if !all_similar_artists
|
if !all_similar_artists
|
||||||
.iter()
|
.iter()
|
||||||
@@ -525,7 +530,12 @@ async fn process_folder_batch(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Ok(results)) => {
|
Ok(Ok(results)) => {
|
||||||
crate::metrics::record_agent_rag("release", "ok", rag_start.elapsed(), results.len());
|
crate::metrics::record_agent_rag(
|
||||||
|
"release",
|
||||||
|
"ok",
|
||||||
|
rag_start.elapsed(),
|
||||||
|
results.len(),
|
||||||
|
);
|
||||||
for r in results {
|
for r in results {
|
||||||
if !all_similar_releases
|
if !all_similar_releases
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod archive_cleanup;
|
||||||
pub mod artwork_backfill;
|
pub mod artwork_backfill;
|
||||||
pub mod inbox_discover;
|
pub mod inbox_discover;
|
||||||
pub mod inbox_process;
|
pub mod inbox_process;
|
||||||
|
|||||||
+20
@@ -52,6 +52,7 @@ fn build_registry() -> Arc<JobRegistry> {
|
|||||||
registry.register(jobs::inbox_discover::InboxDiscoverJob);
|
registry.register(jobs::inbox_discover::InboxDiscoverJob);
|
||||||
registry.register(jobs::inbox_process::InboxProcessJob);
|
registry.register(jobs::inbox_process::InboxProcessJob);
|
||||||
registry.register(jobs::inbox_process::FileProcessJob);
|
registry.register(jobs::inbox_process::FileProcessJob);
|
||||||
|
registry.register(jobs::archive_cleanup::ArchiveCleanupJob);
|
||||||
registry.register(jobs::artwork_backfill::ArtworkBackfillJob);
|
registry.register(jobs::artwork_backfill::ArtworkBackfillJob);
|
||||||
registry.register(jobs::metadata_backfill::MetadataBackfillJob);
|
registry.register(jobs::metadata_backfill::MetadataBackfillJob);
|
||||||
registry.register(jobs::lastfm_popularity::LastfmPopularityJob);
|
registry.register(jobs::lastfm_popularity::LastfmPopularityJob);
|
||||||
@@ -378,6 +379,15 @@ impl App for FuruApp {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if !live_config.auth_password_enabled {
|
||||||
|
metrics::record_auth_attempt("password", "failure", "disabled");
|
||||||
|
let msg = i18n.t.login_disabled.to_owned();
|
||||||
|
return login_page_handler(i18n, &config, db, msg)
|
||||||
|
.await?
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
// Try to authenticate
|
// Try to authenticate
|
||||||
if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await
|
if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await
|
||||||
{
|
{
|
||||||
@@ -425,6 +435,16 @@ impl App for FuruApp {
|
|||||||
get(oidc::oidc_callback_handler),
|
get(oidc::oidc_callback_handler),
|
||||||
"oidc_callback",
|
"oidc_callback",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/auth/mobile/oidc/start",
|
||||||
|
get(oidc::oidc_mobile_start_handler),
|
||||||
|
"mobile_oidc_start",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/auth/mobile/oidc/callback",
|
||||||
|
get(oidc::oidc_mobile_callback_handler),
|
||||||
|
"mobile_oidc_callback",
|
||||||
|
),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+409
-3
@@ -4,6 +4,7 @@ use std::sync::LazyLock;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use cot::db::Database;
|
use cot::db::Database;
|
||||||
|
use cot::request::extractors::UrlQuery;
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
use openidconnect::core::{CoreClient, CoreProviderMetadata};
|
use openidconnect::core::{CoreClient, CoreProviderMetadata};
|
||||||
use openidconnect::{
|
use openidconnect::{
|
||||||
@@ -54,6 +55,13 @@ const SESSION_NONCE: &str = "oidc_nonce";
|
|||||||
const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier";
|
const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier";
|
||||||
const SESSION_REDIRECT_URI: &str = "oidc_redirect_uri";
|
const SESSION_REDIRECT_URI: &str = "oidc_redirect_uri";
|
||||||
|
|
||||||
|
const SESSION_MOBILE_CSRF_STATE: &str = "mobile_oidc_csrf_state";
|
||||||
|
const SESSION_MOBILE_NONCE: &str = "mobile_oidc_nonce";
|
||||||
|
const SESSION_MOBILE_PKCE_VERIFIER: &str = "mobile_oidc_pkce_verifier";
|
||||||
|
const SESSION_MOBILE_PROVIDER_REDIRECT_URI: &str = "mobile_oidc_provider_redirect_uri";
|
||||||
|
const SESSION_MOBILE_APP_REDIRECT_URI: &str = "mobile_oidc_app_redirect_uri";
|
||||||
|
const DEFAULT_MOBILE_REDIRECT_URI: &str = "furumi://auth/callback";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Provider cache
|
// Provider cache
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -247,13 +255,23 @@ pub struct OidcCallbackQuery {
|
|||||||
state: String,
|
state: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MobileOidcStartQuery {
|
||||||
|
redirect_uri: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MobileOidcCallbackQuery {
|
||||||
|
code: Option<String>,
|
||||||
|
state: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn oidc_callback_handler(
|
pub async fn oidc_callback_handler(
|
||||||
i18n: I18n,
|
i18n: I18n,
|
||||||
db: Database,
|
db: Database,
|
||||||
session: Session,
|
session: Session,
|
||||||
cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery<
|
UrlQuery(query): UrlQuery<OidcCallbackQuery>,
|
||||||
OidcCallbackQuery,
|
|
||||||
>,
|
|
||||||
) -> cot::Result<cot::response::Response> {
|
) -> cot::Result<cot::response::Response> {
|
||||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
|
||||||
@@ -461,6 +479,296 @@ pub async fn oidc_callback_handler(
|
|||||||
Ok(auth::redirect(&redirect_to))
|
Ok(auth::redirect(&redirect_to))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mobile OIDC flow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn oidc_mobile_start_handler(
|
||||||
|
origin: RequestOrigin,
|
||||||
|
db: Database,
|
||||||
|
session: Session,
|
||||||
|
UrlQuery(query): UrlQuery<MobileOidcStartQuery>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(app_redirect_uri) = safe_mobile_redirect_uri(query.redirect_uri.as_deref()) else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "bad_redirect_uri");
|
||||||
|
return Ok(text_response(
|
||||||
|
cot::http::StatusCode::BAD_REQUEST,
|
||||||
|
"invalid mobile redirect_uri",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
|
||||||
|
if !config.auth_sso_enabled
|
||||||
|
|| config.oidc_issuer.is_empty()
|
||||||
|
|| config.oidc_client_id.is_empty()
|
||||||
|
|| config.oidc_client_secret.is_empty()
|
||||||
|
{
|
||||||
|
tracing::warn!("Mobile OIDC start requested but SSO is not configured");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_configured");
|
||||||
|
return Ok(mobile_redirect_error(
|
||||||
|
&app_redirect_uri,
|
||||||
|
"sso_not_configured",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let http = oidc_http_client();
|
||||||
|
let client = match get_or_refresh_provider(&config, &http).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC provider error: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_error");
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider_redirect_uri = format!("{}/auth/mobile/oidc/callback", origin.0);
|
||||||
|
let redirect_url = RedirectUrl::new(provider_redirect_uri.clone())
|
||||||
|
.map_err(|e| cot::Error::internal(format!("bad mobile redirect URI: {e}")))?;
|
||||||
|
let client = client.set_redirect_uri(redirect_url);
|
||||||
|
|
||||||
|
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||||
|
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();
|
||||||
|
|
||||||
|
session
|
||||||
|
.insert(SESSION_MOBILE_CSRF_STATE, csrf_state.secret().clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_MOBILE_NONCE, nonce.secret().clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_MOBILE_PKCE_VERIFIER, pkce_verifier.secret().clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.insert(
|
||||||
|
SESSION_MOBILE_PROVIDER_REDIRECT_URI,
|
||||||
|
provider_redirect_uri.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_MOBILE_APP_REDIRECT_URI, app_redirect_uri)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
auth_url = %auth_url,
|
||||||
|
provider_redirect_uri = %provider_redirect_uri,
|
||||||
|
"Mobile OIDC start: redirecting to provider",
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(auth::redirect(auth_url.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn oidc_mobile_callback_handler(
|
||||||
|
db: Database,
|
||||||
|
session: Session,
|
||||||
|
UrlQuery(query): UrlQuery<MobileOidcCallbackQuery>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let app_redirect_uri = mobile_app_redirect_uri_from_session(&session).await?;
|
||||||
|
|
||||||
|
if query.error.is_some() {
|
||||||
|
tracing::warn!("Mobile OIDC callback returned provider error");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_denied");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(code) = query.code else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_code");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_code"));
|
||||||
|
};
|
||||||
|
let Some(state) = query.state else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_state");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_state"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let saved_csrf: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_CSRF_STATE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let saved_nonce: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_NONCE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let saved_pkce: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_PKCE_VERIFIER)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let provider_redirect_uri: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_PROVIDER_REDIRECT_URI)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let Some(saved_csrf) = saved_csrf else {
|
||||||
|
tracing::warn!("Mobile OIDC callback: no CSRF state in session");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_state");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_state"));
|
||||||
|
};
|
||||||
|
if state != saved_csrf {
|
||||||
|
tracing::warn!("Mobile OIDC callback: CSRF state mismatch");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "csrf");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "csrf"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(nonce_str) = saved_nonce else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_nonce");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_nonce"));
|
||||||
|
};
|
||||||
|
let Some(pkce_str) = saved_pkce else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_pkce");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_pkce"));
|
||||||
|
};
|
||||||
|
let Some(provider_redirect_uri) = provider_redirect_uri else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_redirect_uri");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(
|
||||||
|
&app_redirect_uri,
|
||||||
|
"missing_redirect_uri",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if !config.auth_sso_enabled
|
||||||
|
|| config.oidc_issuer.is_empty()
|
||||||
|
|| config.oidc_client_id.is_empty()
|
||||||
|
|| config.oidc_client_secret.is_empty()
|
||||||
|
{
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_configured");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(
|
||||||
|
&app_redirect_uri,
|
||||||
|
"sso_not_configured",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let http = oidc_http_client();
|
||||||
|
let client = match get_or_refresh_provider(&config, &http).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC provider error during callback: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_error");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let redirect_url = RedirectUrl::new(provider_redirect_uri)
|
||||||
|
.map_err(|e| cot::Error::internal(format!("bad mobile redirect URI from session: {e}")))?;
|
||||||
|
let client = client.set_redirect_uri(redirect_url);
|
||||||
|
|
||||||
|
let token_request = match client.exchange_code(AuthorizationCode::new(code)) {
|
||||||
|
Ok(req) => req,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC token endpoint not configured: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "token_config");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let token_response = token_request
|
||||||
|
.set_pkce_verifier(PkceCodeVerifier::new(pkce_str))
|
||||||
|
.request_async(&http)
|
||||||
|
.await;
|
||||||
|
let token_response = match token_response {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC token exchange failed: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "token_exchange");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
use openidconnect::TokenResponse;
|
||||||
|
let id_token = match token_response.id_token() {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
tracing::error!("Mobile OIDC response missing ID token");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_id_token");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let nonce = Nonce::new(nonce_str);
|
||||||
|
let claims = match id_token.claims(&client.id_token_verifier(), &nonce) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC ID token verification failed: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "id_token_verify");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "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());
|
||||||
|
let groups = extract_groups_from_jwt(&id_token.to_string());
|
||||||
|
|
||||||
|
if !is_allowed_by_groups(&groups, &config.oidc_user_groups, &config.oidc_admin_groups) {
|
||||||
|
tracing::warn!(
|
||||||
|
"Mobile OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}",
|
||||||
|
config.oidc_user_groups,
|
||||||
|
config.oidc_admin_groups,
|
||||||
|
);
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_in_group");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "access_denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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!("Mobile OIDC user provisioning failed: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provisioning");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let exchange_code = auth::create_mobile_exchange_code(&db, user.id_val())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "success", "ok");
|
||||||
|
crate::metrics::record_session_created("mobile_oidc");
|
||||||
|
Ok(mobile_redirect_success(&app_redirect_uri, &exchange_code))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// User provisioning
|
// User provisioning
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -616,6 +924,104 @@ fn redirect_login_with_error(message: &str) -> cot::Result<cot::response::Respon
|
|||||||
Ok(auth::redirect(&format!("/login?error={encoded}")))
|
Ok(auth::redirect(&format!("/login?error={encoded}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn text_response(status: cot::http::StatusCode, message: &str) -> cot::response::Response {
|
||||||
|
cot::http::Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.body(cot::Body::fixed(message.to_owned()))
|
||||||
|
.expect("valid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mobile_app_redirect_uri_from_session(session: &Session) -> cot::Result<String> {
|
||||||
|
let saved: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_APP_REDIRECT_URI)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Ok(safe_mobile_redirect_uri(saved.as_deref())
|
||||||
|
.unwrap_or_else(|| DEFAULT_MOBILE_REDIRECT_URI.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_mobile_oidc_session(session: &Session) -> cot::Result<()> {
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_CSRF_STATE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_NONCE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_PKCE_VERIFIER)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_PROVIDER_REDIRECT_URI)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_APP_REDIRECT_URI)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_mobile_redirect_uri(raw: Option<&str>) -> Option<String> {
|
||||||
|
let value = raw
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(DEFAULT_MOBILE_REDIRECT_URI);
|
||||||
|
if value.len() > 2048 || value.bytes().any(|b| matches!(b, b'\r' | b'\n')) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lower = value.to_ascii_lowercase();
|
||||||
|
if lower.starts_with("furumi://") || lower.starts_with("furumusic://") {
|
||||||
|
return Some(value.to_owned());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response {
|
||||||
|
auth::redirect(&append_query_param(app_redirect_uri, "code", code))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response {
|
||||||
|
auth::redirect(&append_query_param(app_redirect_uri, "error", error))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_query_param(uri: &str, key: &str, value: &str) -> String {
|
||||||
|
let (base, fragment) = uri.split_once('#').unwrap_or((uri, ""));
|
||||||
|
let separator = if base.contains('?') { '&' } else { '?' };
|
||||||
|
let mut out = format!("{base}{separator}{key}={}", urlencoded(value));
|
||||||
|
if !fragment.is_empty() {
|
||||||
|
out.push('#');
|
||||||
|
out.push_str(fragment);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_groups_from_jwt(token: &str) -> Vec<String> {
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
let Some(payload_b64) = token.split('.').nth(1) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Ok(payload_bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode(payload_b64)
|
||||||
|
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64))
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Ok(value) = serde_json::from_slice::<serde_json::Value>(&payload_bytes) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Some(arr) = value.get("groups").and_then(|value| value.as_array()) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|value| value.as_str().map(String::from))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Minimal percent-encoding for query parameter values.
|
/// Minimal percent-encoding for query parameter values.
|
||||||
fn urlencoded(s: &str) -> String {
|
fn urlencoded(s: &str) -> String {
|
||||||
let mut out = String::with_capacity(s.len() * 2);
|
let mut out = String::with_capacity(s.len() * 2);
|
||||||
|
|||||||
+612
-279
File diff suppressed because it is too large
Load Diff
@@ -1372,6 +1372,7 @@ async fn run_scheduled_job(
|
|||||||
if !live_config.agent_enabled
|
if !live_config.agent_enabled
|
||||||
&& job_name != "lastfm_popularity"
|
&& job_name != "lastfm_popularity"
|
||||||
&& job_name != "lastfm_scrobble"
|
&& job_name != "lastfm_scrobble"
|
||||||
|
&& job_name != "archive_cleanup"
|
||||||
&& job_name != "artwork_backfill"
|
&& job_name != "artwork_backfill"
|
||||||
{
|
{
|
||||||
tracing::warn!(job = job_name, "Skipping: agent_enabled=false");
|
tracing::warn!(job = job_name, "Skipping: agent_enabled=false");
|
||||||
|
|||||||
Reference in New Issue
Block a user