//! Public blog pages. use axum::{ extract::{Path, State}, response::IntoResponse, routing::get, Router, }; use tower_sessions::Session; use crate::{ auth::MaybeUserUnverified, constants, db::{self, Slug}, error::{AppError, Result}, helpers::{fetch_discussion_info, get_csrf_token, get_initials}, templates::*, types::*, AppState, }; /// Register blog page routes. pub fn blog_routes() -> Router { Router::new() .route("/p/{slug}/blog", get(project_blog_page)) .route("/p/{slug}/blog/{post_slug}", get(blog_post_page)) .route("/changelog", get(changelog_index)) .route("/changelog/{post_slug}", get(changelog_post)) } /// Public blog index page for a project. #[tracing::instrument(skip_all, name = "blog_pages::project_blog_page")] async fn project_blog_page( State(state): State, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, Path(slug): Path, ) -> Result { let csrf_token = get_csrf_token(&session).await; let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?; let db_project = db::projects::get_public_project_by_slug(&state.db, &slug) .await? .ok_or(AppError::NotFound)?; let db_user = db::users::get_user_by_id(&state.db, db_project.user_id) .await? .ok_or(AppError::NotFound)?; let db_posts = db::blog_posts::get_published_blog_posts_by_project(&state.db, db_project.id).await?; let project = Project::from_db(&db_project, 0); let posts: Vec = db_posts.iter().map(BlogPostSummary::from).collect(); Ok(ProjectBlogTemplate { csrf_token, session_user: maybe_user, project, creator_username: db_user.username.to_string(), project_slug: db_project.slug.to_string(), posts, }) } /// Public blog post reader page. #[tracing::instrument(skip_all, name = "blog_pages::blog_post_page")] async fn blog_post_page( State(state): State, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, Path((slug, post_slug)): Path<(String, String)>, ) -> Result { let csrf_token = get_csrf_token(&session).await; let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?; let post_slug = Slug::new(&post_slug).map_err(|_| AppError::NotFound)?; let db_project = db::projects::get_public_project_by_slug(&state.db, &slug) .await? .ok_or(AppError::NotFound)?; let db_user = db::users::get_user_by_id(&state.db, db_project.user_id) .await? .ok_or(AppError::NotFound)?; let db_post = db::blog_posts::get_blog_post_by_slug(&state.db, db_project.id, &post_slug) .await? .ok_or(AppError::NotFound)?; // Only published posts are visible publicly (unless owner) let is_owner = maybe_user.as_ref().map(|u| u.id == db_project.user_id).unwrap_or(false); if db_post.published_at.is_none() && !is_owner { return Err(AppError::NotFound); } let avatar_initials = get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username)); let title_json = json_escape(&db_post.title); let project_title_json = json_escape(&db_project.title); let project_slug_str = db_project.slug.to_string(); let (discussion_url, discussion_count) = fetch_discussion_info(&state, db_post.mt_thread_id, &project_slug_str, "blog").await; Ok(BlogPostTemplate { csrf_token, session_user: maybe_user, title: db_post.title, title_json, body_html: db_post.body_html, published_at: db_post.published_at .map(|d| d.format("%b %d, %Y").to_string()) .unwrap_or_default(), creator_username: db_user.username.to_string(), creator_display_name: db_user.display_name.clone(), creator_avatar_initials: avatar_initials, project_title: db_project.title, project_title_json, project_slug: project_slug_str, post_slug: post_slug.to_string(), host_url: state.config.host_url.clone(), project_cover_image_url: db_project.cover_image_url, discussion_url, discussion_count, }) } // -- /changelog alias routes -- /// Platform changelog index (alias for the "changelog" project blog). #[tracing::instrument(skip_all, name = "blog_pages::changelog_index")] async fn changelog_index( State(state): State, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, ) -> Result { let csrf_token = get_csrf_token(&session).await; // `from_trusted` is safe here because CHANGELOG_PROJECT_SLUG is a // compile-time constant (`&'static str`), not user input. The audit // flagged this site historically; the constant origin is the // justification. let slug = Slug::from_trusted(constants::CHANGELOG_PROJECT_SLUG.to_owned()); let db_project = db::projects::get_public_project_by_slug(&state.db, &slug) .await? .ok_or(AppError::NotFound)?; let db_user = db::users::get_user_by_id(&state.db, db_project.user_id) .await? .ok_or(AppError::NotFound)?; let db_posts = db::blog_posts::get_published_blog_posts_by_project(&state.db, db_project.id).await?; let project = Project::from_db(&db_project, 0); let posts: Vec = db_posts.iter().map(BlogPostSummary::from).collect(); Ok(ProjectBlogTemplate { csrf_token, session_user: maybe_user, project, creator_username: db_user.username.to_string(), project_slug: db_project.slug.to_string(), posts, }) } /// Platform changelog post (alias for a "changelog" project blog post). #[tracing::instrument(skip_all, name = "blog_pages::changelog_post")] async fn changelog_post( State(state): State, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, Path(post_slug): Path, ) -> Result { let csrf_token = get_csrf_token(&session).await; // `from_trusted` is safe here because CHANGELOG_PROJECT_SLUG is a // compile-time constant (`&'static str`), not user input. The audit // flagged this site historically; the constant origin is the // justification. let slug = Slug::from_trusted(constants::CHANGELOG_PROJECT_SLUG.to_owned()); let post_slug = Slug::new(&post_slug).map_err(|_| AppError::NotFound)?; let db_project = db::projects::get_public_project_by_slug(&state.db, &slug) .await? .ok_or(AppError::NotFound)?; let db_user = db::users::get_user_by_id(&state.db, db_project.user_id) .await? .ok_or(AppError::NotFound)?; let db_post = db::blog_posts::get_blog_post_by_slug(&state.db, db_project.id, &post_slug) .await? .ok_or(AppError::NotFound)?; let is_owner = maybe_user .as_ref() .map(|u| u.id == db_project.user_id) .unwrap_or(false); if db_post.published_at.is_none() && !is_owner { return Err(AppError::NotFound); } let avatar_initials = get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username)); let title_json = json_escape(&db_post.title); let project_title_json = json_escape(&db_project.title); let project_slug_str = db_project.slug.to_string(); let (discussion_url, discussion_count) = fetch_discussion_info(&state, db_post.mt_thread_id, &project_slug_str, "blog").await; Ok(BlogPostTemplate { csrf_token, session_user: maybe_user, title: db_post.title, title_json, body_html: db_post.body_html, published_at: db_post .published_at .map(|d| d.format("%b %d, %Y").to_string()) .unwrap_or_default(), creator_username: db_user.username.to_string(), creator_display_name: db_user.display_name.clone(), creator_avatar_initials: avatar_initials, project_title: db_project.title, project_title_json, project_slug: project_slug_str, post_slug: post_slug.to_string(), host_url: state.config.host_url.clone(), project_cover_image_url: db_project.cover_image_url, discussion_url, discussion_count, }) }