diff --git a/assets/views/admin/about.html b/assets/views/admin/about.html new file mode 100644 index 0000000..8ec89b0 --- /dev/null +++ b/assets/views/admin/about.html @@ -0,0 +1,19 @@ +{% extends "admin/base.html" %} + +{% block title %}Edit About{% endblock title %} + +{% block content %} +

Edit About

+ +
+ + + +
+{% endblock content %} diff --git a/assets/views/admin/base.html b/assets/views/admin/base.html new file mode 100644 index 0000000..c6c0780 --- /dev/null +++ b/assets/views/admin/base.html @@ -0,0 +1,27 @@ + + + + + + {% block title %}Admin{% endblock title %} + + + + + +
+ +
+
+ {% block content %}{% endblock content %} +
+ + diff --git a/assets/views/admin/blog/edit.html b/assets/views/admin/blog/edit.html new file mode 100644 index 0000000..4f7e874 --- /dev/null +++ b/assets/views/admin/blog/edit.html @@ -0,0 +1,31 @@ +{% extends "admin/base.html" %} + +{% block title %}Edit Article{% endblock title %} + +{% block content %} +

Edit Article

+ +
+ + + + + + +
+{% endblock content %} diff --git a/assets/views/admin/blog/index.html b/assets/views/admin/blog/index.html new file mode 100644 index 0000000..76f2115 --- /dev/null +++ b/assets/views/admin/blog/index.html @@ -0,0 +1,36 @@ +{% extends "admin/base.html" %} + +{% block title %}Blog Articles{% endblock title %} + +{% block content %} +

Blog Articles

+

New article

+ +{% if articles | length > 0 %} + + + + + + + + + + {% for article in articles %} + + + + + + {% endfor %} + +
TitleStatusActions
{{ article.title }}{% if article.published %}Published{% else %}Draft{% endif %} + Edit +
+ +
+
+{% else %} +

No articles yet.

+{% endif %} +{% endblock content %} diff --git a/assets/views/admin/blog/new.html b/assets/views/admin/blog/new.html new file mode 100644 index 0000000..be8ed18 --- /dev/null +++ b/assets/views/admin/blog/new.html @@ -0,0 +1,31 @@ +{% extends "admin/base.html" %} + +{% block title %}New Article{% endblock title %} + +{% block content %} +

New Article

+ +
+ + + + + + +
+{% endblock content %} diff --git a/assets/views/admin/index.html b/assets/views/admin/index.html new file mode 100644 index 0000000..0c3b06e --- /dev/null +++ b/assets/views/admin/index.html @@ -0,0 +1,13 @@ +{% extends "admin/base.html" %} + +{% block title %}Admin{% endblock title %} + +{% block content %} +

Admin

+

Logged in as {{ admin.email }}

+ + +{% endblock content %} diff --git a/assets/views/admin/login.html b/assets/views/admin/login.html new file mode 100644 index 0000000..0b3f313 --- /dev/null +++ b/assets/views/admin/login.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block title %}Admin login{% endblock title %} + +{% block content %} +

Admin login

+ +{% if error %} +

{{ error }}

+{% endif %} + +
+ + + +
+{% endblock content %} diff --git a/assets/views/base.html b/assets/views/base.html new file mode 100644 index 0000000..0b74ff9 --- /dev/null +++ b/assets/views/base.html @@ -0,0 +1,24 @@ + + + + + + {% block title %}Universal Web{% endblock title %} + + + + + +
+ +
+
+ {% block content %}{% endblock content %} +
+ + diff --git a/assets/views/blog/index.html b/assets/views/blog/index.html new file mode 100644 index 0000000..476bda3 --- /dev/null +++ b/assets/views/blog/index.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block title %}Blog{% endblock title %} + +{% block content %} +

Blog

+ +{% if articles | length > 0 %} + +{% else %} +

No published posts yet.

