From 18352caabe9d14f47818e59b72f6312b264055c2 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Fri, 31 Oct 2025 20:37:07 +0100 Subject: [PATCH] webdav token regeneration --- backend/src/auth/webdav_tokens.rs | 45 ++++++++++++++++ backend/src/routes/auth.rs | 10 +++- backend/src/routes/mod.rs | 4 ++ backend/src/routes/profile.rs | 28 +++++++++- backend/src/routes/webdav/mod.rs | 3 +- backend/src/tenants.rs | 2 +- backend/src/workers/index.rs | 36 +++++++------ backend/src/workers/ocr.rs | 38 ++++++++------ backend/src/workers/thumbnails.rs | 40 ++++++++------ backend/tests/common/mod.rs | 3 +- backend/tests/webdav_flow.rs | 70 ++++++++++++++++++++++++- frontend/src/app/AppLayout.jsx | 46 ++++++++++++++++ frontend/src/app/SettingsRoute.jsx | 4 ++ frontend/src/settings/SettingsModal.jsx | 44 +++++++++++++--- 14 files changed, 304 insertions(+), 69 deletions(-) diff --git a/backend/src/auth/webdav_tokens.rs b/backend/src/auth/webdav_tokens.rs index 0ea62f7..a72e58c 100644 --- a/backend/src/auth/webdav_tokens.rs +++ b/backend/src/auth/webdav_tokens.rs @@ -74,6 +74,51 @@ pub fn list_webdav_tokens( Ok(tokens) } +pub fn regenerate_webdav_token( + conn: &mut PgPooledConnection, + token_id: Uuid, + user_id: Uuid, + tenant_id: Option, +) -> Result { + let mut query = webdav_tokens::table + .filter(webdav_tokens::id.eq(token_id)) + .filter(webdav_tokens::user_id.eq(user_id)) + .into_boxed(); + + if let Some(tenant) = tenant_id { + query = query.filter(webdav_tokens::tenant_id.eq(tenant)); + } + + let record = query + .first::(conn) + .optional() + .map_err(AppError::from)? + .ok_or_else(AppError::not_found)?; + + if record.revoked_at.is_some() { + return Err(AppError::bad_request( + "cannot regenerate a revoked WebDAV token", + )); + } + + let raw_secret = generate_secret()?; + let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string(); + let token_hash = hash_secret(&raw_secret)?; + + let updated = diesel::update(webdav_tokens::table.find(record.id)) + .set(( + webdav_tokens::token_prefix.eq(&token_prefix), + webdav_tokens::token_hash.eq(&token_hash), + webdav_tokens::last_used_at.eq::>(None), + )) + .get_result::(conn)?; + + Ok(IssuedWebdavToken { + token: raw_secret, + record: updated, + }) +} + pub fn find_active_token_by_secret( conn: &mut PgPooledConnection, user_id: Uuid, diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index ce20cc5..9eb34b4 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -533,7 +533,10 @@ pub async fn list_tenants( .select(tenant_dsl::name) .first(&mut tenant_conn) .map_err(AppError::from)?; - tenants.push(TenantSnippet { id: tenant_id, name }); + tenants.push(TenantSnippet { + id: tenant_id, + name, + }); } Ok(Json(TenantListResponse { tenants })) @@ -699,7 +702,10 @@ fn complete_login( .select(tenant_dsl::name) .first(&mut tenant_conn) .map_err(AppError::from)?; - tenants.push(TenantSnippet { id: tenant_id, name }); + tenants.push(TenantSnippet { + id: tenant_id, + name, + }); } Ok(Json(TenantSelectionResponse { diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index c5b163c..081540f 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -148,6 +148,10 @@ pub fn create_router(state: AppState) -> Router<()> { "/webdav-tokens", get(profile::list_webdav_tokens).post(profile::create_webdav_token), ) + .route( + "/webdav-tokens/:id/regenerate", + post(profile::regenerate_webdav_token), + ) .route("/webdav-tokens/:id", delete(profile::delete_webdav_token)) .route("/passkeys", get(profile::list_passkeys)) .route("/passkeys/:id", delete(profile::delete_passkey)); diff --git a/backend/src/routes/profile.rs b/backend/src/routes/profile.rs index dbdcdaa..576e467 100644 --- a/backend/src/routes/profile.rs +++ b/backend/src/routes/profile.rs @@ -12,7 +12,7 @@ use crate::auth::{ passkeys::PasskeySummary, webdav_tokens::{ create_webdav_token as issue_token, list_webdav_tokens as load_tokens, - revoke_webdav_token as revoke_token, + regenerate_webdav_token as rotate_token, revoke_webdav_token as revoke_token, }, TenantScopedConn, }; @@ -134,6 +134,31 @@ pub async fn create_webdav_token( 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}", @@ -208,6 +233,7 @@ fn parse_timestamp(value: &str) -> AppResult { 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 diff --git a/backend/src/routes/webdav/mod.rs b/backend/src/routes/webdav/mod.rs index cea1edc..5255a3e 100644 --- a/backend/src/routes/webdav/mod.rs +++ b/backend/src/routes/webdav/mod.rs @@ -21,8 +21,7 @@ use crate::error::{AppError, AppResult}; use crate::models::{Document, DocumentVersion, Folder, User}; use crate::schema::{ document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl, - folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, - users::dsl as users_dsl, + folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl, }; use crate::state::AppState; use crate::tenants::{apply_tenant_guc, apply_user_guc, clear_user_guc}; diff --git a/backend/src/tenants.rs b/backend/src/tenants.rs index 4393177..97dccba 100644 --- a/backend/src/tenants.rs +++ b/backend/src/tenants.rs @@ -149,7 +149,7 @@ pub fn clear_tenant_context(conn: &mut PgConnection) -> AppResult<()> { set_config('papercrate.tenant_id', '', false), \ set_config('papercrate.user_id', '', false), \ set_config('papercrate.refresh_token_hash', '', false), \ - set_config('papercrate.webdav_token_prefix', '', false)" + set_config('papercrate.webdav_token_prefix', '', false)", ) .execute(conn) .map(|_| ()) diff --git a/backend/src/workers/index.rs b/backend/src/workers/index.rs index d7125b9..e3a472b 100644 --- a/backend/src/workers/index.rs +++ b/backend/src/workers/index.rs @@ -90,23 +90,25 @@ impl JobHandler for IndexDocumentTextJob { let state_clone = state.clone(); let tenant_id = job.tenant_id; - let context = match task::spawn_blocking(move || load_context(state_clone, tenant_id, payload)).await { - Ok(Ok(ctx)) => ctx, - Ok(Err(err)) => { - warn!(job_id = %job.id, error = %err, "index job will retry"); - return JobExecution::Retry { - delay: Duration::from_secs(30), - error: err, - }; - } - Err(join_err) => { - error!(job_id = %job.id, error = %join_err, "index task panicked"); - return JobExecution::Retry { - delay: Duration::from_secs(60), - error: format!("worker panicked: {join_err}"), - }; - } - }; + let context = + match task::spawn_blocking(move || load_context(state_clone, tenant_id, payload)).await + { + Ok(Ok(ctx)) => ctx, + Ok(Err(err)) => { + warn!(job_id = %job.id, error = %err, "index job will retry"); + return JobExecution::Retry { + delay: Duration::from_secs(30), + error: err, + }; + } + Err(join_err) => { + error!(job_id = %job.id, error = %join_err, "index task panicked"); + return JobExecution::Retry { + delay: Duration::from_secs(60), + error: format!("worker panicked: {join_err}"), + }; + } + }; if context.text_s3_key.is_none() { warn!(job_id = %job.id, "missing OCR text asset; failing indexing job"); diff --git a/backend/src/workers/ocr.rs b/backend/src/workers/ocr.rs index e3196a4..fb08d8a 100644 --- a/backend/src/workers/ocr.rs +++ b/backend/src/workers/ocr.rs @@ -83,22 +83,22 @@ impl JobHandler for GenerateOcrTextJob { }) .await { - Ok(Ok(ctx)) => ctx, - Ok(Err(err)) => { - warn!(job_id = %job.id, error = %err, "ocr job will retry"); - return JobExecution::Retry { - delay: Duration::from_secs(30), - error: err, - }; - } - Err(join_err) => { - error!(job_id = %job.id, error = %join_err, "ocr task panicked"); - return JobExecution::Retry { - delay: Duration::from_secs(60), - error: format!("worker panicked: {join_err}"), - }; - } - }; + Ok(Ok(ctx)) => ctx, + Ok(Err(err)) => { + warn!(job_id = %job.id, error = %err, "ocr job will retry"); + return JobExecution::Retry { + delay: Duration::from_secs(30), + error: err, + }; + } + Err(join_err) => { + error!(job_id = %job.id, error = %join_err, "ocr task panicked"); + return JobExecution::Retry { + delay: Duration::from_secs(60), + error: format!("worker panicked: {join_err}"), + }; + } + }; if context.skip { info!(job_id = %job.id, "ocr already present; skipping"); @@ -489,7 +489,11 @@ fn persist_ocr_metadata( Ok(()) } -fn enqueue_index_job(state: &AppState, tenant_id: Uuid, payload: &OcrPayload) -> Result<(), String> { +fn enqueue_index_job( + state: &AppState, + tenant_id: Uuid, + payload: &OcrPayload, +) -> Result<(), String> { let mut conn = state .db_for_tenant(tenant_id) .map_err(|err| format!("{err:?}"))?; diff --git a/backend/src/workers/thumbnails.rs b/backend/src/workers/thumbnails.rs index 111fdb3..f1237a2 100644 --- a/backend/src/workers/thumbnails.rs +++ b/backend/src/workers/thumbnails.rs @@ -82,22 +82,22 @@ impl JobHandler for GenerateThumbnailsJob { }) .await { - Ok(Ok(ctx)) => ctx, - Ok(Err(err)) => { - warn!(job_id = %job.id, error = %err, "thumbnail job will retry"); - return JobExecution::Retry { - delay: Duration::from_secs(30), - error: err, - }; - } - Err(join_err) => { - error!(job_id = %job.id, error = %join_err, "thumbnail task panicked"); - return JobExecution::Retry { - delay: Duration::from_secs(60), - error: format!("worker panicked: {join_err}"), - }; - } - }; + Ok(Ok(ctx)) => ctx, + Ok(Err(err)) => { + warn!(job_id = %job.id, error = %err, "thumbnail job will retry"); + return JobExecution::Retry { + delay: Duration::from_secs(30), + error: err, + }; + } + Err(join_err) => { + error!(job_id = %job.id, error = %join_err, "thumbnail task panicked"); + return JobExecution::Retry { + delay: Duration::from_secs(60), + error: format!("worker panicked: {join_err}"), + }; + } + }; if initial.skip { info!(job_id = %job.id, "thumbnails already exist; skipping"); @@ -129,7 +129,13 @@ impl JobHandler for GenerateThumbnailsJob { let version_id = initial.version.id; let tenant_id = initial.tenant_id; match task::spawn_blocking(move || { - persist_document_page_count(state_clone, tenant_id, document_id, version_id, page_count) + persist_document_page_count( + state_clone, + tenant_id, + document_id, + version_id, + page_count, + ) }) .await { diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 9eea33d..9710042 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -37,8 +37,7 @@ use tower::util::ServiceExt; use uuid::Uuid; const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); -const RESET_DATABASE_SQL: &str = - "DROP SCHEMA IF EXISTS tenant CASCADE;\n\ +const RESET_DATABASE_SQL: &str = "DROP SCHEMA IF EXISTS tenant CASCADE;\n\ DROP SCHEMA IF EXISTS shared CASCADE;\n\ DROP SCHEMA IF EXISTS public CASCADE;\n\ CREATE SCHEMA public;\n\ diff --git a/backend/tests/webdav_flow.rs b/backend/tests/webdav_flow.rs index 581b722..2fe47ee 100644 --- a/backend/tests/webdav_flow.rs +++ b/backend/tests/webdav_flow.rs @@ -54,6 +54,20 @@ async fn webdav_token_api_crud() -> Result<()> { assert_eq!(created.info.label.as_deref(), Some("dav")); assert!(created.info.last_used_at.is_none()); + let regenerate_response = app + .post_json( + &format!("/api/profile/webdav-tokens/{token_id}/regenerate"), + &json!({}), + Some(&access_token), + ) + .await?; + assert_eq!(regenerate_response.status(), StatusCode::OK); + let regenerate_body = body_to_vec(regenerate_response.into_body()).await?; + let regenerated: CreateTokenResponse = serde_json::from_slice(®enerate_body)?; + assert_eq!(regenerated.info.id, token_id); + assert_ne!(regenerated.token, created.token); + assert!(regenerated.info.last_used_at.is_none()); + let list_response = app .get("/api/profile/webdav-tokens", Some(&access_token)) .await?; @@ -106,9 +120,10 @@ async fn webdav_basic_auth_uses_tokens() -> Result<()> { let token_id = created.info.id; let router = webdav::create_router().with_state(app.state.clone()); + let original_secret = created.token.clone(); let auth_header = format!( "Basic {}", - BASE64.encode(format!("{}:{}", username, created.token)) + BASE64.encode(format!("{}:{}", username, original_secret)) ); let propfind = Method::from_bytes(b"PROPFIND")?; @@ -131,6 +146,57 @@ async fn webdav_basic_auth_uses_tokens() -> Result<()> { .await?; assert!(used.is_some()); + let regenerate_response = app + .post_json( + &format!("/api/profile/webdav-tokens/{token_id}/regenerate"), + &json!({}), + Some(&access_token), + ) + .await?; + assert_eq!(regenerate_response.status(), StatusCode::OK); + let regenerate_body = body_to_vec(regenerate_response.into_body()).await?; + let regenerated: CreateTokenResponse = serde_json::from_slice(®enerate_body)?; + assert_ne!(regenerated.token, original_secret); + + let unused_after_regen = app + .with_conn(move |conn| { + let record = webdav_tokens::table + .find(token_id) + .first::(conn)?; + Ok::<_, anyhow::Error>(record.last_used_at) + }) + .await?; + assert!(unused_after_regen.is_none()); + + let old_secret_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header( + header::AUTHORIZATION, + format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, original_secret)) + ), + ) + .header("depth", "0") + .body(Body::empty())?; + let old_secret_response = router.clone().oneshot(old_secret_request).await?; + assert_eq!(old_secret_response.status(), StatusCode::UNAUTHORIZED); + + let new_secret_header = format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, regenerated.token)) + ); + + let success_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header(header::AUTHORIZATION, new_secret_header.clone()) + .header("depth", "0") + .body(Body::empty())?; + let response = router.clone().oneshot(success_request).await?; + assert_eq!(response.status(), StatusCode::MULTI_STATUS); + let delete_response = app .delete( &format!("/api/profile/webdav-tokens/{token_id}"), @@ -142,7 +208,7 @@ async fn webdav_basic_auth_uses_tokens() -> Result<()> { let failure_request = Request::builder() .method(propfind) .uri("/") - .header(header::AUTHORIZATION, auth_header) + .header(header::AUTHORIZATION, new_secret_header) .header("depth", "0") .body(Body::empty())?; let response = router.oneshot(failure_request).await?; diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index 37590c4..4cfff69 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -322,6 +322,7 @@ const AppLayout = () => { const [webdavTokensLoading, setWebdavTokensLoading] = useState(false); const [creatingWebdavToken, setCreatingWebdavToken] = useState(false); const [deletingWebdavTokenId, setDeletingWebdavTokenId] = useState(null); + const [regeneratingWebdavTokenId, setRegeneratingWebdavTokenId] = useState(null); const [webdavTokenSecret, setWebdavTokenSecret] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [activeTagFilters, setActiveTagFilters] = useState([]); @@ -1597,6 +1598,47 @@ const AppLayout = () => { [refreshWebdavTokens, notifyApiError, setStatusMessage], ); + const regenerateWebdavToken = useCallback( + async (tokenId) => { + if (!tokenId) { + return false; + } + setRegeneratingWebdavTokenId(tokenId); + try { + const { data } = await api.post(`/profile/webdav-tokens/${tokenId}/regenerate`); + if (data?.token_info) { + setWebdavTokens((previous) => { + let found = false; + const next = previous.map((entry) => { + if (entry.id === data.token_info.id) { + found = true; + return data.token_info; + } + return entry; + }); + if (!found) { + return [data.token_info, ...previous]; + } + return next; + }); + } else { + await refreshWebdavTokens(); + } + if (data?.token) { + setWebdavTokenSecret(data.token); + } + setStatusMessage('WebDAV token regenerated.', 'success'); + return true; + } catch (error) { + notifyApiError(error, 'Failed to regenerate WebDAV token.'); + return false; + } finally { + setRegeneratingWebdavTokenId(null); + } + }, + [notifyApiError, refreshWebdavTokens, setStatusMessage], + ); + const dismissCreatedWebdavToken = useCallback(() => { setWebdavTokenSecret(null); }, []); @@ -5040,9 +5082,11 @@ const AppLayout = () => { webdavTokensLoading, creatingWebdavToken, deletingWebdavTokenId, + regeneratingWebdavTokenId, refreshWebdavTokens, createWebdavToken, deleteWebdavToken, + regenerateWebdavToken, webdavTokenSecret, dismissCreatedWebdavToken, passkeys, @@ -5098,9 +5142,11 @@ const AppLayout = () => { webdavTokensLoading, creatingWebdavToken, deletingWebdavTokenId, + regeneratingWebdavTokenId, refreshWebdavTokens, createWebdavToken, deleteWebdavToken, + regenerateWebdavToken, webdavTokenSecret, dismissCreatedWebdavToken, passkeys, diff --git a/frontend/src/app/SettingsRoute.jsx b/frontend/src/app/SettingsRoute.jsx index dec6563..348a842 100644 --- a/frontend/src/app/SettingsRoute.jsx +++ b/frontend/src/app/SettingsRoute.jsx @@ -10,9 +10,11 @@ const SettingsRoute = () => { webdavTokensLoading, creatingWebdavToken, deletingWebdavTokenId, + regeneratingWebdavTokenId, refreshWebdavTokens, createWebdavToken, deleteWebdavToken, + regenerateWebdavToken, webdavTokenSecret, dismissCreatedWebdavToken, passkeys, @@ -58,9 +60,11 @@ const SettingsRoute = () => { loading={webdavTokensLoading} creating={creatingWebdavToken} deletingId={deletingWebdavTokenId} + regeneratingId={regeneratingWebdavTokenId} onRefresh={refreshWebdavTokens} onCreate={createWebdavToken} onDelete={deleteWebdavToken} + onRegenerate={regenerateWebdavToken} createdToken={webdavTokenSecret} onDismissCreatedToken={dismissCreatedWebdavToken} passkeys={passkeys} diff --git a/frontend/src/settings/SettingsModal.jsx b/frontend/src/settings/SettingsModal.jsx index dc7dfe4..6ff898b 100644 --- a/frontend/src/settings/SettingsModal.jsx +++ b/frontend/src/settings/SettingsModal.jsx @@ -19,9 +19,11 @@ const SettingsModal = ({ loading = false, creating = false, deletingId = null, + regeneratingId = null, onRefresh, onCreate, onDelete, + onRegenerate, createdToken = null, onDismissCreatedToken, passkeys = [], @@ -147,6 +149,16 @@ const SettingsModal = ({ [newTokenExpires, newTokenLabel, onCreate], ); + const handleRegenerateToken = useCallback( + async (token) => { + if (!token?.id) { + return; + } + await onRegenerate?.(token.id); + }, + [onRegenerate], + ); + const renderWebdavSection = useMemo(() => { const hasTokens = Array.isArray(tokens) && tokens.length > 0; @@ -240,14 +252,28 @@ const SettingsModal = ({ {isRevoked ? ( Revoked ) : ( - + <> + + + )} @@ -264,12 +290,14 @@ const SettingsModal = ({ createdToken, creating, deletingId, + regeneratingId, newTokenLabel, newTokenExpires, formError, formatDateTime, handleCopyToken, handleCreateToken, + handleRegenerateToken, handleRefresh, onDelete, handleDismissSecret,