/* * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl * Source-Available Code / Dual-Licensed. * * Free for non-commercial and evaluation use under terms of BSL/GPLv3. * Commercial or production use requires a valid paid license. * See LICENSE file in repository root for details. */ use super::*; #[derive(Debug, Deserialize)] pub struct AccessTokenRequest { kind: String, slug: String, password: String, } #[derive(Debug, Serialize)] pub struct AccessTokenResponse { granted: bool, expires_at: String, } pub async fn create_resource_access_token( State(state): State, headers: HeaderMap, Json(payload): Json, ) -> Result { let kind = payload.kind.trim(); let slug = payload.slug.trim(); let client_key = crate::security::client_key(&headers); let client_limit_key = format!("resource-password-client:{client_key}"); let limit_key = format!("resource-password:{client_key}:{kind}:{slug}"); let window = std::time::Duration::from_secs(15 * 60); state .check_rate_limit(client_limit_key, 50, window) .await .map_err(|seconds| ApiError::rate_limited(&format!( "Too many password attempts. Try again in {seconds} seconds." )))?; state .check_rate_limit(limit_key.clone(), 10, window) .await .map_err(|seconds| ApiError::rate_limited(&format!( "Too many password attempts. Try again in {seconds} seconds." )))?; match kind { "workspace" => { let workspace = db::find_workspace(&state.db, slug) .await? .ok_or_else(ApiError::not_found_workspace)?; if !db::verify_workspace_password(&workspace, Some(payload.password.as_str())) { return Err(ApiError::unauthorized()); } } "pad" => { let pad = db::find_pad(&state.db, slug) .await? .ok_or_else(ApiError::not_found_note)?; if !db::verify_pad_password(&pad, Some(payload.password.as_str())) { return Err(ApiError::unauthorized()); } } _ => return Err(ApiError::bad_request("Invalid resource kind")), } let mut bytes = [0u8; 32]; OsRng.fill_bytes(&mut bytes); let token = hex::encode(bytes); let expires_at = (Utc::now() + Duration::days(state.anonymous_access_token_ttl_days)).to_rfc3339(); sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_INSERT, )) .bind(hash_access_token(&token)) .bind(kind) .bind(slug) .bind(&expires_at) .execute(state.db.pool()) .await?; state.clear_rate_limit(&limit_key).await; let cookie = crate::security::resource_cookie( kind, slug, &token, state.anonymous_access_token_ttl_days, ); let mut response = Json(AccessTokenResponse { granted: true, expires_at, }) .into_response(); response.headers_mut().insert(header::SET_COOKIE, cookie); Ok(response) } pub(crate) async fn verify_password_access_token( state: &SharedState, kind: &str, slug: &str, token: Option<&str>, ) -> Result { let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(false); }; let count: i64 = sqlx::query_scalar(queries::get( state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT, )) .bind(hash_access_token(token)) .bind(kind) .bind(slug) .bind(Utc::now().to_rfc3339()) .fetch_one(state.db.pool()) .await?; if count == 0 { return Ok(false); } // Password-derived access must stop working when the resource no longer // has a password. This also invalidates tokens created by older versions // for private resources that never had a password configured. match kind { "workspace" => Ok(db::find_workspace(&state.db, slug) .await? .is_some_and(|workspace| workspace.password_hash.is_some())), "pad" => Ok(db::find_pad(&state.db, slug) .await? .is_some_and(|pad| pad.password_hash.is_some())), _ => Ok(false), } } pub(super) fn hash_access_token(token: &str) -> String { hex::encode(Sha256::digest(token.as_bytes())) }