use axum::{ extract::{Path, Query, State}, http::StatusCode, Json, }; use chrono::{DateTime, NaiveDateTime}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; use crate::auth::{ passkeys::PasskeySummary, webdav_tokens::{ create_webdav_token as issue_token, list_webdav_tokens as load_tokens, regenerate_webdav_token as rotate_token, revoke_webdav_token as revoke_token, }, TenantScopedConn, }; use crate::error::{AppError, AppResult}; use crate::models::WebdavToken; use crate::state::AppState; use crate::utils::{db::no_content, time::to_iso}; #[derive(Debug, Serialize, ToSchema)] pub struct WebdavTokenResponse { pub id: Uuid, pub tenant_id: Uuid, #[schema(nullable)] pub label: Option, pub created_at: String, #[schema(nullable)] pub last_used_at: Option, #[schema(nullable)] pub expires_at: Option, #[schema(nullable)] pub revoked_at: Option, } #[derive(Debug, Serialize, ToSchema)] pub struct WebdavTokenCreatedResponse { pub token: String, pub token_info: WebdavTokenResponse, } #[derive(Debug, Deserialize, ToSchema)] pub struct CreateWebdavTokenRequest { #[schema(nullable)] pub label: Option, #[schema(nullable)] pub expires_at: Option, } #[derive(Debug, Deserialize, ToSchema)] pub struct RevokePasskeyQuery { #[serde(default)] #[schema(nullable)] pub reason: Option, } #[utoipa::path( get, path = "/api/profile/passkeys", responses((status = 200, description = "List registered passkeys", body = [PasskeySummary])), tag = "Profile" )] pub async fn list_passkeys( State(state): State, TenantScopedConn { mut conn, user_id, .. }: TenantScopedConn, ) -> AppResult>> { let service = state .passkeys .as_ref() .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; let passkeys = service.list_for_user(&mut conn, user_id)?; Ok(Json(passkeys)) } #[utoipa::path( get, path = "/api/profile/webdav-tokens", responses((status = 200, description = "List WebDAV tokens", body = [WebdavTokenResponse])), tag = "Profile" )] pub async fn list_webdav_tokens( TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, ) -> AppResult>> { let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?; let responses = tokens.into_iter().map(webdav_token_to_response).collect(); Ok(Json(responses)) } #[utoipa::path( post, path = "/api/profile/webdav-tokens", request_body = CreateWebdavTokenRequest, responses((status = 201, description = "WebDAV token created", body = WebdavTokenCreatedResponse)), tag = "Profile" )] pub async fn create_webdav_token( TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, Json(payload): Json, ) -> AppResult<(StatusCode, Json)> { let expires_at = match payload.expires_at { Some(ref value) => Some(parse_timestamp(value)?), None => None, }; let issued = issue_token( &mut conn, user_id, tenant_id, payload.label.clone(), expires_at, )?; let response = WebdavTokenCreatedResponse { token: issued.token, token_info: webdav_token_to_response(issued.record), }; Ok((StatusCode::CREATED, Json(response))) } #[utoipa::path( post, path = "/api/profile/webdav-tokens/{id}/regenerate", params(("id" = Uuid, Path, description = "WebDAV token ID")), responses((status = 200, description = "WebDAV token regenerated", body = WebdavTokenCreatedResponse)), tag = "Profile" )] pub async fn regenerate_webdav_token( TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, Path(token_id): Path, ) -> AppResult> { let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?; let response = WebdavTokenCreatedResponse { token: issued.token, token_info: webdav_token_to_response(issued.record), }; Ok(Json(response)) } #[utoipa::path( delete, path = "/api/profile/webdav-tokens/{id}", params(("id" = Uuid, Path, description = "WebDAV token ID")), responses((status = 204, description = "WebDAV token revoked")), tag = "Profile" )] pub async fn delete_webdav_token( TenantScopedConn { mut conn, user_id, .. }: TenantScopedConn, Path(token_id): Path, ) -> AppResult { revoke_token(&mut conn, token_id, user_id)?; no_content() } #[utoipa::path( delete, path = "/api/profile/passkeys/{id}", params( ("id" = Uuid, Path, description = "Passkey ID"), ("reason" = Option, Query, description = "Optional reason for revoking the passkey") ), responses((status = 204, description = "Passkey revoked")), tag = "Profile" )] pub async fn delete_passkey( State(state): State, TenantScopedConn { mut conn, user_id, .. }: TenantScopedConn, Path(passkey_id): Path, Query(query): Query, ) -> AppResult { let service = state .passkeys .as_ref() .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; let active_count = service.active_passkey_count(&mut conn, user_id)?; if active_count <= 1 { return Err(AppError::bad_request( "cannot revoke the last remaining passkey", )); } service.revoke_passkey(&mut conn, user_id, passkey_id, query.reason)?; no_content() } fn webdav_token_to_response(token: WebdavToken) -> WebdavTokenResponse { WebdavTokenResponse { id: token.id, tenant_id: token.tenant_id, label: token.label, created_at: to_iso(token.created_at), last_used_at: token.last_used_at.map(to_iso), expires_at: token.expires_at.map(to_iso), revoked_at: token.revoked_at.map(to_iso), } } fn parse_timestamp(value: &str) -> AppResult { let dt = DateTime::parse_from_rfc3339(value) .map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?; Ok(dt.naive_utc()) } #[derive(utoipa::OpenApi)] #[openapi( paths( crate::routes::profile::list_webdav_tokens, crate::routes::profile::create_webdav_token, crate::routes::profile::regenerate_webdav_token, crate::routes::profile::delete_webdav_token, crate::routes::profile::list_passkeys, crate::routes::profile::delete_passkey ), components(schemas( crate::routes::profile::WebdavTokenResponse, crate::routes::profile::WebdavTokenCreatedResponse, crate::routes::profile::CreateWebdavTokenRequest, crate::routes::profile::RevokePasskeyQuery, crate::auth::passkeys::PasskeySummary )) )] pub struct ProfileApiDoc;