Skip to main content

backbone_bucket/presentation/http/
file_share_password.rs

1//! A file share's password: set, cleared and checked through this verb only.
2//!
3//! Hand-written (user-owned; see `metaphor.codegen.yaml`). `password_hash` is `@sensitive` and
4//! `@immutable`, so no generic route serves it or lets a client write it: a client used to set it
5//! to whatever it sent, hashed or not. Here the plaintext comes in, an argon2 hash goes to the
6//! row, and [`FileSharePassword::verify`] is what a share route checks a visitor's password with.
7//!
8//!   POST   /file_shares/:id/password   body { password }   → 204, 404, 422 (too short)
9//!   DELETE /file_shares/:id/password                       → 204, 404
10//!
11//! Statements ride the request-dedicated connection when the composing service bound one.
12
13use 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
24/// The shortest share password accepted.
25pub const MIN_PASSWORD_LEN: usize = 8;
26
27/// Why a share password could not be set or checked.
28#[derive(Debug)]
29pub enum FileSharePasswordError {
30    /// No live share has this id.
31    NotFound(Uuid),
32    /// The password is shorter than [`MIN_PASSWORD_LEN`].
33    TooShort,
34    /// The database or the hasher failed.
35    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/// Sets, clears and checks share passwords.
58#[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    /// Store the argon2 hash of `password` on the share.
69    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    /// Remove the share's password.
78    pub async fn clear(&self, share: Uuid) -> Result<(), FileSharePasswordError> {
79        self.write(share, None).await
80    }
81
82    /// Whether `password` opens the share: true when it matches the stored hash, or when the
83    /// share has no password.
84    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
153/// The share-password verb routes; the host mounts them beside the bucket collections, behind
154/// the same guard.
155pub 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}