use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::{Json, Router};
use backbone_orm::{company_scope::fetch_optional_scalar_scoped, org_scope};
use serde::Deserialize;
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
pub const MIN_PASSWORD_LEN: usize = 8;
#[derive(Debug)]
pub enum FileSharePasswordError {
NotFound(Uuid),
TooShort,
Internal(String),
}
impl FileSharePasswordError {
fn response(&self) -> Response {
let (status, code, message) = match self {
Self::NotFound(id) => (StatusCode::NOT_FOUND, "file_share_not_found", format!("no file share {id}")),
Self::TooShort => (
StatusCode::UNPROCESSABLE_ENTITY,
"file_share_password_too_short",
format!("a share password has at least {MIN_PASSWORD_LEN} characters"),
),
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "the password could not be stored".to_string()),
};
(status, Json(json!({ "success": false, "error": code, "message": message }))).into_response()
}
}
fn internal(e: impl std::fmt::Display) -> FileSharePasswordError {
FileSharePasswordError::Internal(e.to_string())
}
#[derive(Clone)]
pub struct FileSharePassword {
pool: PgPool,
}
impl FileSharePassword {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub async fn set(&self, share: Uuid, password: &str) -> Result<(), FileSharePasswordError> {
if password.chars().count() < MIN_PASSWORD_LEN {
return Err(FileSharePasswordError::TooShort);
}
let hash = hash(password)?;
self.write(share, Some(hash)).await
}
pub async fn clear(&self, share: Uuid) -> Result<(), FileSharePasswordError> {
self.write(share, None).await
}
pub async fn verify(&self, share: Uuid, password: &str) -> Result<bool, FileSharePasswordError> {
let stored: Option<Option<String>> = fetch_optional_scalar_scoped(
&self.pool,
sqlx::query_scalar(
"SELECT password_hash FROM bucket.file_shares WHERE id = $1 AND (metadata->>'deleted_at') IS NULL",
)
.bind(share),
)
.await
.map_err(internal)?;
match stored {
None => Err(FileSharePasswordError::NotFound(share)),
Some(None) => Ok(true),
Some(Some(hash)) => {
use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier};
let parsed = PasswordHash::new(&hash).map_err(internal)?;
Ok(Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok())
}
}
}
async fn write(&self, share: Uuid, hash: Option<String>) -> Result<(), FileSharePasswordError> {
let res = org_scope::execute_scoped(
&self.pool,
sqlx::query(
"UPDATE bucket.file_shares SET password_hash = $2 WHERE id = $1 AND (metadata->>'deleted_at') IS NULL",
)
.bind(share)
.bind(hash),
)
.await
.map_err(internal)?;
if res.rows_affected() == 0 {
return Err(FileSharePasswordError::NotFound(share));
}
Ok(())
}
}
fn hash(password: &str) -> Result<String, FileSharePasswordError> {
use argon2::password_hash::{rand_core::OsRng, SaltString};
use argon2::{Argon2, PasswordHasher};
let salt = SaltString::generate(&mut OsRng);
Ok(Argon2::default().hash_password(password.as_bytes(), &salt).map_err(internal)?.to_string())
}
#[derive(Deserialize)]
struct SetPasswordBody {
password: String,
}
async fn set_password(
State(svc): State<FileSharePassword>,
Path(id): Path<Uuid>,
Json(body): Json<SetPasswordBody>,
) -> Response {
match svc.set(id, &body.password).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => e.response(),
}
}
async fn clear_password(State(svc): State<FileSharePassword>, Path(id): Path<Uuid>) -> Response {
match svc.clear(id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => e.response(),
}
}
pub fn file_share_password_routes(pool: PgPool) -> Router {
Router::new()
.route("/file_shares/:id/password", post(set_password).delete(clear_password))
.with_state(FileSharePassword::new(pool))
}