webdav token regeneration

This commit is contained in:
2025-10-31 20:37:07 +01:00
parent e9731ec691
commit 18352caabe
14 changed files with 304 additions and 69 deletions
+45
View File
@@ -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<Uuid>,
) -> Result<IssuedWebdavToken, AppError> {
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::<WebdavToken>(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::<Option<NaiveDateTime>>(None),
))
.get_result::<WebdavToken>(conn)?;
Ok(IssuedWebdavToken {
token: raw_secret,
record: updated,
})
}
pub fn find_active_token_by_secret(
conn: &mut PgPooledConnection,
user_id: Uuid,
+8 -2
View File
@@ -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 {
+4
View File
@@ -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));
+27 -1
View File
@@ -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<Uuid>,
) -> AppResult<Json<WebdavTokenCreatedResponse>> {
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<NaiveDateTime> {
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
+1 -2
View File
@@ -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};
+1 -1
View File
@@ -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(|_| ())
+19 -17
View File
@@ -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");
+21 -17
View File
@@ -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:?}"))?;
+23 -17
View File
@@ -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
{
+1 -2
View File
@@ -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\
+68 -2
View File
@@ -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(&regenerate_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(&regenerate_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::<WebdavToken>(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?;
+46
View File
@@ -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,
+4
View File
@@ -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}
+36 -8
View File
@@ -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 ? (
<span className="settings-status">Revoked</span>
) : (
<button
type="button"
className="danger"
onClick={() => onDelete?.(token.id)}
disabled={deletingId === token.id}
>
{deletingId === token.id ? 'Revoking…' : 'Revoke'}
</button>
<>
<button
type="button"
className="secondary"
onClick={() => handleRegenerateToken(token)}
disabled={
regeneratingId === token.id || deletingId === token.id
}
>
{regeneratingId === token.id ? 'Regenerating…' : 'Regenerate'}
</button>
<button
type="button"
className="danger"
onClick={() => onDelete?.(token.id)}
disabled={
deletingId === token.id || regeneratingId === token.id
}
>
{deletingId === token.id ? 'Revoking…' : 'Revoke'}
</button>
</>
)}
</td>
</tr>
@@ -264,12 +290,14 @@ const SettingsModal = ({
createdToken,
creating,
deletingId,
regeneratingId,
newTokenLabel,
newTokenExpires,
formError,
formatDateTime,
handleCopyToken,
handleCreateToken,
handleRegenerateToken,
handleRefresh,
onDelete,
handleDismissSecret,