The smokesignal.events web application

feature: post RSVP going to feed

+255 -2
+1 -1
src/config.rs
··· 204 204 /// Returns the OAuth scope string with the external_base interpolated 205 205 pub fn oauth_scope(&self) -> String { 206 206 format!( 207 - "atproto include:community.lexicon.calendar.authFull?aud=did:web:{}#smokesignal include:events.smokesignal.authFull blob:*/* account:email", 207 + "atproto include:community.lexicon.calendar.authFull?aud=did:web:{}#smokesignal include:events.smokesignal.authFull include:app.bsky.authCreatePosts blob:*/* account:email", 208 208 self.external_base 209 209 ) 210 210 }
+2
src/http/errors/mod.rs
··· 11 11 pub mod login_error; 12 12 pub mod middleware_errors; 13 13 pub mod profile_import_error; 14 + pub mod share_bluesky_error; 14 15 pub mod url_error; 15 16 pub mod view_event_error; 16 17 pub mod web_error; ··· 24 25 pub(crate) use login_error::LoginError; 25 26 pub(crate) use middleware_errors::WebSessionError; 26 27 pub(crate) use profile_import_error::ProfileImportError; 28 + pub(crate) use share_bluesky_error::ShareBlueskyError; 27 29 pub(crate) use url_error::UrlError; 28 30 pub(crate) use view_event_error::ViewEventError; 29 31 pub(crate) use web_error::WebError;
+17
src/http/errors/share_bluesky_error.rs
··· 1 + use thiserror::Error; 2 + 3 + /// Represents errors that can occur when sharing an RSVP to Bluesky. 4 + #[derive(Debug, Error)] 5 + pub(crate) enum ShareBlueskyError { 6 + /// Error when the AT Protocol server returns an error response. 7 + #[error("error-smokesignal-share-bluesky-1 Failed to create post: {message}")] 8 + PostCreationFailed { message: String }, 9 + 10 + /// Error when the event is not found. 11 + #[error("error-smokesignal-share-bluesky-2 Event not found: {aturi}")] 12 + EventNotFound { aturi: String }, 13 + 14 + /// Error when the user does not have a "going" RSVP to the event. 15 + #[error("error-smokesignal-share-bluesky-3 No going RSVP found for event")] 16 + NoGoingRsvp, 17 + }
+8
src/http/errors/web_error.rs
··· 20 20 use super::lfg_error::LfgError; 21 21 use super::login_error::LoginError; 22 22 use super::middleware_errors::MiddlewareAuthError; 23 + use super::share_bluesky_error::ShareBlueskyError; 23 24 use super::url_error::UrlError; 24 25 25 26 /// Represents all possible errors that can occur in the HTTP layer. ··· 158 159 /// such as creating, viewing, or deactivating LFG records. 159 160 #[error(transparent)] 160 161 LfgError(#[from] LfgError), 162 + 163 + /// Share to Bluesky errors. 164 + /// 165 + /// This error occurs when there are issues with sharing RSVP to Bluesky, 166 + /// such as post creation failures or event not found. 167 + #[error(transparent)] 168 + ShareBlueskyError(#[from] ShareBlueskyError), 161 169 } 162 170 163 171 /// Implementation of Axum's `IntoResponse` trait for WebError.
+5
src/http/handle_create_rsvp.rs
··· 446 446 None 447 447 }; 448 448 449 + // Get event AT-URI for share button 450 + let event_aturi = build_rsvp_form.subject_aturi.clone(); 451 + 449 452 return Ok(RenderHtml( 450 453 &render_template, 451 454 web_context.engine.clone(), ··· 453 456 build_rsvp_form, 454 457 event_url, 455 458 redirect_url, 459 + is_going_rsvp, 460 + event_aturi, 456 461 }}, 457 462 ) 458 463 .into_response());
+165
src/http/handle_share_rsvp_bluesky.rs
··· 1 + use std::collections::HashSet; 2 + 3 + use ammonia::Builder; 4 + use axum::{extract::State, response::IntoResponse}; 5 + use axum_extra::extract::{Cached, Form}; 6 + use axum_htmx::HxRequest; 7 + use axum_template::RenderHtml; 8 + use chrono::Utc; 9 + use minijinja::context as template_context; 10 + use serde::Deserialize; 11 + use serde_json::json; 12 + 13 + use crate::{ 14 + atproto::auth::create_dpop_auth_from_session, 15 + http::{ 16 + context::WebContext, 17 + errors::{CommonError, ShareBlueskyError, WebError}, 18 + middleware_auth::Auth, 19 + middleware_i18n::Language, 20 + utils::{truncate_text, url_from_aturi}, 21 + }, 22 + storage::event::{event_get, extract_event_details, rsvp_get_by_event_and_did}, 23 + }; 24 + use atproto_client::com::atproto::repo::{CreateRecordRequest, CreateRecordResponse, create_record}; 25 + 26 + #[derive(Deserialize)] 27 + pub(crate) struct ShareRsvpBlueskyRequest { 28 + event_aturi: String, 29 + } 30 + 31 + pub(crate) async fn handle_share_rsvp_bluesky( 32 + State(web_context): State<WebContext>, 33 + Language(language): Language, 34 + Cached(auth): Cached<Auth>, 35 + HxRequest(_hx_request): HxRequest, 36 + Form(request): Form<ShareRsvpBlueskyRequest>, 37 + ) -> Result<impl IntoResponse, WebError> { 38 + let current_handle = auth.require("/rsvp")?; 39 + let session = auth.session().ok_or(CommonError::NotAuthorized)?; 40 + 41 + let render_template = format!("{}/share_rsvp_bluesky.partial.html", language); 42 + 43 + // Fetch event details 44 + let event = event_get(&web_context.pool, &request.event_aturi) 45 + .await 46 + .map_err(|_| ShareBlueskyError::EventNotFound { 47 + aturi: request.event_aturi.clone(), 48 + })?; 49 + 50 + // Verify the user has a "going" RSVP to this event 51 + let rsvp = rsvp_get_by_event_and_did(&web_context.pool, &request.event_aturi, &current_handle.did) 52 + .await? 53 + .ok_or(ShareBlueskyError::NoGoingRsvp)?; 54 + 55 + if rsvp.status != "going" { 56 + return Err(ShareBlueskyError::NoGoingRsvp.into()); 57 + } 58 + 59 + // Build event URL 60 + let event_url = url_from_aturi(&web_context.config.external_base, &request.event_aturi)?; 61 + 62 + // Extract event details for description 63 + let details = extract_event_details(&event); 64 + let description_short = if details.description.is_empty() { 65 + String::new() 66 + } else { 67 + let truncated = truncate_text(&details.description, 200, Some("...".to_string())); 68 + Builder::new() 69 + .tags(HashSet::new()) 70 + .clean(&truncated) 71 + .to_string() 72 + }; 73 + 74 + // Create DPoP auth 75 + let dpop_auth = create_dpop_auth_from_session(session)?; 76 + 77 + // Build the post text 78 + let post_text = format!("I'm going to \"{}\"!", event.name); 79 + 80 + // Build the external embed 81 + let external_embed = json!({ 82 + "$type": "app.bsky.embed.external", 83 + "external": { 84 + "uri": event_url, 85 + "title": event.name, 86 + "description": description_short 87 + } 88 + }); 89 + 90 + // Build the post record 91 + let post_record = json!({ 92 + "$type": "app.bsky.feed.post", 93 + "text": post_text, 94 + "createdAt": Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(), 95 + "embed": external_embed, 96 + "langs": ["en"] 97 + }); 98 + 99 + // Create the record 100 + let create_request = CreateRecordRequest { 101 + repo: current_handle.did.clone(), 102 + collection: "app.bsky.feed.post".to_string(), 103 + validate: true, 104 + record_key: None, 105 + record: post_record, 106 + swap_commit: None, 107 + }; 108 + 109 + let result = create_record( 110 + &web_context.http_client, 111 + &atproto_client::client::Auth::DPoP(dpop_auth), 112 + &current_handle.pds, 113 + create_request, 114 + ) 115 + .await; 116 + 117 + match result { 118 + Ok(CreateRecordResponse::StrongRef { uri, .. }) => { 119 + // Build Bluesky post URL 120 + let post_url = build_bluesky_post_url(&current_handle.handle, &uri); 121 + 122 + Ok(RenderHtml( 123 + &render_template, 124 + web_context.engine.clone(), 125 + template_context! { 126 + success => true, 127 + post_url, 128 + language => language.to_string(), 129 + }, 130 + ) 131 + .into_response()) 132 + } 133 + Ok(CreateRecordResponse::Error(err)) => Ok(RenderHtml( 134 + &render_template, 135 + web_context.engine.clone(), 136 + template_context! { 137 + success => false, 138 + error_message => err.error_message(), 139 + language => language.to_string(), 140 + }, 141 + ) 142 + .into_response()), 143 + Err(err) => Ok(RenderHtml( 144 + &render_template, 145 + web_context.engine.clone(), 146 + template_context! { 147 + success => false, 148 + error_message => err.to_string(), 149 + language => language.to_string(), 150 + }, 151 + ) 152 + .into_response()), 153 + } 154 + } 155 + 156 + /// Build a Bluesky post URL from handle and AT-URI. 157 + /// AT-URI format: at://did:plc:xxx/app.bsky.feed.post/rkey 158 + /// Result: https://bsky.app/profile/{handle}/post/{rkey} 159 + fn build_bluesky_post_url(handle: &str, aturi: &str) -> String { 160 + let rkey = aturi 161 + .strip_prefix("at://") 162 + .and_then(|s| s.split('/').last()) 163 + .unwrap_or(""); 164 + format!("https://bsky.app/profile/{}/post/{}", handle, rkey) 165 + }
+1 -1
src/http/middleware_auth.rs
··· 16 16 17 17 /// Cookie name for session storage. 18 18 /// Updated version to force re-authentication when cookie format changes. 19 - pub(crate) const AUTH_COOKIE_NAME: &str = "session20260118"; 19 + pub(crate) const AUTH_COOKIE_NAME: &str = "session20260122"; 20 20 21 21 #[derive(Clone)] 22 22 pub(crate) enum Auth {
+1
src/http/mod.rs
··· 62 62 pub mod handle_search; 63 63 pub mod handle_set_language; 64 64 pub mod handle_settings; 65 + pub mod handle_share_rsvp_bluesky; 65 66 pub mod handle_unaccept_rsvp; 66 67 pub mod handle_unsubscribe; 67 68 pub mod handle_view_event;
+2
src/http/server.rs
··· 107 107 handle_notification_preferences_update, handle_profile_update, handle_settings, 108 108 handle_timezone_update, 109 109 }, 110 + handle_share_rsvp_bluesky::handle_share_rsvp_bluesky, 110 111 handle_unaccept_rsvp::handle_unaccept_rsvp, 111 112 handle_unsubscribe::handle_unsubscribe, 112 113 handle_view_event::handle_view_event, ··· 347 348 .route("/accept_rsvp", post(handle_accept_rsvp)) 348 349 .route("/bulk_accept_rsvps", post(handle_bulk_accept_rsvps)) 349 350 .route("/unaccept_rsvp", post(handle_unaccept_rsvp)) 351 + .route("/share_rsvp_bluesky", post(handle_share_rsvp_bluesky)) 350 352 .route("/finalize_acceptance", post(handle_finalize_acceptance)) 351 353 .route( 352 354 "/event/upload-header",
+32
templates/en-us/create_rsvp.partial.html
··· 15 15 {% else %} 16 16 <p>The RSVP has been recorded!</p> 17 17 {% endif %} 18 + {% if is_going_rsvp %} 19 + <div id="share-bluesky-section" class="mt-4 pt-4" style="border-top: 1px solid rgba(0,0,0,0.1);"> 20 + <p class="mb-3"><strong>Let others know you're going!</strong></p> 21 + <button 22 + class="button is-info" 23 + hx-post="/share_rsvp_bluesky" 24 + hx-target="#share-bluesky-section" 25 + hx-swap="innerHTML" 26 + hx-vals='{"event_aturi": "{{ event_aturi }}"}' 27 + data-loading-disable 28 + data-loading-class="is-loading"> 29 + <span class="icon"><i class="fa-brands fa-bluesky"></i></span> 30 + <span>Post to Bluesky</span> 31 + </button> 32 + </div> 33 + {% endif %} 18 34 </div> 19 35 </article> 20 36 {% else %} ··· 36 52 <span>View Event</span> 37 53 </a> 38 54 </p> 55 + {% if is_going_rsvp %} 56 + <div id="share-bluesky-section" class="mt-4 pt-4" style="border-top: 1px solid rgba(0,0,0,0.1);"> 57 + <p class="mb-3"><strong>Let others know you're going!</strong></p> 58 + <button 59 + class="button is-info" 60 + hx-post="/share_rsvp_bluesky" 61 + hx-target="#share-bluesky-section" 62 + hx-swap="innerHTML" 63 + hx-vals='{"event_aturi": "{{ event_aturi }}"}' 64 + data-loading-disable 65 + data-loading-class="is-loading"> 66 + <span class="icon"><i class="fa-brands fa-bluesky"></i></span> 67 + <span>Post to Bluesky</span> 68 + </button> 69 + </div> 70 + {% endif %} 39 71 </div> 40 72 </article> 41 73 {% endif %}
+21
templates/en-us/share_rsvp_bluesky.partial.html
··· 1 + {% if success %} 2 + <span class="icon-text"> 3 + <span class="icon has-text-success"><i class="fas fa-check-circle"></i></span> 4 + <span>Posted to Bluesky!</span> 5 + </span> 6 + <p class="mt-2"> 7 + <a href="{{ post_url }}" class="button is-link is-small" target="_blank" rel="noopener noreferrer"> 8 + <span class="icon"><i class="fa-brands fa-bluesky"></i></span> 9 + <span>View Post</span> 10 + <span class="icon is-small"><i class="fas fa-external-link-alt"></i></span> 11 + </a> 12 + </p> 13 + {% else %} 14 + <span class="icon-text"> 15 + <span class="icon has-text-danger"><i class="fas fa-exclamation-circle"></i></span> 16 + <span>Failed to post to Bluesky</span> 17 + </span> 18 + {% if error_message %} 19 + <p class="mt-2 is-size-7">{{ error_message }}</p> 20 + {% endif %} 21 + {% endif %}