about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock1
-rw-r--r--migrations/0002_custom_theme_setting.sql3
-rw-r--r--src/database/settings.rs19
-rw-r--r--src/frontend/mod.rs22
-rw-r--r--src/frontend/onboarding.rs2
-rw-r--r--src/indieauth/mod.rs15
-rw-r--r--src/login.rs28
-rw-r--r--templates/Cargo.toml1
-rw-r--r--templates/src/lib.rs1
-rw-r--r--templates/src/templates.rs6
-rw-r--r--templates/src/themes.rs25
11 files changed, 106 insertions, 17 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 783ec4f..f4137d3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1924,6 +1924,7 @@ dependencies = [
  "markup",
  "microformats",
  "rand",
+ "serde",
  "serde_json",
  "time",
  "walkdir",
diff --git a/migrations/0002_custom_theme_setting.sql b/migrations/0002_custom_theme_setting.sql
new file mode 100644
index 0000000..a258e82
--- /dev/null
+++ b/migrations/0002_custom_theme_setting.sql
@@ -0,0 +1,3 @@
+ALTER TABLE kittybox.users
+ADD COLUMN custom_css JSONB NOT NULL DEFAULT '""'::jsonb,
+ADD COLUMN theme JSONB NOT NULL DEFAULT '"kittybox"'::jsonb;
diff --git a/src/database/settings.rs b/src/database/settings.rs
index 792a155..77e5821 100644
--- a/src/database/settings.rs
+++ b/src/database/settings.rs
@@ -1,3 +1,5 @@
+pub use kittybox_frontend_renderer::themes::ThemeName;
+
 mod private {
     pub trait Sealed {}
 }
@@ -61,3 +63,20 @@ impl Setting for Webring {
         Self(data)
     }
 }
