backend: webdav tokens

This commit is contained in:
2025-10-29 00:12:58 +01:00
parent b4282c2b4b
commit d9b2297eca
12 changed files with 707 additions and 119 deletions
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS webdav_tokens_user_tenant_idx;
DROP INDEX IF EXISTS webdav_tokens_token_prefix_key;
DROP TABLE IF EXISTS webdav_tokens;
@@ -0,0 +1,16 @@
CREATE TABLE webdav_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
token_prefix TEXT NOT NULL,
token_hash TEXT NOT NULL,
label TEXT,
scopes JSONB NOT NULL DEFAULT '["webdav"]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX webdav_tokens_token_prefix_key ON webdav_tokens(token_prefix);
CREATE INDEX webdav_tokens_user_tenant_idx ON webdav_tokens(user_id, tenant_id);
+1
View File
@@ -1,5 +1,6 @@
pub mod jwt;
pub mod password;
pub mod webdav_tokens;
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
use axum_extra::headers::{authorization::Bearer, Authorization};
+231
View File
@@ -0,0 +1,231 @@
use argon2::{
password_hash::{PasswordHasher, SaltString},
Argon2,
};
use chrono::{NaiveDateTime, Utc};
use diesel::prelude::*;
use rand::rngs::OsRng;
use rand::RngCore;
use serde_json::json;
use uuid::Uuid;
use crate::{
error::AppError,
models::{NewWebdavToken, WebdavToken},
schema::webdav_tokens,
state::PgPooledConnection,
};
const WEB_DAV_SCOPE: &str = "webdav";
const TOKEN_PREFIX_LENGTH: usize = 12;
const TOKEN_SECRET_LENGTH: usize = 32;
pub struct IssuedWebdavToken {
pub token: String,
pub record: WebdavToken,
}
pub fn create_webdav_token(
conn: &mut PgPooledConnection,
user_id: Uuid,
tenant_id: Uuid,
label: Option<String>,
scopes: Option<Vec<String>>,
expires_at: Option<NaiveDateTime>,
) -> Result<IssuedWebdavToken, AppError> {
let raw_secret = generate_secret()?;
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
let token_hash = hash_secret(&raw_secret)?;
let scopes_value = scopes
.map(|scopes| json!(scopes))
.unwrap_or_else(|| json!([WEB_DAV_SCOPE]));
let new_token = NewWebdavToken {
id: Uuid::new_v4(),
user_id,
tenant_id,
token_prefix,
token_hash,
label,
scopes: scopes_value,
expires_at,
};
let record = diesel::insert_into(webdav_tokens::table)
.values(&new_token)
.get_result::<WebdavToken>(conn)?;
Ok(IssuedWebdavToken {
token: raw_secret,
record,
})
}
pub fn list_webdav_tokens(
conn: &mut PgPooledConnection,
user_id: Uuid,
tenant_id: Option<Uuid>,
) -> Result<Vec<WebdavToken>, AppError> {
let mut query = webdav_tokens::table
.filter(webdav_tokens::user_id.eq(user_id))
.into_boxed();
if let Some(tenant_id) = tenant_id {
query = query.filter(webdav_tokens::tenant_id.eq(tenant_id));
}
let tokens = query
.order(webdav_tokens::created_at.asc())
.load::<WebdavToken>(conn)?;
Ok(tokens)
}
pub fn find_active_token_by_secret(
conn: &mut PgPooledConnection,
user_id: Uuid,
tenant_id: Option<Uuid>,
secret: &str,
) -> Result<Option<WebdavToken>, AppError> {
if secret.len() < TOKEN_PREFIX_LENGTH {
return Ok(None);
}
let prefix = &secret[..TOKEN_PREFIX_LENGTH];
let mut query = webdav_tokens::table
.filter(webdav_tokens::user_id.eq(user_id))
.filter(webdav_tokens::token_prefix.eq(prefix))
.filter(webdav_tokens::revoked_at.is_null())
.into_boxed();
let now = Utc::now().naive_utc();
query = query.filter(
webdav_tokens::expires_at
.is_null()
.or(webdav_tokens::expires_at.gt(now)),
);
if let Some(tenant_id) = tenant_id {
query = query.filter(webdav_tokens::tenant_id.eq(tenant_id));
}
let candidates = query.load::<WebdavToken>(conn)?;
for token in candidates {
if verify_token_secret(secret, &token.token_hash)? && token_allows_webdav(&token.scopes) {
return Ok(Some(token));
}
}
Ok(None)
}
pub fn revoke_webdav_token(
conn: &mut PgPooledConnection,
token_id: Uuid,
user_id: Uuid,
) -> Result<(), AppError> {
let affected = diesel::update(
webdav_tokens::table
.filter(webdav_tokens::id.eq(token_id))
.filter(webdav_tokens::user_id.eq(user_id)),
)
.set(webdav_tokens::revoked_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
if affected == 0 {
return Err(AppError::not_found());
}
Ok(())
}
pub fn touch_webdav_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> {
diesel::update(webdav_tokens::table.filter(webdav_tokens::id.eq(token_id)))
.set(webdav_tokens::last_used_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
Ok(())
}
pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result<bool, AppError> {
crate::auth::password::verify_password(secret, token_hash)
.map_err(|err| AppError::internal(format!("failed to verify token: {err}")))
}
fn generate_secret() -> Result<String, AppError> {
let mut buffer = [0u8; TOKEN_SECRET_LENGTH];
OsRng
.try_fill_bytes(&mut buffer)
.map_err(|err| AppError::internal(format!("failed to generate token: {err}")))?;
Ok(hex::encode(buffer))
}
fn hash_secret(secret: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(secret.as_bytes(), &salt)
.map_err(|err| AppError::internal(format!("failed to hash token: {err}")))?;
Ok(hash.to_string())
}
pub fn parse_scopes(scopes: &serde_json::Value) -> Vec<String> {
match scopes {
serde_json::Value::Array(values) => values
.iter()
.filter_map(|value| value.as_str().map(|s| s.to_string()))
.collect(),
serde_json::Value::String(value) => value
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect(),
_ => vec![],
}
}
pub fn token_allows_webdav(scopes: &serde_json::Value) -> bool {
parse_scopes(scopes)
.into_iter()
.any(|scope| scope == WEB_DAV_SCOPE)
}
fn _ensure_constants() {
assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_secret_has_expected_length() {
let secret = generate_secret().unwrap();
assert_eq!(secret.len(), TOKEN_SECRET_LENGTH * 2);
}
#[test]
fn hash_and_verify_secret_round_trip() {
let secret = generate_secret().unwrap();
let hash = hash_secret(&secret).unwrap();
assert!(verify_token_secret(&secret, &hash).unwrap());
assert!(!verify_token_secret("wrong", &hash).unwrap());
}
#[test]
fn parse_scopes_handles_strings_and_arrays() {
let from_array = parse_scopes(&json!(["webdav", "other"]));
assert_eq!(from_array, vec!["webdav", "other"]);
let from_string = parse_scopes(&json!("webdav, other"));
assert_eq!(from_string, vec!["webdav", "other"]);
assert!(parse_scopes(&serde_json::Value::Null).is_empty());
}
#[test]
fn token_allows_webdav_matches_scope() {
assert!(token_allows_webdav(&json!(["webdav"])));
assert!(!token_allows_webdav(&json!(["api"])));
}
}
+31
View File
@@ -58,6 +58,37 @@ pub struct NewUser {
pub password_hash: String,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = webdav_tokens)]
#[diesel(belongs_to(User))]
#[diesel(belongs_to(Tenant))]
pub struct WebdavToken {
pub id: Uuid,
pub user_id: Uuid,
pub tenant_id: Uuid,
pub token_prefix: String,
pub token_hash: String,
pub label: Option<String>,
pub scopes: serde_json::Value,
pub created_at: NaiveDateTime,
pub last_used_at: Option<NaiveDateTime>,
pub expires_at: Option<NaiveDateTime>,
pub revoked_at: Option<NaiveDateTime>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = webdav_tokens)]
pub struct NewWebdavToken {
pub id: Uuid,
pub user_id: Uuid,
pub tenant_id: Uuid,
pub token_prefix: String,
pub token_hash: String,
pub label: Option<String>,
pub scopes: serde_json::Value,
pub expires_at: Option<NaiveDateTime>,
}
#[derive(Debug, Clone, Queryable, Identifiable)]
#[diesel(table_name = folders)]
pub struct Folder {
+79 -1
View File
@@ -11,6 +11,7 @@ use uuid::Uuid;
doc::refresh,
doc::logout,
doc::me,
doc::list_tenants,
doc::select_tenant,
doc::list_documents,
doc::check_document,
@@ -45,6 +46,9 @@ use uuid::Uuid;
doc::create_correspondent,
doc::update_correspondent,
doc::delete_correspondent,
doc::list_webdav_tokens,
doc::create_webdav_token,
doc::delete_webdav_token,
),
components(
schemas(
@@ -53,6 +57,7 @@ use uuid::Uuid;
schemas::TenantSummary,
schemas::TenantSelectionResponse,
schemas::TenantSelectionRequest,
schemas::TenantListResponse,
schemas::LoginResponseVariants,
schemas::DocumentResponse,
schemas::DocumentDetailResponse,
@@ -98,6 +103,9 @@ use uuid::Uuid;
schemas::CorrespondentCatalogEntry,
schemas::CreateCorrespondentRequest,
schemas::UpdateCorrespondentRequest,
schemas::WebdavTokenResponse,
schemas::WebdavTokenCreatedResponse,
schemas::CreateWebdavTokenRequest,
)
),
tags(
@@ -107,7 +115,8 @@ use uuid::Uuid;
(name = "Assets", description = "Document assets"),
(name = "Folders", description = "Folder management"),
(name = "Tags", description = "Tag catalog"),
(name = "Correspondents", description = "Correspondent catalog")
(name = "Correspondents", description = "Correspondent catalog"),
(name = "Profile", description = "User profile and WebDAV tokens")
)
)]
pub struct ApiDoc;
@@ -169,6 +178,14 @@ mod doc {
)]
pub(super) fn me() {}
#[utoipa::path(
get,
path = "/api/auth/tenants",
responses((status = 200, description = "Available tenants", body = TenantListResponse)),
tag = "Auth"
)]
pub(super) fn list_tenants() {}
#[utoipa::path(
post,
path = "/api/auth/select-tenant",
@@ -529,6 +546,32 @@ mod doc {
tag = "Correspondents"
)]
pub(super) fn delete_correspondent() {}
#[utoipa::path(
get,
path = "/api/profile/webdav-tokens",
responses((status = 200, description = "List WebDAV tokens", body = [WebdavTokenResponse])),
tag = "Profile"
)]
pub(super) fn list_webdav_tokens() {}
#[utoipa::path(
post,
path = "/api/profile/webdav-tokens",
request_body = CreateWebdavTokenRequest,
responses((status = 201, description = "WebDAV token created", body = WebdavTokenCreatedResponse)),
tag = "Profile"
)]
pub(super) fn create_webdav_token() {}
#[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(super) fn delete_webdav_token() {}
}
#[cfg(test)]
@@ -585,6 +628,11 @@ pub mod schemas {
pub tenant_id: Uuid,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TenantListResponse {
pub tenants: Vec<TenantSnippet>,
}
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(untagged)]
pub enum LoginResponseVariants {
@@ -1001,4 +1049,34 @@ pub mod schemas {
#[schema(nullable)]
pub metadata: Option<Value>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct WebdavTokenResponse {
pub id: Uuid,
pub tenant_id: Uuid,
#[schema(nullable)]
pub label: Option<String>,
pub scopes: Vec<String>,
pub created_at: String,
#[schema(nullable)]
pub last_used_at: Option<String>,
#[schema(nullable)]
pub expires_at: Option<String>,
#[schema(nullable)]
pub revoked_at: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct WebdavTokenCreatedResponse {
pub token: String,
pub token_info: WebdavTokenResponse,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CreateWebdavTokenRequest {
#[schema(nullable)]
pub label: Option<String>,
#[schema(nullable, example = "2025-01-01T00:00:00Z")]
pub expires_at: Option<String>,
}
}
+9
View File
@@ -20,6 +20,7 @@ pub mod correspondents;
pub mod documents;
pub mod folders;
pub mod health;
pub mod profile;
pub mod tags;
pub mod webdav;
@@ -130,6 +131,13 @@ pub fn create_router(state: AppState) -> Router<()> {
.delete(correspondents::delete_correspondent),
);
let profile_routes = Router::new()
.route(
"/webdav-tokens",
get(profile::list_webdav_tokens).post(profile::create_webdav_token),
)
.route("/webdav-tokens/:id", delete(profile::delete_webdav_token));
let protected_state = state.clone();
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
@@ -138,6 +146,7 @@ pub fn create_router(state: AppState) -> Router<()> {
.nest("/api/folders", folders_routes)
.nest("/api/tags", tags_routes)
.nest("/api/correspondents", correspondents_routes)
.nest("/api/profile", profile_routes)
.nest("/api/assets", assets_routes)
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
+112
View File
@@ -0,0 +1,112 @@
use axum::{extract::Path, http::StatusCode, Json};
use chrono::{DateTime, NaiveDateTime};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::{
webdav_tokens::{
create_webdav_token as issue_token, list_webdav_tokens as load_tokens, parse_scopes,
revoke_webdav_token as revoke_token,
},
TenantScopedConn,
};
use crate::error::{AppError, AppResult};
use crate::models::WebdavToken;
use crate::utils::{db::no_content, time::to_iso};
#[derive(Debug, Serialize)]
pub struct WebdavTokenResponse {
pub id: Uuid,
pub tenant_id: Uuid,
pub label: Option<String>,
pub scopes: Vec<String>,
pub created_at: String,
pub last_used_at: Option<String>,
pub expires_at: Option<String>,
pub revoked_at: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct WebdavTokenCreatedResponse {
pub token: String,
pub token_info: WebdavTokenResponse,
}
#[derive(Debug, Deserialize)]
pub struct CreateWebdavTokenRequest {
pub label: Option<String>,
pub expires_at: Option<String>,
}
pub async fn list_webdav_tokens(
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<WebdavTokenResponse>>> {
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))
}
pub async fn create_webdav_token(
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateWebdavTokenRequest>,
) -> AppResult<(StatusCode, Json<WebdavTokenCreatedResponse>)> {
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(),
None,
expires_at,
)?;
let response = WebdavTokenCreatedResponse {
token: issued.token,
token_info: webdav_token_to_response(issued.record),
};
Ok((StatusCode::CREATED, Json(response)))
}
pub async fn delete_webdav_token(
TenantScopedConn {
mut conn, user_id, ..
}: TenantScopedConn,
Path(token_id): Path<Uuid>,
) -> AppResult<StatusCode> {
revoke_token(&mut conn, token_id, user_id)?;
no_content()
}
fn webdav_token_to_response(token: WebdavToken) -> WebdavTokenResponse {
WebdavTokenResponse {
id: token.id,
tenant_id: token.tenant_id,
label: token.label,
scopes: parse_scopes(&token.scopes),
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<NaiveDateTime> {
let dt = DateTime::parse_from_rfc3339(value)
.map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?;
Ok(dt.naive_utc())
}
+52 -118
View File
@@ -8,6 +8,7 @@ use axum::Router;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use diesel::prelude::*;
use diesel::OptionalExtension;
use diesel::PgConnection;
use futures_util::StreamExt;
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
@@ -15,7 +16,7 @@ use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
use quick_xml::Writer;
use uuid::Uuid;
use crate::auth::password;
use crate::auth::webdav_tokens::{find_active_token_by_secret, touch_webdav_token};
use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentVersion, Folder, User};
use crate::schema::{
@@ -29,17 +30,11 @@ use crate::utils::{http::inline_content_disposition, time::to_http_date};
const REALM: &str = "Papercrate WebDAV";
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
#[derive(Clone, Debug)]
struct TenantEntry {
tenant_id: Uuid,
slug: String,
}
#[derive(Clone, Debug)]
struct WebDavContext {
tenant_id: Uuid,
_user_id: Uuid,
_username: String,
tenants: Vec<TenantEntry>,
}
pub fn create_router() -> Router<AppState> {
@@ -89,35 +84,20 @@ async fn handle_propfind(
let segments = parse_segments(path)?;
let resources = if segments.is_empty() {
build_account_root_resources(&context.tenants, depth)
} else {
let (requested_slug, remainder) = segments.split_first().unwrap();
let tenant_entry = match context
.tenants
.iter()
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
{
Some(entry) => TenantEntry {
tenant_id: entry.tenant_id,
slug: entry.slug.clone(),
},
None => return Ok(not_found_response()),
};
let tenant_id = context.tenant_id;
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
let resources = if segments.is_empty() {
let contents = fetch_folder_contents(state, tenant_id, None)?;
build_resources_for_folder(None, &[], &contents, depth)
} else {
let resolution = match resolve_path(state, tenant_id, &segments)? {
Some(resolved) => resolved,
None => return Ok(not_found_response()),
};
match resolution {
ResolvedPath::TenantRoot { chain } => {
let contents = fetch_folder_contents(state, tenant_entry.tenant_id, None)?;
build_resources_for_folder(None, &chain, &contents, depth)
}
ResolvedPath::Folder { folder, chain } => {
let contents =
fetch_folder_contents(state, tenant_entry.tenant_id, Some(folder.id))?;
let contents = fetch_folder_contents(state, tenant_id, Some(folder.id))?;
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
}
ResolvedPath::Document {
@@ -151,29 +131,13 @@ async fn handle_get_or_head(
None => return Ok(unauthorized_response()),
};
let tenant_id = context.tenant_id;
let segments = parse_segments(path)?;
let (requested_slug, remainder) = match segments.split_first() {
Some(values) => values,
None => return Ok(method_not_allowed()),
};
let tenant_entry = match context
.tenants
.iter()
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
{
Some(entry) => TenantEntry {
tenant_id: entry.tenant_id,
slug: entry.slug.clone(),
},
None => return Ok(not_found_response()),
};
if remainder.is_empty() {
if segments.is_empty() {
return Ok(method_not_allowed());
}
let resolution = match resolve_path(state, &tenant_entry, remainder)? {
let resolution = match resolve_path(state, tenant_id, &segments)? {
Some(resolved) => resolved,
None => return Ok(not_found_response()),
};
@@ -458,8 +422,8 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
}
};
let (username, password) = match credential_str.split_once(':') {
Some((username, password)) if !username.is_empty() => (username, password),
let (username, secret) = match credential_str.split_once(':') {
Some((username, secret)) if !username.is_empty() => (username, secret),
_ => return Ok(None),
};
@@ -478,35 +442,46 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
Err(err) => return Err(AppError::from(err)),
};
let valid = password::verify_password(password, &user.password_hash)
.map_err(|_| AppError::internal("failed to verify password"))?;
let token = match find_active_token_by_secret(&mut conn, user.id, None, secret)? {
Some(token) => token,
None => {
tracing::warn!(%username, "webdav token invalid or expired");
return Ok(None);
}
};
if !valid {
tracing::warn!(%username, "webdav password invalid");
return Ok(None);
}
let tenant_rows: Vec<(Uuid, String)> = memberships_dsl::user_memberships
let tenant_row = memberships_dsl::user_memberships
.inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user.id))
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
.select((tenant_dsl::id, tenant_dsl::slug))
.load(&mut conn)?;
.first::<(Uuid, String)>(&mut conn)
.optional()?;
if tenant_rows.is_empty() {
tracing::warn!(%username, "webdav user has no tenant memberships");
return Ok(None);
}
let (tenant_id, _slug) = match tenant_row {
Some(row) => row,
None => {
tracing::warn!(
%username,
tenant_id = %token.tenant_id,
"webdav token tenant membership missing"
);
return Ok(None);
}
};
let tenants: Vec<TenantEntry> = tenant_rows
.into_iter()
.map(|(tenant_id, slug)| TenantEntry { tenant_id, slug })
.collect();
touch_webdav_token(&mut conn, token.id)?;
tracing::debug!(%username, tenant_count = tenants.len(), "webdav login success");
tracing::debug!(
%username,
tenant_id = %tenant_id,
token_id = %token.id,
"webdav token login success"
);
Ok(Some(WebDavContext {
tenant_id,
_user_id: user.id,
_username: user.username,
tenants,
}))
}
@@ -564,37 +539,6 @@ fn build_resources_for_folder(
resources
}
fn build_account_root_resources(tenants: &[TenantEntry], depth: u8) -> Vec<DavResource> {
let mut resources = Vec::new();
resources.push(DavResource {
href: "/".to_string(),
display_name: "/".to_string(),
is_collection: true,
content_length: None,
content_type: None,
last_modified: None,
});
if depth == 0 {
return resources;
}
for tenant in tenants {
let href = build_href(&[tenant.slug.clone()], true);
resources.push(DavResource {
href,
display_name: tenant.slug.clone(),
is_collection: true,
content_length: None,
content_type: None,
last_modified: None,
});
}
resources
}
fn build_resources_for_document(
chain: &[String],
document: &Document,
@@ -717,9 +661,6 @@ struct DavResource {
last_modified: Option<String>,
}
enum ResolvedPath {
TenantRoot {
chain: Vec<String>,
},
Folder {
folder: Folder,
chain: Vec<String>,
@@ -733,23 +674,18 @@ enum ResolvedPath {
fn resolve_path(
state: &AppState,
tenant: &TenantEntry,
tenant_id: Uuid,
segments: &[String],
) -> AppResult<Option<ResolvedPath>> {
let mut conn = state.db_for_tenant(tenant.tenant_id)?;
let mut conn = state.db_for_tenant(tenant_id)?;
let mut parent_id: Option<Uuid> = None;
let mut chain: Vec<String> = vec![tenant.slug.clone()];
let mut chain: Vec<String> = Vec::new();
let mut current_folder: Option<Folder> = None;
if segments.is_empty() {
return Ok(Some(ResolvedPath::TenantRoot { chain }));
}
for (index, segment) in segments.iter().enumerate() {
let is_last = index == segments.len() - 1;
if let Some(folder) = find_folder_by_name(&mut conn, tenant.tenant_id, parent_id, segment)?
{
if let Some(folder) = find_folder_by_name(&mut conn, tenant_id, parent_id, segment)? {
chain.push(folder.name.clone());
if is_last {
return Ok(Some(ResolvedPath::Folder { folder, chain }));
@@ -761,7 +697,7 @@ fn resolve_path(
if is_last {
if let Some((document, version)) =
find_document_by_filename(&mut conn, tenant.tenant_id, parent_id, segment)?
find_document_by_filename(&mut conn, tenant_id, parent_id, segment)?
{
chain.push(document.filename.clone());
return Ok(Some(ResolvedPath::Document {
@@ -773,7 +709,7 @@ fn resolve_path(
}
if let Ok(uuid) = Uuid::parse_str(segment) {
if let Some(folder) = find_folder_by_id(&mut conn, tenant.tenant_id, uuid)? {
if let Some(folder) = find_folder_by_id(&mut conn, tenant_id, uuid)? {
if folder.parent_id != parent_id {
return Ok(None);
}
@@ -786,9 +722,7 @@ fn resolve_path(
continue;
}
if let Some((document, version)) =
find_document_by_id(&mut conn, tenant.tenant_id, uuid)?
{
if let Some((document, version)) = find_document_by_id(&mut conn, tenant_id, uuid)? {
if document.folder_id != parent_id {
return Ok(None);
}
+19
View File
@@ -183,6 +183,22 @@ diesel::table! {
}
}
diesel::table! {
webdav_tokens (id) {
id -> Uuid,
user_id -> Uuid,
tenant_id -> Uuid,
token_prefix -> Text,
token_hash -> Text,
label -> Nullable<Text>,
scopes -> Jsonb,
created_at -> Timestamptz,
last_used_at -> Nullable<Timestamptz>,
expires_at -> Nullable<Timestamptz>,
revoked_at -> Nullable<Timestamptz>,
}
}
diesel::joinable!(correspondents -> tenants (tenant_id));
diesel::joinable!(document_asset_objects -> document_assets (asset_id));
diesel::joinable!(document_asset_objects -> tenants (tenant_id));
@@ -206,6 +222,8 @@ diesel::joinable!(refresh_tokens -> users (user_id));
diesel::joinable!(tags -> tenants (tenant_id));
diesel::joinable!(user_memberships -> tenants (tenant_id));
diesel::joinable!(user_memberships -> users (user_id));
diesel::joinable!(webdav_tokens -> tenants (tenant_id));
diesel::joinable!(webdav_tokens -> users (user_id));
diesel::allow_tables_to_appear_in_same_query!(
correspondents,
@@ -222,4 +240,5 @@ diesel::allow_tables_to_appear_in_same_query!(
tenants,
user_memberships,
users,
webdav_tokens,
);
+1
View File
@@ -707,6 +707,7 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
jobs, \
refresh_tokens, \
tags, \
webdav_tokens, \
user_memberships, \
users, \
tenants \
+153
View File
@@ -0,0 +1,153 @@
mod common;
use anyhow::Result;
use axum::body::Body;
use axum::http::{header, Method, Request, StatusCode};
use backend::models::WebdavToken;
use backend::routes::webdav;
use backend::schema::webdav_tokens;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use common::{acquire_db_lock, body_to_vec, TestApp};
use diesel::prelude::*;
use serde::Deserialize;
use serde_json::json;
use tower::ServiceExt;
use uuid::Uuid;
#[derive(Deserialize)]
struct TokenInfo {
id: Uuid,
label: Option<String>,
last_used_at: Option<String>,
revoked_at: Option<String>,
}
#[derive(Deserialize)]
struct CreateTokenResponse {
token: String,
#[serde(rename = "token_info")]
info: TokenInfo,
}
#[tokio::test]
async fn webdav_token_api_crud() -> Result<()> {
let _guard = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "alice";
let password = "correct horse battery";
app.insert_user(username, password, "admin").await?;
let access_token = app.login_token(username, password).await?;
let create_response = app
.post_json(
"/api/profile/webdav-tokens",
&json!({ "label": "dav" }),
Some(&access_token),
)
.await?;
assert_eq!(create_response.status(), StatusCode::CREATED);
let create_body = body_to_vec(create_response.into_body()).await?;
let created: CreateTokenResponse = serde_json::from_slice(&create_body)?;
let token_id = created.info.id;
assert_eq!(created.info.label.as_deref(), Some("dav"));
assert!(created.info.last_used_at.is_none());
let list_response = app
.get("/api/profile/webdav-tokens", Some(&access_token))
.await?;
assert_eq!(list_response.status(), StatusCode::OK);
let list_body = body_to_vec(list_response.into_body()).await?;
let listed: Vec<TokenInfo> = serde_json::from_slice(&list_body)?;
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, token_id);
let delete_response = app
.delete(
&format!("/api/profile/webdav-tokens/{token_id}"),
Some(&access_token),
)
.await?;
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
let list_after = app
.get("/api/profile/webdav-tokens", Some(&access_token))
.await?;
let list_after_body = body_to_vec(list_after.into_body()).await?;
let listed_after: Vec<TokenInfo> = serde_json::from_slice(&list_after_body)?;
assert_eq!(listed_after.len(), 1);
assert_eq!(listed_after[0].id, token_id);
assert!(listed_after[0].revoked_at.is_some());
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn webdav_basic_auth_uses_tokens() -> Result<()> {
let _guard = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "bruce";
let password = "wayne";
app.insert_user(username, password, "admin").await?;
let access_token = app.login_token(username, password).await?;
let create_response = app
.post_json(
"/api/profile/webdav-tokens",
&json!({ "label": "webdav" }),
Some(&access_token),
)
.await?;
let create_body = body_to_vec(create_response.into_body()).await?;
let created: CreateTokenResponse = serde_json::from_slice(&create_body)?;
let token_id = created.info.id;
let router = webdav::create_router().with_state(app.state.clone());
let auth_header = format!(
"Basic {}",
BASE64.encode(format!("{}:{}", username, created.token))
);
let propfind = Method::from_bytes(b"PROPFIND")?;
let success_request = Request::builder()
.method(propfind.clone())
.uri("/")
.header(header::AUTHORIZATION, auth_header.clone())
.header("depth", "0")
.body(Body::empty())?;
let response = router.clone().oneshot(success_request).await?;
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
let used = 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!(used.is_some());
let delete_response = app
.delete(
&format!("/api/profile/webdav-tokens/{token_id}"),
Some(&access_token),
)
.await?;
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
let failure_request = Request::builder()
.method(propfind)
.uri("/")
.header(header::AUTHORIZATION, auth_header)
.header("depth", "0")
.body(Body::empty())?;
let response = router.oneshot(failure_request).await?;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
app.cleanup().await?;
Ok(())
}