+{% endif %} +{% endblock content %} diff --git a/assets/views/blog/show.html b/assets/views/blog/show.html new file mode 100644 index 0000000..9b01559 --- /dev/null +++ b/assets/views/blog/show.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block title %}{{ article.title }}{% endblock title %} + +{% block content %} +
+

{{ article.title }}

+

Views: {{ article.view_count }}

+ {% if article.excerpt %}

{{ article.excerpt }}

{% endif %} +
{{ article.content | linebreaksbr | safe }}
+
+{% endblock content %} diff --git a/assets/views/home/index.html b/assets/views/home/index.html new file mode 100644 index 0000000..c70c63e --- /dev/null +++ b/assets/views/home/index.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block title %}Home{% endblock title %} + +{% block content %} +

Home

+ +
+

Latest posts

+ {% if articles | length > 0 %} + + {% else %} +

No published posts yet.

+ {% endif %} +
+{% endblock content %} diff --git a/assets/views/pages/about.html b/assets/views/pages/about.html new file mode 100644 index 0000000..3810531 --- /dev/null +++ b/assets/views/pages/about.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block title %}{{ page.title }}{% endblock title %} + +{% block content %} +
+

{{ page.title }}

+
{{ page.content | linebreaksbr | safe }}
+
+{% endblock content %} diff --git a/src/app.rs b/src/app.rs index f2234e2..2ab714f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -55,6 +55,7 @@ impl Hooks for App { .add_route(controllers::admin::routes()) .add_route(controllers::blog::routes()) .add_route(controllers::pages::routes()) + .add_route(controllers::frontend::routes()) } async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { queue.register(DownloadWorker::build(ctx)).await?; diff --git a/src/controllers/auth.rs b/src/controllers/auth.rs index b413c17..0d20d8f 100644 --- a/src/controllers/auth.rs +++ b/src/controllers/auth.rs @@ -34,7 +34,7 @@ fn is_admin(ctx: &AppContext, user: &users::Model) -> bool { admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email)) } -fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> { +pub(crate) fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> { Cookie::build((AUTH_COOKIE, token.to_string())) .path("/") .http_only(true) @@ -43,7 +43,7 @@ fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> { .build() } -fn clear_auth_cookie() -> Cookie<'static> { +pub(crate) fn clear_auth_cookie() -> Cookie<'static> { Cookie::build((AUTH_COOKIE, "")) .path("/") .http_only(true) diff --git a/src/controllers/frontend.rs b/src/controllers/frontend.rs new file mode 100644 index 0000000..6c0af91 --- /dev/null +++ b/src/controllers/frontend.rs @@ -0,0 +1,344 @@ +use crate::{ + controllers::{admin, auth as auth_controller}, + models::{ + _entities::{blog_articles, site_pages}, + users::{self, LoginParams}, + }, +}; +use chrono::Utc; +use loco_rs::prelude::*; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, +}; +use serde::Deserialize; +use serde_json::json; +use uuid::Uuid; + +const ABOUT_SLUG: &str = "about"; + +#[derive(Debug, Deserialize)] +struct ArticleForm { + title: String, + content: String, + excerpt: Option, + published: Option, + featured_image_id: Option, +} + +#[derive(Debug, Deserialize)] +struct AboutForm { + title: String, + content: String, +} + +fn slugify(title: &str) -> String { + let mut slug = String::new(); + let mut last_was_dash = false; + + for ch in title.chars().flat_map(char::to_lowercase) { + if ch.is_ascii_alphanumeric() { + slug.push(ch); + last_was_dash = false; + } else if !last_was_dash && !slug.is_empty() { + slug.push('-'); + last_was_dash = true; + } + } + + let slug = slug.trim_matches('-').to_string(); + if slug.is_empty() { + Uuid::new_v4().to_string() + } else { + slug + } +} + +fn published_at_for(published: bool) -> Option> { + published.then(|| Utc::now().into()) +} + +fn is_checked(value: &Option) -> bool { + value.as_deref().is_some_and(|value| value == "on" || value == "true") +} + +fn normalize_empty(value: Option) -> Option { + value.and_then(|value| { + let value = value.trim().to_string(); + if value.is_empty() { + None + } else { + Some(value) + } + }) +} + +async fn about_page(ctx: &AppContext) -> Result { + site_pages::Entity::find() + .filter(site_pages::Column::Slug.eq(ABOUT_SLUG)) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::NotFound) +} + +async fn article_by_id(ctx: &AppContext, id: Uuid) -> Result { + blog_articles::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::NotFound) +} + +#[debug_handler] +async fn home( + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + let articles = blog_articles::Entity::find() + .filter(blog_articles::Column::Published.eq(true)) + .order_by_desc(blog_articles::Column::PublishedAt) + .limit(5) + .all(&ctx.db) + .await?; + + format::view(&v, "home/index.html", json!({ "articles": articles })) +} + +#[debug_handler] +async fn about( + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + format::view(&v, "pages/about.html", json!({ "page": about_page(&ctx).await? })) +} + +#[debug_handler] +async fn blog_index( + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + let articles = blog_articles::Entity::find() + .filter(blog_articles::Column::Published.eq(true)) + .order_by_desc(blog_articles::Column::PublishedAt) + .all(&ctx.db) + .await?; + + format::view(&v, "blog/index.html", json!({ "articles": articles })) +} + +#[debug_handler] +async fn blog_show( + ViewEngine(v): ViewEngine, + Path(slug): Path, + State(ctx): State, +) -> Result { + let article = blog_articles::Entity::find() + .filter(blog_articles::Column::Slug.eq(slug)) + .filter(blog_articles::Column::Published.eq(true)) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::NotFound)?; + + let mut active = article.into_active_model(); + let next_count = active.view_count.as_ref().to_owned() + 1; + active.view_count = Set(next_count); + let article = active.update(&ctx.db).await?; + + format::view(&v, "blog/show.html", json!({ "article": article })) +} + +#[debug_handler] +async fn admin_login_page(ViewEngine(v): ViewEngine) -> Result { + format::view(&v, "admin/login.html", json!({ "error": null })) +} + +#[debug_handler] +async fn admin_login( + ViewEngine(v): ViewEngine, + State(ctx): State, + Form(params): Form, +) -> Result { + let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { + return format::view(&v, "admin/login.html", json!({ "error": "Invalid credentials" })); + }; + + if !user.verify_password(¶ms.password) || !admin::is_admin(&ctx, &user) { + return format::view(&v, "admin/login.html", json!({ "error": "Invalid credentials" })); + } + + let jwt_secret = ctx.config.get_jwt_config()?; + let token = user + .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) + .or_else(|_| unauthorized("unauthorized!"))?; + + format::render() + .cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])? + .redirect("/admin/dashboard") +} + +#[debug_handler] +async fn admin_logout() -> Result { + format::render() + .cookies(&[auth_controller::clear_auth_cookie()])? + .redirect("/admin/login") +} + +#[debug_handler] +async fn admin_home( + auth: auth::JWT, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + let admin_user = admin::current_admin(auth, &ctx).await?; + format::view(&v, "admin/index.html", json!({ "admin": admin_user })) +} + +#[debug_handler] +async fn admin_about( + auth: auth::JWT, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + admin::current_admin(auth, &ctx).await?; + format::view(&v, "admin/about.html", json!({ "page": about_page(&ctx).await? })) +} + +#[debug_handler] +async fn admin_about_update( + auth: auth::JWT, + State(ctx): State, + Form(params): Form, +) -> Result { + admin::current_admin(auth, &ctx).await?; + let mut page = about_page(&ctx).await?.into_active_model(); + page.title = Set(params.title); + page.content = Set(params.content); + page.update(&ctx.db).await?; + format::redirect("/admin/about") +} + +#[debug_handler] +async fn admin_articles( + auth: auth::JWT, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + admin::current_admin(auth, &ctx).await?; + let articles = blog_articles::Entity::find() + .order_by_desc(blog_articles::Column::CreatedAt) + .all(&ctx.db) + .await?; + format::view(&v, "admin/blog/index.html", json!({ "articles": articles })) +} + +#[debug_handler] +async fn admin_article_new( + auth: auth::JWT, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + admin::current_admin(auth, &ctx).await?; + format::view(&v, "admin/blog/new.html", json!({})) +} + +#[debug_handler] +async fn admin_article_create( + auth: auth::JWT, + State(ctx): State, + Form(params): Form, +) -> Result { + let admin_user = admin::current_admin(auth, &ctx).await?; + let published = is_checked(¶ms.published); + + blog_articles::ActiveModel { + id: Set(Uuid::new_v4()), + title: Set(params.title.clone()), + slug: Set(slugify(¶ms.title)), + content: Set(params.content), + excerpt: Set(normalize_empty(params.excerpt)), + published: Set(published), + author_id: Set(admin_user.id), + featured_image_id: Set(normalize_empty(params.featured_image_id)), + view_count: Set(0), + published_at: Set(published_at_for(published)), + ..Default::default() + } + .insert(&ctx.db) + .await?; + + format::redirect("/admin/blog/articles") +} + +#[debug_handler] +async fn admin_article_edit( + auth: auth::JWT, + ViewEngine(v): ViewEngine, + Path(id): Path, + State(ctx): State, +) -> Result { + admin::current_admin(auth, &ctx).await?; + format::view( + &v, + "admin/blog/edit.html", + json!({ "article": article_by_id(&ctx, id).await? }), + ) +} + +#[debug_handler] +async fn admin_article_update( + auth: auth::JWT, + Path(id): Path, + State(ctx): State, + Form(params): Form, +) -> Result { + admin::current_admin(auth, &ctx).await?; + let existing = article_by_id(&ctx, id).await?; + let was_published = existing.published; + let published = is_checked(¶ms.published); + + let mut article = existing.into_active_model(); + article.title = Set(params.title.clone()); + article.slug = Set(slugify(¶ms.title)); + article.content = Set(params.content); + article.excerpt = Set(normalize_empty(params.excerpt)); + article.published = Set(published); + article.featured_image_id = Set(normalize_empty(params.featured_image_id)); + if published && !was_published { + article.published_at = Set(published_at_for(true)); + } else if !published { + article.published_at = Set(None); + } + article.update(&ctx.db).await?; + + format::redirect("/admin/blog/articles") +} + +#[debug_handler] +async fn admin_article_delete( + auth: auth::JWT, + Path(id): Path, + State(ctx): State, +) -> Result { + admin::current_admin(auth, &ctx).await?; + article_by_id(&ctx, id).await?.delete(&ctx.db).await?; + format::redirect("/admin/blog/articles") +} + +pub fn routes() -> Routes { + Routes::new() + .add("/", get(home)) + .add("/about", get(about)) + .add("/blog", get(blog_index)) + .add("/blog/{slug}", get(blog_show)) + .add("/admin/login", get(admin_login_page)) + .add("/admin/login", post(admin_login)) + .add("/admin/logout", post(admin_logout)) + .add("/admin", get(admin_login_page)) + .add("/admin/dashboard", get(admin_home)) + .add("/admin/about", get(admin_about)) + .add("/admin/about", post(admin_about_update)) + .add("/admin/blog/articles", get(admin_articles)) + .add("/admin/blog/articles/new", get(admin_article_new)) + .add("/admin/blog/articles", post(admin_article_create)) + .add("/admin/blog/articles/{id}/edit", get(admin_article_edit)) + .add("/admin/blog/articles/{id}", post(admin_article_update)) + .add("/admin/blog/articles/{id}/delete", post(admin_article_delete)) +} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 12be272..9ec8f05 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -1,4 +1,5 @@ pub mod admin; pub mod auth; pub mod blog; +pub mod frontend; pub mod pages;