simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

story.rs (14602B)


      1 use std::convert::Infallible;
      2 
      3 use askama::Template;
      4 use axum::{
      5     extract::{Path, State},
      6     response::{Html, IntoResponse, Redirect, Response, sse::{Event, Sse}},
      7     Form,
      8 };
      9 use axum_extra::extract::cookie::CookieJar;
     10 use serde::Deserialize;
     11 
     12 use crate::database::{CommentWithMeta, StoryWithMeta};
     13 use crate::database::{self, TARGET_TYPE_COMMENT, TARGET_TYPE_STORY};
     14 use crate::error::AppError;
     15 use crate::state::AppState;
     16 use crate::templates::{ApplicationTemplate, CommentsTemplate, EditTemplate, StoryTemplate, SubmitTemplate};
     17 
     18 async fn fetch_enriched_comments(
     19     state: &AppState,
     20     user_id: Option<i64>,
     21     story_id: i64,
     22 ) -> Result<Vec<CommentWithMeta>, AppError> {
     23     let db = state.db();
     24     let comments = db.get_comments_for_story(story_id).await?;
     25 
     26     let comment_ids: Vec<i64> = comments.iter().map(|c| c.id).collect();
     27     let voted_comment_ids = if let Some(uid) = user_id {
     28         if !comment_ids.is_empty() {
     29             db.get_user_votes(uid, TARGET_TYPE_COMMENT, &comment_ids)
     30                 .await
     31                 .unwrap_or_default()
     32         } else {
     33             vec![]
     34         }
     35     } else {
     36         vec![]
     37     };
     38 
     39     // Batch-fetch comment author usernames
     40     let unique_user_ids: Vec<i64> = {
     41         let mut ids: Vec<i64> = comments.iter()
     42             .filter_map(|c| c.created_by.parse::<i64>().ok())
     43             .collect();
     44         ids.sort_unstable();
     45         ids.dedup();
     46         ids
     47     };
     48     let author_map = db.enrich_comment_authors(&unique_user_ids).await.unwrap_or_default();
     49 
     50     let mut enriched = Vec::with_capacity(comments.len());
     51     for comment in comments {
     52         let uid: i64 = comment.created_by.parse().unwrap_or(0);
     53         let comment_author = author_map.get(&uid).cloned();
     54         let comment_time_ago = database::time_ago(comment.created_at);
     55         let comment_voted = voted_comment_ids.contains(&comment.id);
     56 
     57         enriched.push(CommentWithMeta::from_comment(
     58             comment,
     59             comment_author,
     60             comment_time_ago,
     61             comment_voted,
     62         ));
     63     }
     64 
     65     Ok(enriched)
     66 }
     67 
     68 pub async fn show_story(
     69     State(state): State<AppState>,
     70     jar: CookieJar,
     71     Path(id): Path<i64>,
     72 ) -> Result<Html<String>, AppError> {
     73     let user_id = state.get_user_id(&jar).await;
     74 
     75     let db = state.db();
     76     let story = db.get_story(id).await?
     77         .ok_or_else(|| AppError::NotFound("Story not found".to_string()))?;
     78 
     79     let tags = db.get_categories_for_story(id).await.unwrap_or_default();
     80 
     81     let author_username = if let Some(ref created_by) = story.created_by {
     82         if let Ok(uid) = created_by.parse::<i64>() {
     83             db.get_user_by_id(uid)
     84                 .await
     85                 .ok()
     86                 .flatten()
     87                 .map(|u| u.username)
     88         } else {
     89             None
     90         }
     91     } else {
     92         None
     93     };
     94 
     95     let story_voted = if let Some(uid) = user_id {
     96         db.get_user_votes(uid, TARGET_TYPE_STORY, &[id])
     97             .await
     98             .map(|v| v.contains(&id))
     99             .unwrap_or(false)
    100     } else {
    101         false
    102     };
    103 
    104     let time_ago = database::time_ago(story.published);
    105     let story_with_meta = StoryWithMeta::from_story(story, tags, author_username, time_ago, story_voted, 0);
    106 
    107     let enriched_comments = fetch_enriched_comments(&state, user_id, id).await?;
    108 
    109     let is_logged_in = user_id.is_some();
    110     let current_user_id = user_id.map(|id| id.to_string());
    111 
    112     let story_template = StoryTemplate {
    113         story: story_with_meta,
    114         comments: enriched_comments,
    115         is_logged_in,
    116         current_user_id,
    117     };
    118 
    119     let app = ApplicationTemplate {
    120         content: story_template.render()?,
    121     };
    122     Ok(Html(app.render()?))
    123 }
    124 
    125 pub async fn show_submit(
    126     State(state): State<AppState>,
    127     jar: CookieJar,
    128 ) -> Result<Html<String>, AppError> {
    129     let user_id = state.get_user_id(&jar).await;
    130     if user_id.is_none() {
    131         return Ok(Html(
    132             r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string(),
    133         ));
    134     }
    135 
    136     let all_tags = state.db().get_categories(50).await.unwrap_or_default();
    137 
    138     let submit = SubmitTemplate {
    139         error: None,
    140         title: String::new(),
    141         url: String::new(),
    142         text: String::new(),
    143         all_tags,
    144         selected_tags: vec![],
    145     };
    146 
    147     let app = ApplicationTemplate {
    148         content: submit.render()?,
    149     };
    150     Ok(Html(app.render()?))
    151 }
    152 
    153 #[derive(Debug, Deserialize)]
    154 pub struct SubmitForm {
    155     pub title: String,
    156     pub url: Option<String>,
    157     pub text: Option<String>,
    158     #[serde(default)]
    159     pub tags: Vec<i64>,
    160 }
    161 
    162 pub async fn submit_story(
    163     State(state): State<AppState>,
    164     jar: CookieJar,
    165     Form(form): Form<SubmitForm>,
    166 ) -> Result<Redirect, Html<String>> {
    167     let uid = state.get_user_id(&jar).await.ok_or_else(|| {
    168         Html(r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string())
    169     })?;
    170 
    171     let all_tags = state.db().get_categories(50).await.unwrap_or_default();
    172 
    173     // Validate: must have either URL or text
    174     let url = form.url.as_ref().filter(|u| !u.trim().is_empty());
    175     let text = form.text.as_ref().filter(|t| !t.trim().is_empty());
    176 
    177     if url.is_none() && text.is_none() {
    178         return Err(render_submit_error(
    179             &form,
    180             &all_tags,
    181             "Please provide either a URL or text",
    182         ));
    183     }
    184 
    185     if form.title.trim().is_empty() {
    186         return Err(render_submit_error(&form, &all_tags, "Title is required"));
    187     }
    188 
    189     // Validate URL if provided
    190     if let Some(u) = url {
    191         // Simple URL validation
    192         if !u.starts_with("http://") && !u.starts_with("https://") {
    193             return Err(render_submit_error(&form, &all_tags, "Invalid URL. Must start with http:// or https://"));
    194         }
    195     }
    196 
    197     let domain = url.and_then(|u| database::extract_domain(u));
    198     let title = form.title.trim().to_string();
    199     let url_val = url.map(|s| s.to_string());
    200     let text_val = text.map(|s| s.to_string()).unwrap_or_default();
    201     let tag_ids = form.tags.clone();
    202 
    203     let story = state.db().create_story(
    204         uid,
    205         &title,
    206         url_val.as_deref(),
    207         &text_val,
    208         domain.as_deref(),
    209         "en",
    210     ).await.map_err(|e| render_submit_error(&form, &all_tags, &format!("Failed to create story: {}", e)))?;
    211 
    212     let story_id = story.id;
    213     if !tag_ids.is_empty() {
    214         for tag_id in tag_ids {
    215             if let Err(e) = state.db().add_tag_to_story(story_id, tag_id).await {
    216                 eprintln!("Failed to add tag {} to story {}: {}", tag_id, story_id, e);
    217             }
    218         }
    219     }
    220 
    221     Ok(Redirect::to(&format!("/story/{}", story.id)))
    222 }
    223 
    224 fn render_submit_error(form: &SubmitForm, all_tags: &[database::Category], error: &str) -> Html<String> {
    225     let submit = SubmitTemplate {
    226         error: Some(error.to_string()),
    227         title: form.title.clone(),
    228         url: form.url.clone().unwrap_or_default(),
    229         text: form.text.clone().unwrap_or_default(),
    230         all_tags: all_tags.to_vec(),
    231         selected_tags: form.tags.clone(),
    232     };
    233     let app = ApplicationTemplate {
    234         content: submit.render().unwrap_or_default(),
    235     };
    236     Html(app.render().unwrap_or_default())
    237 }
    238 
    239 // ============================================================================
    240 // Edit story
    241 // ============================================================================
    242 
    243 pub async fn show_edit(
    244     State(state): State<AppState>,
    245     jar: CookieJar,
    246     Path(id): Path<i64>,
    247 ) -> Result<Html<String>, AppError> {
    248     let user_id = state.get_user_id(&jar).await
    249         .ok_or_else(|| AppError::Unauthorized("Login required".to_string()))?;
    250 
    251     let db = state.db();
    252     let story = db.get_story(id).await?
    253         .ok_or_else(|| AppError::NotFound("Story not found".to_string()))?;
    254 
    255     // Check ownership
    256     let story_owner: i64 = story.created_by.as_ref()
    257         .and_then(|s| s.parse().ok())
    258         .unwrap_or(0);
    259     if story_owner != user_id {
    260         return Err(AppError::Unauthorized("You can only edit your own stories".to_string()));
    261     }
    262 
    263     let all_tags = db.get_categories(50).await.unwrap_or_default();
    264     let story_tags = db.get_categories_for_story(id).await.unwrap_or_default();
    265 
    266     let selected_tags: Vec<i64> = story_tags.iter().map(|t| t.id).collect();
    267 
    268     let edit = EditTemplate {
    269         error: None,
    270         story_id: id,
    271         title: story.title,
    272         url: story.url.unwrap_or_default(),
    273         text: story.text,
    274         all_tags,
    275         selected_tags,
    276     };
    277 
    278     let app = ApplicationTemplate {
    279         content: edit.render()?,
    280     };
    281     Ok(Html(app.render()?))
    282 }
    283 
    284 #[derive(Debug, Deserialize)]
    285 pub struct EditForm {
    286     pub title: String,
    287     pub url: Option<String>,
    288     pub text: Option<String>,
    289     #[serde(default)]
    290     pub tags: Vec<i64>,
    291 }
    292 
    293 pub async fn edit_story(
    294     State(state): State<AppState>,
    295     jar: CookieJar,
    296     Path(id): Path<i64>,
    297     Form(form): Form<EditForm>,
    298 ) -> Result<Redirect, Html<String>> {
    299     let user_id = state.get_user_id(&jar).await.ok_or_else(|| {
    300         Html(r#"<meta http-equiv="refresh" content="0;url=/login">"#.to_string())
    301     })?;
    302 
    303     // Check ownership
    304     let story = state.db().get_story(id).await
    305         .and_then(|opt| opt.ok_or_else(|| AppError::NotFound("Story not found".to_string())))
    306         .map_err(|e| render_edit_error(id, &form, &[], &format!("{}", e)))?;
    307 
    308     let story_owner: i64 = story.created_by.as_ref()
    309         .and_then(|s| s.parse().ok())
    310         .unwrap_or(0);
    311     if story_owner != user_id {
    312         return Err(render_edit_error(id, &form, &[], "You can only edit your own stories"));
    313     }
    314 
    315     if form.title.trim().is_empty() {
    316         let all_tags = state.db().get_categories(50).await.unwrap_or_default();
    317         return Err(render_edit_error(id, &form, &all_tags, "Title is required"));
    318     }
    319 
    320     let url = form.url.as_ref().filter(|u| !u.trim().is_empty());
    321     let text = form.text.as_ref().filter(|t| !t.trim().is_empty());
    322     let domain = url.and_then(|u| database::extract_domain(u));
    323 
    324     let title = form.title.trim().to_string();
    325     let url_val = url.map(|s| s.to_string());
    326     let text_val = text.map(|s| s.to_string()).unwrap_or_default();
    327     let tag_ids = form.tags.clone();
    328 
    329     let db = state.db();
    330     db.update_story(id, &title, url_val.as_deref(), &text_val, domain.as_deref()).await
    331         .map_err(|e| render_edit_error(id, &form, &[], &format!("Failed to update: {}", e)))?;
    332 
    333     db.remove_all_tags_from_story(id).await
    334         .map_err(|e| render_edit_error(id, &form, &[], &format!("Failed to update: {}", e)))?;
    335     for tag_id in tag_ids {
    336         if let Err(e) = db.add_tag_to_story(id, tag_id).await {
    337             eprintln!("Failed to add tag {} to story {}: {}", tag_id, id, e);
    338         }
    339     }
    340 
    341     Ok(Redirect::to(&format!("/story/{}", id)))
    342 }
    343 
    344 fn render_comments_event(
    345     comments: Vec<CommentWithMeta>,
    346     is_logged_in: bool,
    347     story_id: i64,
    348 ) -> Option<Event> {
    349     let template = CommentsTemplate {
    350         comments,
    351         is_logged_in,
    352     };
    353     let inner_html = template.render().ok()?;
    354     let html = format!(
    355         "<section class=\"comments\" id=\"comments-section\" \
    356          data-init=\"@get('/story/{story_id}/comments-stream', \
    357          {{requestCancellation: 'cleanup'}})\">\
    358          {inner_html}</section>"
    359     );
    360     let data: String = html
    361         .lines()
    362         .map(|line| format!("elements {line}"))
    363         .collect::<Vec<_>>()
    364         .join("\n");
    365     Some(
    366         Event::default()
    367             .event("datastar-patch-elements")
    368             .data(data),
    369     )
    370 }
    371 
    372 /// Long-lived SSE endpoint that uses broadcast channel for comment notifications.
    373 pub async fn comments_stream(
    374     State(state): State<AppState>,
    375     jar: CookieJar,
    376     Path(story_id): Path<i64>,
    377 ) -> Response {
    378     let user_id = state.get_user_id(&jar).await;
    379     let is_logged_in = user_id.is_some();
    380     let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(10);
    381     let mut broadcast_rx = state.comment_tx.subscribe();
    382 
    383     let state_clone = state.clone();
    384     tokio::spawn(async move {
    385         // Send current comments immediately
    386         match fetch_enriched_comments(&state_clone, user_id, story_id).await {
    387             Ok(comments) => {
    388                 if let Some(event) = render_comments_event(comments, is_logged_in, story_id) {
    389                     if tx.send(Ok(event)).await.is_err() {
    390                         return;
    391                     }
    392                 }
    393             }
    394             Err(e) => {
    395                 eprintln!("Failed to fetch initial comments for SSE: {}", e);
    396                 return;
    397             }
    398         }
    399 
    400         // Wait for broadcast notifications
    401         loop {
    402             match broadcast_rx.recv().await {
    403                 Ok(changed_story_id) if changed_story_id == story_id => {
    404                     match fetch_enriched_comments(&state_clone, user_id, story_id).await {
    405                         Ok(comments) => {
    406                             if let Some(event) = render_comments_event(comments, is_logged_in, story_id) {
    407                                 if tx.send(Ok(event)).await.is_err() {
    408                                     break;
    409                                 }
    410                             }
    411                         }
    412                         Err(e) => {
    413                             eprintln!("Failed to fetch comments for SSE: {}", e);
    414                         }
    415                     }
    416                 }
    417                 Ok(_) => continue,
    418                 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
    419                 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
    420             }
    421         }
    422     });
    423 
    424     let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
    425     let sse = Sse::new(stream).keep_alive(
    426         axum::response::sse::KeepAlive::new()
    427             .interval(std::time::Duration::from_secs(30)),
    428     );
    429     let mut response = sse.into_response();
    430     response
    431         .headers_mut()
    432         .insert("X-Accel-Buffering", "no".parse().unwrap());
    433     response
    434 }
    435 
    436 fn render_edit_error(story_id: i64, form: &EditForm, all_tags: &[database::Category], error: &str) -> Html<String> {
    437     let edit = EditTemplate {
    438         error: Some(error.to_string()),
    439         story_id,
    440         title: form.title.clone(),
    441         url: form.url.clone().unwrap_or_default(),
    442         text: form.text.clone().unwrap_or_default(),
    443         all_tags: all_tags.to_vec(),
    444         selected_tags: form.tags.clone(),
    445     };
    446     let app = ApplicationTemplate {
    447         content: edit.render().unwrap_or_default(),
    448     };
    449     Html(app.render().unwrap_or_default())
    450 }