The smokesignal.events web application

feature: private event content

+451 -88
+43 -58
Cargo.toml
··· 34 34 atproto-record = { git = "https://tangled.org/@smokesignal.events/atproto-identity-rs" } 35 35 atproto-xrpcs = { git = "https://tangled.org/@smokesignal.events/atproto-identity-rs" } 36 36 37 + ammonia = "4" 37 38 anyhow = "1.0" 38 39 async-trait = "0.1" 39 - axum-extra = { version = "0.10", features = ["cookie", "cookie-private", "form", "query", "cookie-key-expansion", "typed-header", "typed-routing"] } 40 40 axum = { version = "0.8", features = ["http2", "macros", "multipart"] } 41 + axum-extra = { version = "0.12", features = ["cookie", "cookie-private", "form", "query", "cookie-key-expansion", "typed-header", "typed-routing", "cached"] } 42 + axum-htmx = { version = "0.8", features = ["auto-vary"] } 41 43 axum-template = { version = "3.0", features = ["minijinja-autoreload", "minijinja"] } 42 44 base64 = "0.22" 45 + bloomfilter = "3" 46 + bytes = "1.10" 47 + chrono = { version = "0.4", default-features = false, features = ["std", "alloc", "now", "serde"] } 43 48 chrono-tz = { version = "0.10", features = ["serde"] } 44 - chrono = { version = "0.4", default-features = false, features = ["std", "alloc", "now", "serde"] } 45 - futures-util = { version = "0.3", features = ["sink"] } 46 - headers = "0.4" 49 + cookie = "0.18" 50 + crockford = "1.2" 51 + css-inline = "0.18" 52 + deadpool-redis = {version = "0.22", features = ["connection-manager", "tokio-comp", "tokio-rustls-comp"] } 53 + elliptic-curve = { version = "0.13", features = ["pem", "pkcs8", "sec1", "std", "alloc", "digest", "ecdh", "jwk", "bits"] } 54 + fluent = "0.17" 55 + fluent-bundle = "0.16" 56 + fluent-syntax = "0.12" 57 + hex = "0.4.3" 58 + hmac = "0.12" 47 59 http = "1.1" 60 + icalendar = "0.17" 61 + image = "0.25" 62 + intl-memoizer = "0.5" 63 + itertools = "0.14" 64 + lettre = {version = "0.11", default-features = false, features = ["builder", "hostname", "pool", "smtp-transport", "rustls-tls", "tokio1-rustls-tls"]} 65 + lru = "0.16" 66 + metrohash = "1.0" 67 + minify-html-onepass = "0.18" 68 + minijinja = { version = "2.7", features = ["builtins", "json", "urlencode"] } 69 + minijinja-autoreload = { version = "2.7", optional = true } 70 + minijinja-embed = { version = "2.7", optional = true } 71 + minio = { version = "0.3", optional = true } 72 + once_cell = "1.19" 73 + opensearch = { version = "2.3", default-features = false, features = ["rustls-tls"] } 74 + ordermap = "1" 75 + p256 = { version = "0.13", features = ["ecdsa-core", "jwk", "serde", "ecdh"] } 76 + rand = "0.9" 77 + regex = "1" 78 + reqwest = { version = "0.12", features = ["json", "zstd", "rustls-tls"] } 79 + rust-embed = "8.5" 80 + serde = { version = "1.0", features = ["alloc", "derive"] } 48 81 serde_json = { version = "1.0", features = ["alloc"] } 49 - serde = { version = "1.0", features = ["alloc", "derive"] } 82 + sha2 = "0.10" 83 + sqlx = { version = "0.8", default-features = false, features = ["derive", "macros", "migrate", "json", "runtime-tokio", "postgres", "chrono", "tls-rustls-ring-native-roots"] } 50 84 thiserror = "2.0" 51 - tokio-util = { version = "0.7", features = ["net", "rt", "tracing"] } 52 85 tokio = { version = "1.41", features = ["bytes", "macros", "net", "rt", "rt-multi-thread", "signal", "sync"] } 86 + tokio-util = { version = "0.7", features = ["net", "rt", "tracing"] } 53 87 tower-http = { version = "0.6", features = ["cors", "fs", "timeout", "trace", "tracing"] } 54 - tower = { version = "0.5", features = ["limit", "timeout", "tokio", "tracing"] } 55 - tracing-subscriber = { version = "0.3", features = ["env-filter", "chrono", "json"] } 56 88 tracing = { version = "0.1", features = ["async-await", "log", "valuable"] } 57 - reqwest = { version = "0.12", features = ["json", "zstd", "rustls-tls"] } 58 - reqwest-chain = "1" 59 - reqwest-middleware = { version = "0.4", features = ["http2", "json", "multipart"] } 60 - reqwest-retry = "0.7" 61 - regex = "1" 62 - duration-str = "0.11" 63 - minijinja = { version = "2.7", features = ["builtins", "json", "urlencode"] } 64 - minijinja-autoreload = { version = "2.7", optional = true } 65 - minijinja-embed = { version = "2.7", optional = true } 66 - axum-htmx = { version = "0.7", features = ["auto-vary"] } 67 - hickory-resolver = { version = "0.24", features = ["dns-over-https-rustls", "dns-over-rustls", "rustls", "tokio-rustls"] } 68 - rand = "0.8" 69 - async-stream = "0.3" 70 - tokio-stream = "0.1" 89 + tracing-subscriber = { version = "0.3", features = ["env-filter", "chrono", "json"] } 90 + ulid = { version = "1.1", features = ["serde"] } 91 + unic-langid = "0.9" 71 92 url = "2.5" 72 - cookie = "0.18" 73 - ammonia = "4" 74 - rust-embed = "8.5" 75 - sqlx = { version = "0.8", default-features = false, features = ["derive", "macros", "migrate", "json", "runtime-tokio", "postgres", "chrono", "tls-rustls-ring-native-roots"] } 76 - elliptic-curve = { version = "0.13.8", features = ["pem", "pkcs8", "sec1", "std", "alloc", "digest", "ecdh", "jwk", "bits"] } 77 - p256 = { version = "0.13.2", features = ["ecdsa-core", "jwk", "serde", "ecdh"] } 78 - ordermap = "0.5" 79 93 urlencoding = "2.1" 80 - ulid = { version = "1.1", features = ["serde"] } 81 - unic-langid = "0.9" 82 - intl-memoizer = "0.5" 83 - fluent = "0.16" 84 - fluent-bundle = "0.15" 85 - fluent-syntax = "0.11" 86 - sha2 = "0.10.8" 87 - redis = { version = "0.28", features = ["tokio-comp", "tokio-rustls-comp"] } 88 - itertools = "0.14.0" 89 - deadpool = "0.12.2" 90 - deadpool-redis = {version = "0.20.0", features = ["connection-manager", "tokio-comp", "tokio-rustls-comp"] } 91 - crockford = "1.2.1" 92 - tokio-websockets = { version = "0.11.3", features = ["client", "rand", "ring", "rustls-native-roots"] } 93 - zstd = "0.13.3" 94 - once_cell = "1.19" 95 - parking_lot = "0.12" 96 - metrohash = "1.0.7" 97 - minio = { version = "0.3", optional = true } 98 - bytes = "1.10.1" 99 - bloomfilter = "1.0.15" 100 - image = "0.25" 101 - opensearch = { version = "2.3.0", default-features = false, features = ["rustls-tls"] } 102 - icalendar = "0.16" 103 - lru = "0.16.0" 104 - lettre = {version = "0.11.19", default-features = false, features = ["builder", "hostname", "pool", "smtp-transport", "rustls-tls", "tokio1-rustls-tls"]} 105 - hmac = "0.12.1" 106 - hex = "0.4.3" 107 - css-inline = "0.18.0" 108 - minify-html-onepass = "0.18.1" 109 94 110 95 [profile.release] 111 96 opt-level = 3
+11
migrations/20251105000002_private_event_content.sql
··· 1 + -- Create private_event_content table for conditional event content 2 + CREATE TABLE private_event_content ( 3 + aturi TEXT PRIMARY KEY, 4 + display_criteria JSONB NOT NULL DEFAULT '[]', 5 + content TEXT NOT NULL DEFAULT '', 6 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 7 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 8 + ); 9 + 10 + -- Create index for faster lookups 11 + CREATE INDEX idx_private_event_content_aturi ON private_event_content(aturi);
+1 -1
src/bin/crypto.rs
··· 4 4 use rand::RngCore; 5 5 6 6 fn main() { 7 - let mut rng = rand::thread_rng(); 7 + let mut rng = rand::rng(); 8 8 9 9 env::args().for_each(|arg| { 10 10 if arg.as_str() == "key" {
+3 -1
src/http/errors/create_rsvp_errors.rs
··· 17 17 /// 18 18 /// This error occurs when a user attempts to RSVP to an event that requires 19 19 /// confirmed email addresses, but the user has not confirmed their email address. 20 - #[error("error-smokesignal-create-rsvp-2 Email confirmation required: This event requires a confirmed email address to RSVP. Please confirm your email address in your settings.")] 20 + #[error( 21 + "error-smokesignal-create-rsvp-2 Email confirmation required: This event requires a confirmed email address to RSVP. Please confirm your email address in your settings." 22 + )] 21 23 EmailConfirmationRequired, 22 24 }
+5
src/http/event_form.rs
··· 158 158 pub send_notifications: Option<bool>, 159 159 160 160 pub require_confirmed_email: Option<bool>, 161 + 162 + pub private_content: Option<String>, 163 + pub private_content_criteria_going_confirmed: Option<bool>, 164 + pub private_content_criteria_going: Option<bool>, 165 + pub private_content_criteria_interested: Option<bool>, 161 166 } 162 167 163 168 impl From<BuildEventForm> for BuildLocationForm {
+2 -1
src/http/handle_create_rsvp.rs
··· 118 118 if !found_errors { 119 119 // Check if the event requires confirmed email 120 120 let event_aturi = build_rsvp_form.subject_aturi.as_ref().unwrap(); 121 - let event = crate::storage::event::event_get(&web_context.pool, event_aturi).await?; 121 + let event = 122 + crate::storage::event::event_get(&web_context.pool, event_aturi).await?; 122 123 123 124 if event.require_confirmed_email { 124 125 // Check if user has confirmed email
+76 -1
src/http/handle_edit_event.rs
··· 198 198 build_event_form.description = Some(description.clone()); 199 199 build_event_form.require_confirmed_email = Some(event.require_confirmed_email); 200 200 201 + // Load private event content if it exists 202 + if let Ok(Some(private_content)) = 203 + crate::storage::private_event_content::private_event_content_get( 204 + &ctx.web_context.pool, 205 + &lookup_aturi, 206 + ) 207 + .await 208 + { 209 + build_event_form.private_content = Some(private_content.content); 210 + 211 + // Set checkboxes based on criteria 212 + for criterion in private_content.display_criteria.0.iter() { 213 + match criterion.as_str() { 214 + "going_confirmed" => { 215 + build_event_form.private_content_criteria_going_confirmed = Some(true) 216 + } 217 + "going" => build_event_form.private_content_criteria_going = Some(true), 218 + "interested" => { 219 + build_event_form.private_content_criteria_interested = Some(true) 220 + } 221 + _ => {} 222 + } 223 + } 224 + } 225 + 201 226 // If we have a single address location, populate the form fields with its data 202 227 if let LocationEditStatus::Editable(Address { 203 228 country, ··· 650 675 error_template, 651 676 default_context, 652 677 err, 653 - StatusCode::OK 678 + StatusCode::INTERNAL_SERVER_ERROR 654 679 ); 680 + } 681 + 682 + // Save private event content if provided 683 + let private_content = build_event_form.private_content.as_deref().unwrap_or(""); 684 + 685 + // Build display criteria array from checkboxes 686 + let mut display_criteria = Vec::new(); 687 + if build_event_form 688 + .private_content_criteria_going_confirmed 689 + .unwrap_or(false) 690 + { 691 + display_criteria.push("going_confirmed".to_string()); 692 + } 693 + if build_event_form 694 + .private_content_criteria_going 695 + .unwrap_or(false) 696 + { 697 + display_criteria.push("going".to_string()); 698 + } 699 + if build_event_form 700 + .private_content_criteria_interested 701 + .unwrap_or(false) 702 + { 703 + display_criteria.push("interested".to_string()); 704 + } 705 + 706 + // Only save if there's content or criteria, otherwise delete 707 + if !private_content.is_empty() || !display_criteria.is_empty() { 708 + if let Err(err) = 709 + crate::storage::private_event_content::private_event_content_upsert( 710 + &ctx.web_context.pool, 711 + &lookup_aturi, 712 + &display_criteria, 713 + private_content, 714 + ) 715 + .await 716 + { 717 + tracing::error!("Failed to save private event content: {:?}", err); 718 + } 719 + } else { 720 + // Delete private content if both content and criteria are empty 721 + if let Err(err) = 722 + crate::storage::private_event_content::private_event_content_delete( 723 + &ctx.web_context.pool, 724 + &lookup_aturi, 725 + ) 726 + .await 727 + { 728 + tracing::error!("Failed to delete private event content: {:?}", err); 729 + } 655 730 } 656 731 657 732 // Send email notifications to RSVP holders if checkbox is checked and emailer is enabled
+6 -4
src/http/handle_oauth_aip_login.rs
··· 12 12 use axum_template::RenderHtml; 13 13 use http::StatusCode; 14 14 use minijinja::context as template_context; 15 - use rand::{Rng, distributions::Alphanumeric}; 15 + use rand::{Rng, distr::Alphanumeric}; 16 16 use serde::Deserialize; 17 17 18 18 use crate::{ ··· 86 86 87 87 if let Some(subject) = validated_subject { 88 88 // Generate OAuth parameters 89 - let state: String = rand::thread_rng() 89 + let state: String = rand::rng() 90 90 .sample_iter(&Alphanumeric) 91 91 .take(30) 92 92 .map(char::from) 93 93 .collect(); 94 - let nonce: String = rand::thread_rng() 94 + let nonce: String = rand::rng() 95 95 .sample_iter(&Alphanumeric) 96 96 .take(30) 97 97 .map(char::from) ··· 218 218 stringify(oauth_args) 219 219 ); 220 220 221 - if hx_request && let Ok(hx_redirect) = HxRedirect::try_from(destination.as_str()) { 221 + if hx_request { 222 + let hx_redirect = HxRedirect::try_from(destination.as_str()) 223 + .expect("HxRedirect construction should not fail"); 222 224 return Ok((StatusCode::OK, hx_redirect, "").into_response()); 223 225 } 224 226
+3 -3
src/http/handle_oauth_login.rs
··· 16 16 use axum_template::RenderHtml; 17 17 use http::StatusCode; 18 18 use minijinja::context as template_context; 19 - use rand::{Rng, distributions::Alphanumeric}; 19 + use rand::{Rng, distr::Alphanumeric}; 20 20 use serde::Deserialize; 21 21 22 22 use crate::{ ··· 180 180 return contextual_error!(web_context, language, error_template, default_context, err); 181 181 } 182 182 183 - let state: String = rand::thread_rng() 183 + let state: String = rand::rng() 184 184 .sample_iter(&Alphanumeric) 185 185 .take(30) 186 186 .map(char::from) 187 187 .collect(); 188 - let nonce: String = rand::thread_rng() 188 + let nonce: String = rand::rng() 189 189 .sample_iter(&Alphanumeric) 190 190 .take(30) 191 191 .map(char::from)
+80 -13
src/http/handle_view_event.rs
··· 28 28 use crate::storage::event::event_exists; 29 29 use crate::storage::event::event_get; 30 30 use crate::storage::event::get_event_rsvps_with_validation; 31 - use crate::storage::event::get_user_rsvp_with_email_shared; 31 + use crate::storage::event::get_user_rsvp_status_and_validation; 32 32 use crate::storage::identity_profile::handle_for_did; 33 33 use crate::storage::identity_profile::handle_for_handle; 34 34 use crate::storage::identity_profile::handles_by_did; ··· 285 285 .is_some_and(|current_entity| current_entity.did == profile.did); 286 286 287 287 // Get user's RSVP status if logged in 288 - let user_rsvp_status = if let Some(current_entity) = &ctx.current_handle { 289 - match get_user_rsvp_with_email_shared(&ctx.web_context.pool, &aturi, &current_entity.did) 288 + let (user_rsvp_status, user_rsvp_is_validated) = 289 + if let Some(current_entity) = &ctx.current_handle { 290 + match get_user_rsvp_status_and_validation( 291 + &ctx.web_context.pool, 292 + &aturi, 293 + &current_entity.did, 294 + ) 290 295 .await 291 - { 292 - Ok(Some((status, _email_shared))) => Some(status), 293 - Ok(None) => None, 294 - Err(err) => { 295 - tracing::error!("Error getting user RSVP status: {:?}", err); 296 - None 296 + { 297 + Ok(Some((status, is_validated))) => (Some(status), is_validated), 298 + Ok(None) => (None, false), 299 + Err(err) => { 300 + tracing::error!("Error getting user RSVP status: {:?}", err); 301 + (None, false) 302 + } 297 303 } 298 - } 299 - } else { 300 - None 301 - }; 304 + } else { 305 + (None, false) 306 + }; 302 307 303 308 // Check if there's a pending acceptance ticket for the current user's RSVP 304 309 let pending_acceptance = if let Some(current_entity) = &ctx.current_handle { ··· 398 403 let interested_tab_url = format!("?tab=interested&collection={}", encoded_collection); 399 404 let notgoing_tab_url = format!("?tab=notgoing&collection={}", encoded_collection); 400 405 406 + // Check if private content should be displayed 407 + let private_content = if ctx.current_handle.is_some() { 408 + // Load private event content 409 + if let Ok(Some(private_event_content)) = 410 + crate::storage::private_event_content::private_event_content_get( 411 + &ctx.web_context.pool, 412 + &aturi, 413 + ) 414 + .await 415 + { 416 + // Check if viewer meets any of the display criteria 417 + let mut meets_criteria = false; 418 + 419 + // If display_criteria is empty, show to anyone who has RSVP'd 420 + if private_event_content.display_criteria.0.is_empty() { 421 + meets_criteria = user_rsvp_status.is_some(); 422 + } else { 423 + // Check specific criteria 424 + for criterion in private_event_content.display_criteria.0.iter() { 425 + match criterion.as_str() { 426 + "going_confirmed" => { 427 + // Check if user has "going" status AND has a validated RSVP 428 + if user_rsvp_status.as_deref() == Some("going") 429 + && user_rsvp_is_validated 430 + { 431 + meets_criteria = true; 432 + break; 433 + } 434 + } 435 + "going" => { 436 + // Check if user has "going" status 437 + if user_rsvp_status.as_deref() == Some("going") { 438 + meets_criteria = true; 439 + break; 440 + } 441 + } 442 + "interested" => { 443 + // Check if user has "interested" status 444 + if user_rsvp_status.as_deref() == Some("interested") { 445 + meets_criteria = true; 446 + break; 447 + } 448 + } 449 + _ => {} 450 + } 451 + } 452 + } 453 + 454 + if meets_criteria { 455 + Some(private_event_content.content) 456 + } else { 457 + None 458 + } 459 + } else { 460 + None 461 + } 462 + } else { 463 + None 464 + }; 465 + 401 466 Ok(( 402 467 StatusCode::OK, 403 468 RenderHtml( ··· 424 489 notgoing_tab_url, 425 490 // Email confirmation status 426 491 user_has_confirmed_email, 492 + // Private event content 493 + private_content, 427 494 }, 428 495 ), 429 496 )
+3 -2
src/storage/content.rs
··· 319 319 bloom_filter_capacity: usize, 320 320 bloom_filter_error_rate: f64, 321 321 ) -> Self { 322 - let bloom_filter = Bloom::new_for_fp_rate(bloom_filter_capacity, bloom_filter_error_rate); 322 + let bloom_filter = Bloom::new_for_fp_rate(bloom_filter_capacity, bloom_filter_error_rate) 323 + .expect("TODO fix this"); 323 324 324 325 Self { 325 326 underlying_storage, ··· 381 382 // This is a limitation of bloom filters - in production you might want 382 383 // to use a counting bloom filter or periodically reset the filter 383 384 let mut filter = self.not_found_filter.write().await; 384 - *filter = Bloom::new_for_fp_rate(10000, 0.01); 385 + *filter = Bloom::new_for_fp_rate(10000, 0.01).expect("TODO fix this"); 385 386 } 386 387 } 387 388
+2 -2
src/storage/event.rs
··· 589 589 Ok(status) 590 590 } 591 591 592 - pub async fn get_user_rsvp_with_email_shared( 592 + pub async fn get_user_rsvp_status_and_validation( 593 593 pool: &StoragePool, 594 594 event_aturi: &str, 595 595 did: &str, ··· 614 614 .map_err(StorageError::CannotBeginDatabaseTransaction)?; 615 615 616 616 let result = sqlx::query_as::<_, (String, bool)>( 617 - "SELECT status, email_shared FROM rsvps WHERE event_aturi = $1 AND did = $2", 617 + "SELECT status, validated_at IS NOT NULL FROM rsvps WHERE event_aturi = $1 AND did = $2", 618 618 ) 619 619 .bind(event_aturi) 620 620 .bind(did)
+1
src/storage/mod.rs
··· 9 9 pub mod identity_profile; 10 10 pub mod notification; 11 11 pub mod oauth; 12 + pub mod private_event_content; 12 13 pub mod profile; 13 14 pub mod types; 14 15 pub mod webhook;
+119
src/storage/private_event_content.rs
··· 1 + use chrono::Utc; 2 + use serde::{Deserialize, Serialize}; 3 + use serde_json::json; 4 + use sqlx::FromRow; 5 + 6 + use crate::storage::StoragePool; 7 + use crate::storage::errors::StorageError; 8 + 9 + #[derive(Clone, FromRow, Deserialize, Serialize, Debug)] 10 + pub struct PrivateEventContent { 11 + pub aturi: String, 12 + pub display_criteria: sqlx::types::Json<Vec<String>>, 13 + pub content: String, 14 + pub created_at: chrono::DateTime<Utc>, 15 + pub updated_at: chrono::DateTime<Utc>, 16 + } 17 + 18 + /// Upsert private event content for an event 19 + pub async fn private_event_content_upsert( 20 + pool: &StoragePool, 21 + aturi: &str, 22 + display_criteria: &[String], 23 + content: &str, 24 + ) -> Result<(), StorageError> { 25 + // Validate aturi is not empty 26 + if aturi.trim().is_empty() { 27 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 28 + "Event URI cannot be empty".into(), 29 + ))); 30 + } 31 + 32 + let mut tx = pool 33 + .begin() 34 + .await 35 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 36 + 37 + let now = Utc::now(); 38 + 39 + sqlx::query( 40 + "INSERT INTO private_event_content (aturi, display_criteria, content, created_at, updated_at) 41 + VALUES ($1, $2, $3, $4, $5) 42 + ON CONFLICT (aturi) DO UPDATE SET 43 + display_criteria = $2, 44 + content = $3, 45 + updated_at = $5", 46 + ) 47 + .bind(aturi) 48 + .bind(json!(display_criteria)) 49 + .bind(content) 50 + .bind(now) 51 + .bind(now) 52 + .execute(tx.as_mut()) 53 + .await 54 + .map_err(StorageError::UnableToExecuteQuery)?; 55 + 56 + tx.commit() 57 + .await 58 + .map_err(StorageError::CannotCommitDatabaseTransaction) 59 + } 60 + 61 + /// Get private event content for an event by AT-URI 62 + pub async fn private_event_content_get( 63 + pool: &StoragePool, 64 + aturi: &str, 65 + ) -> Result<Option<PrivateEventContent>, StorageError> { 66 + // Validate aturi is not empty 67 + if aturi.trim().is_empty() { 68 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 69 + "Event URI cannot be empty".into(), 70 + ))); 71 + } 72 + 73 + let mut tx = pool 74 + .begin() 75 + .await 76 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 77 + 78 + let content = sqlx::query_as::<_, PrivateEventContent>( 79 + "SELECT * FROM private_event_content WHERE aturi = $1", 80 + ) 81 + .bind(aturi) 82 + .fetch_optional(tx.as_mut()) 83 + .await 84 + .map_err(StorageError::UnableToExecuteQuery)?; 85 + 86 + tx.commit() 87 + .await 88 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 89 + 90 + Ok(content) 91 + } 92 + 93 + /// Delete private event content for an event 94 + pub async fn private_event_content_delete( 95 + pool: &StoragePool, 96 + aturi: &str, 97 + ) -> Result<(), StorageError> { 98 + // Validate aturi is not empty 99 + if aturi.trim().is_empty() { 100 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 101 + "Event URI cannot be empty".into(), 102 + ))); 103 + } 104 + 105 + let mut tx = pool 106 + .begin() 107 + .await 108 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 109 + 110 + sqlx::query("DELETE FROM private_event_content WHERE aturi = $1") 111 + .bind(aturi) 112 + .execute(tx.as_mut()) 113 + .await 114 + .map_err(StorageError::UnableToExecuteQuery)?; 115 + 116 + tx.commit() 117 + .await 118 + .map_err(StorageError::CannotCommitDatabaseTransaction) 119 + }
+2 -2
src/task_webhooks.rs
··· 2 2 use atproto_identity::traits::DidDocumentStorage; 3 3 use atproto_oauth::jwt::{Claims, Header, mint}; 4 4 use chrono::Utc; 5 - use rand::distributions::{Alphanumeric, DistString}; 5 + use rand::distr::{Alphanumeric, SampleString}; 6 6 use serde::{Deserialize, Serialize}; 7 7 use serde_json::json; 8 8 use sqlx::PgPool; ··· 197 197 let header: Header = self.service_key.0.clone().try_into()?; 198 198 199 199 let now = Utc::now(); 200 - let jti = Alphanumeric.sample_string(&mut rand::thread_rng(), 30); 200 + let jti = Alphanumeric.sample_string(&mut rand::rng(), 30); 201 201 202 202 // Create base claims 203 203 let mut claims = Claims::default();
+74
templates/en-us/edit_event.common.html
··· 68 68 </a> 69 69 </div> 70 70 71 + <div class="box has-background-link-light"> 72 + <h2 class="title is-4">Private Event Content</h2> 73 + <p class="mb-4">Add content that will only be visible to attendees who meet specific criteria. This content is stored off-protocol and not published to your PDS.</p> 74 + 75 + <form hx-post="/{{ handle_slug }}/{{ event_rkey }}/edit" hx-target="body" hx-swap="outerHTML"> 76 + <!-- Include all other form fields as hidden to preserve state --> 77 + <input type="hidden" name="build_state" value="Selected"> 78 + {% if build_event_form.name %} 79 + <input type="hidden" name="name" value="{{ build_event_form.name }}"> 80 + {% endif %} 81 + {% if build_event_form.description %} 82 + <input type="hidden" name="description" value="{{ build_event_form.description }}"> 83 + {% endif %} 84 + {% if build_event_form.status %} 85 + <input type="hidden" name="status" value="{{ build_event_form.status }}"> 86 + {% endif %} 87 + {% if build_event_form.mode %} 88 + <input type="hidden" name="mode" value="{{ build_event_form.mode }}"> 89 + {% endif %} 90 + {% if build_event_form.starts_at %} 91 + <input type="hidden" name="starts_at" value="{{ build_event_form.starts_at }}"> 92 + {% endif %} 93 + {% if build_event_form.ends_at %} 94 + <input type="hidden" name="ends_at" value="{{ build_event_form.ends_at }}"> 95 + {% endif %} 96 + {% if build_event_form.require_confirmed_email %} 97 + <input type="hidden" name="require_confirmed_email" value="true"> 98 + {% endif %} 99 + 100 + <div class="field"> 101 + <label class="label" for="privateContentTextarea">Private Content</label> 102 + <div class="control"> 103 + <textarea class="textarea" id="privateContentTextarea" name="private_content" 104 + maxlength="5000" rows="6" 105 + placeholder="Information that only specific attendees can see...">{% if build_event_form.private_content %}{{ build_event_form.private_content }}{% endif %}</textarea> 106 + </div> 107 + <p class="help">This content will only be shown to attendees who meet the selected criteria below.</p> 108 + </div> 109 + 110 + <div class="field"> 111 + <label class="label">Show to:</label> 112 + <div class="control"> 113 + <label class="checkbox is-block mb-2"> 114 + <input type="checkbox" name="private_content_criteria_going_confirmed" value="true" 115 + {% if build_event_form.private_content_criteria_going_confirmed %}checked{% endif %}> 116 + Attendees with "Going" status who have accepted their RSVP ticket 117 + </label> 118 + <label class="checkbox is-block mb-2"> 119 + <input type="checkbox" name="private_content_criteria_going" value="true" 120 + {% if build_event_form.private_content_criteria_going %}checked{% endif %}> 121 + All attendees with "Going" status 122 + </label> 123 + <label class="checkbox is-block"> 124 + <input type="checkbox" name="private_content_criteria_interested" value="true" 125 + {% if build_event_form.private_content_criteria_interested %}checked{% endif %}> 126 + Attendees with "Interested" status 127 + </label> 128 + </div> 129 + <p class="help mt-2">Select one or more criteria. Content will be shown if the viewer matches any selected criterion.</p> 130 + </div> 131 + 132 + <div class="field"> 133 + <div class="control"> 134 + <button type="submit" class="button is-link"> 135 + <span class="icon"> 136 + <i class="fas fa-save"></i> 137 + </span> 138 + <span>Save Private Content</span> 139 + </button> 140 + </div> 141 + </div> 142 + </form> 143 + </div> 144 + 71 145 {% if delete_event_url %} 72 146 {% include 'en-us/delete_event.partial.html' %} 73 147 {% endif %}
+20
templates/en-us/view_event.common.html
··· 279 279 </div> 280 280 </section> 281 281 282 + {% if private_content %} 283 + <section class="section"> 284 + <div class="container"> 285 + <article class="message is-info"> 286 + <div class="message-header"> 287 + <p> 288 + <span class="icon"> 289 + <i class="fas fa-lock"></i> 290 + </span> 291 + <span>Private Event Information</span> 292 + </p> 293 + </div> 294 + <div class="message-body" style="word-break: break-word; white-space: pre-wrap;"> 295 + {{ private_content }} 296 + </div> 297 + </article> 298 + </div> 299 + </section> 300 + {% endif %} 301 + 282 302 <section class="section" id="rsvps"> 283 303 <div class="container"> 284 304 <div class="tabs">