diff --git a/packages/backend/native/package.json b/packages/backend/native/package.json index df287ef4a8..b36fe9f474 100644 --- a/packages/backend/native/package.json +++ b/packages/backend/native/package.json @@ -29,7 +29,7 @@ "test": "node --test ./__tests__/**/*.spec.js", "bench": "node ./benchmark/index.js", "build": "napi build --release --strip --no-const-enum", - "build:debug": "napi build" + "build:debug": "napi build --no-const-enum" }, "devDependencies": { "@napi-rs/cli": "3.5.0", diff --git a/packages/backend/native/src/backend_runtime/blob_complete.rs b/packages/backend/native/src/backend_runtime/blob_complete.rs index 4c8c0edbf3..28fe0334ac 100644 --- a/packages/backend/native/src/backend_runtime/blob_complete.rs +++ b/packages/backend/native/src/backend_runtime/blob_complete.rs @@ -3,7 +3,7 @@ use std::{ path::{Path, PathBuf}, }; -use base64::{Engine as _, engine::general_purpose::URL_SAFE}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use napi::Result; use serde::Deserialize; use sha2::{Digest, Sha256}; @@ -41,8 +41,16 @@ fn blob_complete_success( } } -fn sha256_base64_url_with_padding(body: &[u8]) -> String { - URL_SAFE.encode(Sha256::digest(body)) +fn normalize_base64_url_key(key: &str) -> &str { + key.trim_end_matches('=') +} + +fn sha256_base64_url(body: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(body)) +} + +fn sha256_base64_url_matches(body: &[u8], key: &str) -> bool { + sha256_base64_url(body) == normalize_base64_url_key(key) } #[derive(Deserialize)] @@ -178,7 +186,7 @@ impl BackendRuntime { return Ok(blob_complete_failure("mime_mismatch")); } - if sha256_base64_url_with_padding(&object.body) != key { + if !sha256_base64_url_matches(&object.body, &key) { match self.object_storage_delete(object_key).await { Ok(()) => {} Err(err) if object_missing_error(&err) => {} @@ -243,7 +251,7 @@ impl BackendRuntime { Err(err) => return Err(napi_error(format!("BlobComplete read fs object failed: {err}"))), }; - if sha256_base64_url_with_padding(&body) != key { + if !sha256_base64_url_matches(&body, &key) { let _ = fs::remove_file(&path); let _ = fs::remove_file(PathBuf::from(format!("{}.metadata.json", path.display()))); return Ok(blob_complete_failure("checksum_mismatch")); @@ -268,13 +276,21 @@ impl BackendRuntime { #[cfg(test)] mod tests { - use super::sha256_base64_url_with_padding; + use super::{sha256_base64_url, sha256_base64_url_matches}; #[test] - fn sha256_base64_url_keeps_padding() { + fn sha256_base64_url_omits_padding() { assert_eq!( - sha256_base64_url_with_padding(b"hello"), - "LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=" + sha256_base64_url(b"hello"), + "LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ" ); } + + #[test] + fn sha256_base64_url_matches_legacy_padding() { + assert!(sha256_base64_url_matches( + b"hello", + "LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=" + )); + } } diff --git a/packages/backend/native/src/backend_runtime/runtime_state/dto.rs b/packages/backend/native/src/backend_runtime/runtime_state/dto.rs index 8bdea9bf44..367f7d892e 100644 --- a/packages/backend/native/src/backend_runtime/runtime_state/dto.rs +++ b/packages/backend/native/src/backend_runtime/runtime_state/dto.rs @@ -321,6 +321,38 @@ impl RuntimeStateRows { Ok(row.get::("expires_at_ms")) } + pub(super) async fn upsert_expired_or_consumed_payload_returning_expires_in_tx( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + input: RuntimeStateInsertPayload<'_>, + ) -> Result> { + let row = sqlx::query( + r#" + INSERT INTO runtime_states (purpose, token_hash, lookup_key, payload, expires_at) + VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP + ($5 * INTERVAL '1 millisecond')) + ON CONFLICT (purpose, token_hash) DO UPDATE + SET lookup_key = EXCLUDED.lookup_key, + payload = EXCLUDED.payload, + attempts = 0, + consumed_at = NULL, + expires_at = EXCLUDED.expires_at + WHERE runtime_states.consumed_at IS NOT NULL + OR runtime_states.expires_at <= CURRENT_TIMESTAMP + RETURNING (EXTRACT(EPOCH FROM expires_at) * 1000)::BIGINT AS expires_at_ms + "#, + ) + .bind(input.purpose) + .bind(token_hash(input.token)) + .bind(input.lookup_key) + .bind(input.payload) + .bind(input.ttl_ms as f64) + .fetch_optional(&mut **tx) + .await + .map_err(|err| napi_error(format!("{} failed: {err}", input.context)))?; + + Ok(row.map(|row| row.get::("expires_at_ms"))) + } + pub(super) async fn update_attempts_in_tx( &self, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, diff --git a/packages/backend/native/src/backend_runtime/runtime_state/invite_link.rs b/packages/backend/native/src/backend_runtime/runtime_state/invite_link.rs index 02109703d0..54aba85e28 100644 --- a/packages/backend/native/src/backend_runtime/runtime_state/invite_link.rs +++ b/packages/backend/native/src/backend_runtime/runtime_state/invite_link.rs @@ -33,6 +33,11 @@ pub(super) async fn create( } let mut tx = rows.begin("RuntimeState workspace invite link").await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(&workspace_id) + .execute(&mut *tx) + .await + .map_err(|err| napi_error(format!("RuntimeState workspace invite link active lock failed: {err}")))?; if let Some(existing) = get_by_key_in_tx(rows, &mut tx, WORKSPACE_INVITE_LINK_WORKSPACE_PURPOSE, &workspace_id).await? @@ -51,8 +56,8 @@ pub(super) async fn create( "inviterUserId": inviter_user_id, }); - let expires_at_ms = rows - .insert_payload_returning_expires_in_tx( + let Some(expires_at_ms) = rows + .upsert_expired_or_consumed_payload_returning_expires_in_tx( &mut tx, RuntimeStateInsertPayload { purpose: WORKSPACE_INVITE_LINK_WORKSPACE_PURPOSE, @@ -63,7 +68,16 @@ pub(super) async fn create( context: "RuntimeState workspace invite link create", }, ) - .await?; + .await? + else { + let existing = get_by_key_in_tx(rows, &mut tx, WORKSPACE_INVITE_LINK_WORKSPACE_PURPOSE, &workspace_id).await?; + tx.commit().await.map_err(|err| { + napi_error(format!( + "RuntimeState workspace invite link transaction commit failed: {err}" + )) + })?; + return existing.ok_or_else(|| napi_error("RuntimeState workspace invite link active conflict missing row")); + }; rows .insert_payload_returning_expires_in_tx( &mut tx,