backbone_bucket/presentation/http/
file_share_password.rs1use axum::extract::{Path, State};
14use axum::http::StatusCode;
15use axum::response::{IntoResponse, Response};
16use axum::routing::post;
17use axum::{Json, Router};
18use backbone_orm::{company_scope::fetch_optional_scalar_scoped, org_scope};
19use serde::Deserialize;
20use serde_json::json;
21use sqlx::PgPool;
22use uuid::Uuid;
23
24pub const MIN_PASSWORD_LEN: usize = 8;
26
27#[derive(Debug)]
29pub enum FileSharePasswordError {
30 NotFound(Uuid),
32 TooShort,
34 Internal(String),
36}
37
38impl FileSharePasswordError {
39 fn response(&self) -> Response {
40 let (status, code, message) = match self {
41 Self::NotFound(id) => (StatusCode::NOT_FOUND, "file_share_not_found", format!("no file share {id}")),
42 Self::TooShort => (
43 StatusCode::UNPROCESSABLE_ENTITY,
44 "file_share_password_too_short",
45 format!("a share password has at least {MIN_PASSWORD_LEN} characters"),
46 ),
47 Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "the password could not be stored".to_string()),
48 };
49 (status, Json(json!({ "success": false, "error": code, "message": message }))).into_response()
50 }
51}
52
53fn internal(e: impl std::fmt::Display) -> FileSharePasswordError {
54 FileSharePasswordError::Internal(e.to_string())
55}
56
57#[derive(Clone)]
59pub struct FileSharePassword {
60 pool: PgPool,
61}
62
63impl FileSharePassword {
64 pub fn new(pool: PgPool) -> Self {
65 Self { pool }
66 }
67
68 pub async fn set(&self, share: Uuid, password: &str) -> Result<(), FileSharePasswordError> {
70 if password.chars().count() < MIN_PASSWORD_LEN {
71 return Err(FileSharePasswordError::TooShort);
72 }
73 let hash = hash(password)?;
74 self.write(share, Some(hash)).await
75 }
76
77 pub async fn clear(&self, share: Uuid) -> Result<(), FileSharePasswordError> {
79 self.write(share, None).await
80 }
81
82 pub async fn verify(&self, share: Uuid, password: &str) -> Result<bool, FileSharePasswordError> {
85 let stored: Option<Option<String>> = fetch_optional_scalar_scoped(
86 &self.pool,
87 sqlx::query_scalar(
88 "SELECT password_hash FROM bucket.file_shares WHERE id = $1 AND (metadata->>'deleted_at') IS NULL",
89 )
90 .bind(share),
91 )
92 .await
93 .map_err(internal)?;
94 match stored {
95 None => Err(FileSharePasswordError::NotFound(share)),
96 Some(None) => Ok(true),
97 Some(Some(hash)) => {
98 use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier};
99 let parsed = PasswordHash::new(&hash).map_err(internal)?;
100 Ok(Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok())
101 }
102 }
103 }
104
105 async fn write(&self, share: Uuid, hash: Option<String>) -> Result<(), FileSharePasswordError> {
106 let res = org_scope::execute_scoped(
107 &self.pool,
108 sqlx::query(
109 "UPDATE bucket.file_shares SET password_hash = $2 WHERE id = $1 AND (metadata->>'deleted_at') IS NULL",
110 )
111 .bind(share)
112 .bind(hash),
113 )
114 .await
115 .map_err(internal)?;
116 if res.rows_affected() == 0 {
117 return Err(FileSharePasswordError::NotFound(share));
118 }
119 Ok(())
120 }
121}
122
123fn hash(password: &str) -> Result<String, FileSharePasswordError> {
124 use argon2::password_hash::{rand_core::OsRng, SaltString};
125 use argon2::{Argon2, PasswordHasher};
126 let salt = SaltString::generate(&mut OsRng);
127 Ok(Argon2::default().hash_password(password.as_bytes(), &salt).map_err(internal)?.to_string())
128}
129
130#[derive(Deserialize)]
131struct SetPasswordBody {
132 password: String,
133}
134
135async fn set_password(
136 State(svc): State<FileSharePassword>,
137 Path(id): Path<Uuid>,
138 Json(body): Json<SetPasswordBody>,
139) -> Response {
140 match svc.set(id, &body.password).await {
141 Ok(()) => StatusCode::NO_CONTENT.into_response(),
142 Err(e) => e.response(),
143 }
144}
145
146async fn clear_password(State(svc): State<FileSharePassword>, Path(id): Path<Uuid>) -> Response {
147 match svc.clear(id).await {
148 Ok(()) => StatusCode::NO_CONTENT.into_response(),
149 Err(e) => e.response(),
150 }
151}
152
153pub fn file_share_password_routes(pool: PgPool) -> Router {
156 Router::new()
157 .route("/file_shares/:id/password", post(set_password).delete(clear_password))
158 .with_state(FileSharePassword::new(pool))
159}