+
+#[derive(Debug, Default, serde::Serialize, serde::Deserialize, Clone, Copy, PartialEq, Eq)]
+/// Theme setting for Kittybox, specifying the stylesheet used for laying out the website.
+pub struct Theme(ThemeName);
+impl private::Sealed for Theme {}
+impl Setting for Theme {
+    type Data = ThemeName;
+    const ID: &'static str = "theme";
+
+    fn into_inner(self) -> Self::Data {
+        self.0
+    }
+
+    fn new(data: Self::Data) -> Self {
+        Self(data)
+    }
+}
diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs
index 94b8aa7..a05c91d 100644
--- a/src/frontend/mod.rs
+++ b/src/frontend/mod.rs
@@ -1,4 +1,4 @@
-use crate::database::{Storage, StorageError};
+use crate::database::{settings::Setting, Storage, StorageError};
 use axum::{
     extract::{Query, State},
     http::{StatusCode, Uri},
@@ -271,11 +271,13 @@ pub async fn homepage<D: Storage>(
             // other requests anyway if it's serious...)
             //
             // btw is it more efficient to fetch these in parallel?
-            let (blogname, webring, channels) = tokio::join!(
+            let (blogname, webring, theme, channels) = tokio::join!(
                 db.get_setting::<crate::database::settings::SiteName>(&hcard_url)
                     .map(Result::unwrap_or_default),
                 db.get_setting::<crate::database::settings::Webring>(&hcard_url)
                     .map(Result::unwrap_or_default),
+                db.get_setting::<crate::database::settings::Theme>(&hcard_url)
+                    .map(Result::unwrap_or_default),
                 db.get_channels(&hcard_url).map(|i| i.unwrap_or_default())
             );
 
@@ -293,6 +295,7 @@ pub async fn homepage<D: Storage>(
                     title: blogname.as_ref(),
                     blog_name: blogname.as_ref(),
                     feeds: channels,
+                    theme: theme.into_inner(),
                     user: session.as_deref(),
                     content: MainPage {
                         feed: &hfeed,
@@ -319,9 +322,11 @@ pub async fn homepage<D: Storage>(
             } else {
                 error!("Error while fetching h-card and/or h-feed: {}", err);
                 // Return the error
-                let (blogname, channels) = tokio::join!(
+                let (blogname, theme, channels) = tokio::join!(
                     db.get_setting::<crate::database::settings::SiteName>(&hcard_url)
                         .map(Result::unwrap_or_default),
+                    db.get_setting::<crate::database::settings::Theme>(&hcard_url)
+                        .map(Result::unwrap_or_default),
                     db.get_channels(&hcard_url).map(|i| i.unwrap_or_default())
                 );
 
@@ -332,6 +337,7 @@ pub async fn homepage<D: Storage>(
                         title: blogname.as_ref(),
                         blog_name: blogname.as_ref(),
                         feeds: channels,
+                        theme: theme.into_inner(),
                         user: session.as_deref(),
                         content: ErrorPage {
                             code: err.code(),
@@ -361,9 +367,11 @@ pub async fn catchall<D: Storage>(
 
     match get_post_from_database(&db, path.as_str(), query.after, user).await {
         Ok((post, cursor)) => {
-            let (blogname, channels) = tokio::join!(
+            let (blogname, theme, channels) = tokio::join!(
                 db.get_setting::<crate::database::settings::SiteName>(&host)
                     .map(Result::unwrap_or_default),
+                db.get_setting::<crate::database::settings::Theme>(&host)
+                    .map(Result::unwrap_or_default),
                 db.get_channels(&host).map(|i| i.unwrap_or_default())
             );
             let mut headers = axum::http::HeaderMap::new();
@@ -411,6 +419,7 @@ pub async fn catchall<D: Storage>(
                     title: blogname.as_ref(),
                     blog_name: blogname.as_ref(),
                     feeds: channels,
+                    theme: theme.into_inner(),
                     user: session.as_deref(),
                     content: match post.pointer("/type/0").and_then(|i| i.as_str()) {
                         Some("h-entry") => Entry {
@@ -434,9 +443,11 @@ pub async fn catchall<D: Storage>(
                 .into_response()
         }
         Err(err) => {
-            let (blogname, channels) = tokio::join!(
+            let (blogname, theme, channels) = tokio::join!(
                 db.get_setting::<crate::database::settings::SiteName>(&host)
                     .map(Result::unwrap_or_default),
+                db.get_setting::<crate::database::settings::Theme>(&host)
+                    .map(Result::unwrap_or_default),
                 db.get_channels(&host).map(|i| i.unwrap_or_default())
             );
             (
@@ -449,6 +460,7 @@ pub async fn catchall<D: Storage>(
                     title: blogname.as_ref(),
                     blog_name: blogname.as_ref(),
                     feeds: channels,
+                    theme: theme.into_inner(),
                     user: session.as_deref(),
                     content: ErrorPage {
                         code: err.code(),
diff --git a/src/frontend/onboarding.rs b/src/frontend/onboarding.rs
index 3b53911..3caa458 100644
--- a/src/frontend/onboarding.rs
+++ b/src/frontend/onboarding.rs
@@ -21,6 +21,7 @@ pub async fn get() -> Html<String> {
             title: "Kittybox - Onboarding",
             blog_name: "Kittybox",
             feeds: vec![],
+            theme: Default::default(),
             user: None,
             content: OnboardingPage {}.to_string(),
         }
@@ -150,6 +151,7 @@ pub async fn post<D: Storage + 'static>(
                             title: "Kittybox - Onboarding",
                             blog_name: "Kittybox",
                             feeds: vec![],
+                            theme: Default::default(),
                             user: None,
                             content: ErrorPage {
                                 code: err.code(),
diff --git a/src/indieauth/mod.rs b/src/indieauth/mod.rs
index 5cdbf05..af49a7f 100644
--- a/src/indieauth/mod.rs
+++ b/src/indieauth/mod.rs
@@ -1,4 +1,4 @@
-use crate::database::Storage;
+use crate::database::{settings::Setting, Storage};
 use axum::{
     extract::{Form, FromRef, Json, Query, State},
     http::StatusCode,
@@ -13,6 +13,7 @@ use axum_extra::{
     headers::{authorization::Bearer, Authorization, ContentType, HeaderMapExt},
     TypedHeader,
 };
+use futures::FutureExt;
 use kittybox_indieauth::{
     AuthorizationRequest, AuthorizationResponse, ClientMetadata, Error, ErrorKind, GrantRequest,
     GrantResponse, GrantType, IntrospectionEndpointAuthMethod, Metadata, PKCEMethod, Profile,
@@ -319,12 +320,20 @@ async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>(
     {
         request.scope.replace(Scopes::new(vec![Scope::Profile]));
     }
+    let (blog_name, theme, channels) = tokio::join!(
+        db.get_setting::<crate::database::settings::SiteName>(&me)
+            .map(Result::unwrap_or_default),
+        db.get_setting::<crate::database::settings::Theme>(&me)
+            .map(Result::unwrap_or_default),
+        db.get_channels(&me).map(|i| i.unwrap_or_default())
+    );
 
     Html(
         kittybox_frontend_renderer::Template {
             title: "Confirm sign-in via IndieAuth",
-            blog_name: "Kittybox",
-            feeds: vec![],
+            blog_name: &blog_name.as_ref(),
+            feeds: channels,
+            theme: theme.into_inner(),
             user: None,
             content: kittybox_frontend_renderer::AuthorizationRequestPage {
                 request,
diff --git a/src/login.rs b/src/login.rs
index 3038d9c..1b6a6f3 100644
--- a/src/login.rs
+++ b/src/login.rs
@@ -23,7 +23,7 @@ use kittybox_frontend_renderer::{LoginPage, LogoutPage, Template};
 use kittybox_indieauth::{AuthorizationResponse, Error, GrantType, PKCEVerifier, Scope, Scopes};
 use sha2::Digest;
 
-use crate::database::Storage;
+use crate::database::{settings::Setting, Storage};
 
 /// Show a login page.
 async fn get<S: Storage + Send + Sync + 'static>(
@@ -32,9 +32,11 @@ async fn get<S: Storage + Send + Sync + 'static>(
 ) -> impl axum::response::IntoResponse {
     let hcard_url: url::Url = format!("https://{}/", host).parse().unwrap();
 
-    let (blogname, channels) = tokio::join!(
+    let (blogname, theme, channels) = tokio::join!(
         db.get_setting::<crate::database::settings::SiteName>(&hcard_url)
             .map(Result::unwrap_or_default),
+        db.get_setting::<crate::database::settings::Theme>(&hcard_url)
+            .map(Result::unwrap_or_default),
         db.get_channels(&hcard_url).map(|i| i.unwrap_or_default())
     );
     (
@@ -47,6 +49,7 @@ async fn get<S: Storage + Send + Sync + 'static>(
             title: "Sign in with your website",
             blog_name: blogname.as_ref(),
             feeds: channels,
+            theme: theme.into_inner(),
             user: None,
             content: LoginPage {}.to_string(),
         }
@@ -380,14 +383,27 @@ async fn callback(
 /// of crawlers working with a user's cookies (wget?). If a crawler is
 /// stupid enough to execute JS and send a POST request though, that's
 /// on the crawler.
-async fn logout_page() -> impl axum::response::IntoResponse {
+async fn logout_page<D: Storage + 'static>(
+    State(db): State<D>,
+    Host(host): Host,
+) -> impl axum::response::IntoResponse {
+    let me: url::Url = format!("https://{host}/").parse().unwrap();
+    let (blog_name, theme, channels) = tokio::join!(
+        db.get_setting::<crate::database::settings::SiteName>(&me)
+            .map(Result::unwrap_or_default),
+        db.get_setting::<crate::database::settings::Theme>(&me)
+            .map(Result::unwrap_or_default),
+        db.get_channels(&me).map(|i| i.unwrap_or_default())
+    );
+
     (
         StatusCode::OK,
         [("Content-Type", "text/html")],
         Template {
             title: "Signing out...",
-            blog_name: "Kittybox",
-            feeds: vec![],
+            blog_name: blog_name.as_ref(),
+            theme: theme.into_inner(),
+            feeds: channels,
             user: None,
             content: LogoutPage {}.to_string(),
         }
@@ -501,6 +517,6 @@ where
     axum::routing::Router::new()
         .route("/start", axum::routing::get(get::<S>).post(post))
         .route("/finish", axum::routing::get(callback))
-        .route("/logout", axum::routing::get(logout_page).post(logout))
+        .route("/logout", axum::routing::get(logout_page::<S>).post(logout))
         .route("/client_metadata", axum::routing::get(client_metadata::<S>))
 }
diff --git a/templates/Cargo.toml b/templates/Cargo.toml
index ca56dfe..de81af5 100644
--- a/templates/Cargo.toml
+++ b/templates/Cargo.toml
@@ -23,6 +23,7 @@ http = { workspace = true }
 include_dir = { workspace = true }
 markup = { workspace = true }
 serde_json = { workspace = true }
+serde = { workspace = true }
 
 [dependencies.kittybox-util]
 version = "0.3.0"
diff --git a/templates/src/lib.rs b/templates/src/lib.rs
index 0f9f7c6..194a837 100644
--- a/templates/src/lib.rs
+++ b/templates/src/lib.rs
@@ -10,6 +10,7 @@ mod mf2;
 pub use mf2::{Entry, Feed, Food, VCard, POSTS_PER_PAGE};
 pub mod admin;
 pub mod assets;
+pub mod themes;
 
 #[cfg(test)]
 mod tests {
diff --git a/templates/src/templates.rs b/templates/src/templates.rs
index 5772b4d..785a4b8 100644
--- a/templates/src/templates.rs
+++ b/templates/src/templates.rs
@@ -1,16 +1,16 @@
 #![allow(clippy::needless_lifetimes)]
-use crate::{Feed, VCard};
+use crate::{themes::ThemeName, Feed, VCard};
 use http::StatusCode;
 use kittybox_util::micropub::Channel;
 
 markup::define! {
-    Template<'a>(title: &'a str, blog_name: &'a str, feeds: Vec<Channel>, user: Option<&'a kittybox_indieauth::ProfileUrl>, content: String) {
+    Template<'a>(title: &'a str, blog_name: &'a str, feeds: Vec<Channel>, theme: ThemeName, user: Option<&'a kittybox_indieauth::ProfileUrl>, content: String) {
         @markup::doctype()
         html {
             head {
                 title { @title }
                 link[rel="preconnect", href="https://fonts.gstatic.com"];
-                link[rel="stylesheet", href="/.kittybox/static/style.css"];
+                link[rel="stylesheet", href=theme.into_css_link()];
                 meta[name="viewport", content="initial-scale=1, width=device-width"];
 
                 link[rel="micropub", href="/.kittybox/micropub"];
diff --git a/templates/src/themes.rs b/templates/src/themes.rs
new file mode 100644
index 0000000..ebc0ed9
--- /dev/null
+++ b/templates/src/themes.rs
@@ -0,0 +1,25 @@
+#[derive(Debug, Default, serde::Serialize, serde::Deserialize, Clone, Copy, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+/// Choices of themes possible in Kittybox.
+pub enum ThemeName {
+    #[default]
+    /// Default theme shipped with Kittybox.
+    Kittybox,
+    /// "Serious business" theme, typeset like a business memo.
+    VivianWork,
+    /// Emulation of the old style websites used back in the day.
+    Retro,
+    /// Custom CSS specified by user.
+    Custom,
+}
+
+impl ThemeName {
+    pub(crate) fn into_css_link(self) -> &'static str {
+        match self {
+            ThemeName::Kittybox => "/.kittybox/static/style.css",
+            ThemeName::VivianWork => "/.kittybox/static/vivian_work.style.css",
+            ThemeName::Retro => "/.kittybox/static/retro.style.css",
+            ThemeName::Custom => "/.kittybox/custom_style.css",
+        }
+    }
+}