diff --git a/Cargo.lock b/Cargo.lock index 11814b9..831f33d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,6 +370,19 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atom_syndication" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f68d23e2cb4fd958c705b91a6b4c80ceeaf27a9e11651272a8389d5ce1a4a3" +dependencies = [ + "chrono", + "derive_builder", + "diligent-date-parser", + "never", + "quick-xml", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -953,6 +966,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", + "strsim", "syn 2.0.100", ] @@ -988,6 +1002,37 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.100", +] + [[package]] name = "digest" version = "0.10.7" @@ -1000,6 +1045,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "diligent-date-parser" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ede7d79366f419921e2e2f67889c12125726692a313bffb474bd5f37a581e9" +dependencies = [ + "chrono", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -2062,6 +2116,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "never" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2518,6 +2578,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.37.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quote" version = "1.0.40" @@ -2800,6 +2870,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rss" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2107738f003660f0a91f56fd3e3bd3ab5d918b2ddaf1e1ec2136fb1c46f71bf" +dependencies = [ + "atom_syndication", + "derive_builder", + "never", + "quick-xml", +] + [[package]] name = "rust-embed" version = "8.7.0" @@ -2969,6 +3051,7 @@ dependencies = [ "password-auth", "pulldown-cmark", "rand 0.9.0", + "rss", "rust-embed", "sea-orm", "serde", diff --git a/Cargo.toml b/Cargo.toml index b3b7bac..ff273f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ mime_guess = "2.0.5" password-auth = "1.0.0" pulldown-cmark = "0.13.0" rand = "0.9.0" +rss = "2.0.12" rust-embed = { version = "8.7.0", features = ["axum", "debug-embed"] } sea-orm = { version = "1.1.8", features = [ "sqlx-sqlite", diff --git a/README.md b/README.md index 57d65d8..97c1823 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,10 @@ Still very much an early WIP. ### Roadmap - [ ] Favicon from post +- [ ] Bulk edit tag +- [ ] RSS feed - [ ] Logging - [ ] Improved error handling -- [ ] Bulk edit tag - [ ] Caching - [ ] Lossless compression - [ ] User management diff --git a/src/config.rs b/src/config.rs index 17ecefa..8838d08 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,11 +6,13 @@ use crate::{ }; pub(crate) const APPLICATION_NAME_KEY: &str = "APPLICATION_NAME"; +pub(crate) const BASE_URL_KEY: &str = "BASE_URL"; pub(crate) const AGE_CONFIRMATION_KEY: &str = "AGE_CONFIRMATION"; #[derive(Clone)] pub(crate) struct AppConfig { pub(crate) application_name: String, + pub(crate) base_url: String, pub(crate) age_confirmation: bool, } @@ -24,6 +26,14 @@ impl AppConfig { Some(row) => row.data.as_str().unwrap_or("Samey").to_owned(), None => "Samey".to_owned(), }; + let base_url = match SameyConfig::find() + .filter(samey_config::Column::Key.eq(BASE_URL_KEY)) + .one(db) + .await? + { + Some(row) => row.data.as_str().unwrap_or("").to_owned(), + None => "".to_owned(), + }; let age_confirmation = match SameyConfig::find() .filter(samey_config::Column::Key.eq(AGE_CONFIRMATION_KEY)) .one(db) @@ -34,6 +44,7 @@ impl AppConfig { }; Ok(Self { application_name, + base_url, age_confirmation, }) } diff --git a/src/lib.rs b/src/lib.rs index 8bc2b5f..180f7f0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -92,7 +92,9 @@ pub async fn get_router( fs::create_dir_all(files_dir.as_ref()).await?; let session_store = SessionStorage::new(db.clone()); - let session_layer = SessionManagerLayer::new(session_store); + let session_layer = SessionManagerLayer::new(session_store).with_expiry( + tower_sessions::Expiry::OnInactivity(time::Duration::weeks(1)), + ); let auth_layer = AuthManagerLayerBuilder::new(Backend::new(db), session_layer).build(); Ok(Router::new() @@ -134,6 +136,7 @@ pub async fn get_router( .route_with_tsr("/posts/{page}", get(posts_page)) // Other routes .route_with_tsr("/remove", delete(remove_field)) + .route("/posts.xml", get(rss_page)) .route("/", get(index)) .with_state(state) .nest_service("/files", ServeDir::new(files_dir)) diff --git a/src/query.rs b/src/query.rs index a4d51e0..aad4521 100644 --- a/src/query.rs +++ b/src/query.rs @@ -1,5 +1,6 @@ use std::collections::HashSet; +use chrono::NaiveDateTime; use migration::{Expr, Query}; use sea_orm::{ ColumnTrait, Condition, DatabaseConnection, EntityTrait, FromQueryResult, IntoSimpleExpr, @@ -20,6 +21,10 @@ use crate::{ pub(crate) struct PostOverview { pub(crate) id: i32, pub(crate) thumbnail: String, + pub(crate) media: String, + pub(crate) title: Option, + pub(crate) description: Option, + pub(crate) uploaded_at: NaiveDateTime, pub(crate) tags: Option, pub(crate) media_type: String, pub(crate) rating: String, @@ -54,6 +59,10 @@ pub(crate) fn search_posts( let mut query = SameyPost::find() .select_only() .column(samey_post::Column::Id) + .column(samey_post::Column::Media) + .column(samey_post::Column::Title) + .column(samey_post::Column::Description) + .column(samey_post::Column::UploadedAt) .column(samey_post::Column::Thumbnail) .column(samey_post::Column::Rating) .column(samey_post::Column::MediaType) @@ -77,6 +86,10 @@ pub(crate) fn search_posts( let mut query = SameyPost::find() .select_only() .column(samey_post::Column::Id) + .column(samey_post::Column::Media) + .column(samey_post::Column::Title) + .column(samey_post::Column::Description) + .column(samey_post::Column::UploadedAt) .column(samey_post::Column::Thumbnail) .column(samey_post::Column::Rating) .column(samey_post::Column::MediaType) diff --git a/src/views.rs b/src/views.rs index 9c99371..b05d1af 100644 --- a/src/views.rs +++ b/src/views.rs @@ -29,7 +29,7 @@ use tokio::{task::spawn_blocking, try_join}; use crate::{ AppState, NEGATIVE_PREFIX, RATING_PREFIX, auth::{AuthSession, Credentials, User}, - config::{AGE_CONFIRMATION_KEY, APPLICATION_NAME_KEY}, + config::{AGE_CONFIRMATION_KEY, APPLICATION_NAME_KEY, BASE_URL_KEY}, entities::{ prelude::{ SameyConfig, SameyPool, SameyPoolPost, SameyPost, SameyPostSource, SameyTag, @@ -90,6 +90,63 @@ pub(crate) async fn index( )) } +// RSS view + +#[derive(Template)] +#[template(path = "fragments/rss_entry.html")] +struct RssEntryTemplate<'a> { + post: PostOverview, + base_url: &'a str, +} + +#[axum::debug_handler] +pub(crate) async fn rss_page( + State(AppState { app_config, db, .. }): State, + Query(query): Query, +) -> Result { + let app_config = app_config.read().await; + let application_name = app_config.application_name.clone(); + let base_url = app_config.base_url.clone(); + drop(app_config); + + let tags = query + .tags + .as_ref() + .map(|tags| tags.split_whitespace().collect::>()); + + let posts = search_posts(tags.as_ref(), None) + .paginate(&db, 20) + .fetch_page(0) + .await?; + + let channel = rss::ChannelBuilder::default() + .title(&application_name) + .link(&base_url) + .items( + posts + .into_iter() + .map(|post| { + rss::ItemBuilder::default() + .title(post.tags.clone()) + .pub_date(post.uploaded_at.and_utc().to_rfc2822()) + .link(format!("{}/post/{}", &base_url, post.id)) + .content( + RssEntryTemplate { + post, + base_url: &base_url, + } + .render() + .ok(), + ) + .build() + }) + .collect_vec(), + ) + .build(); + + Ok(channel.to_string()) +} + // Auth views #[derive(Template)] @@ -1097,6 +1154,7 @@ pub(crate) async fn sort_pool( #[template(path = "pages/settings.html")] struct SettingsTemplate { application_name: String, + base_url: String, age_confirmation: bool, } @@ -1110,6 +1168,7 @@ pub(crate) async fn settings( let app_config = app_config.read().await; let application_name = app_config.application_name.clone(); + let base_url = app_config.base_url.clone(); let age_confirmation = app_config.age_confirmation; drop(app_config); @@ -1129,6 +1188,7 @@ pub(crate) async fn settings( Ok(Html( SettingsTemplate { application_name, + base_url, age_confirmation, } .render_with_values(&values)?, @@ -1138,6 +1198,7 @@ pub(crate) async fn settings( #[derive(Debug, Deserialize)] pub(crate) struct UpdateSettingsForm { application_name: String, + base_url: String, age_confirmation: Option, } @@ -1164,6 +1225,16 @@ pub(crate) async fn update_settings( }); } + let _ = mem::replace( + &mut app_config.write().await.base_url, + body.base_url.clone(), + ); + configs.push(samey_config::ActiveModel { + key: Set(BASE_URL_KEY.into()), + data: Set(body.base_url.into()), + ..Default::default() + }); + let age_confirmation = body.age_confirmation.is_some(); let _ = mem::replace( &mut app_config.write().await.age_confirmation, @@ -1248,6 +1319,10 @@ pub(crate) async fn view_post_page( Some(parent_post) => Some(PostOverview { id: parent_id, thumbnail: parent_post.thumbnail, + title: parent_post.title, + description: parent_post.description, + uploaded_at: parent_post.uploaded_at, + media: parent_post.media, tags: Some( get_tags_for_post(post_id) .all(&db) @@ -1277,6 +1352,10 @@ pub(crate) async fn view_post_page( children_posts.push(PostOverview { id: child_post.id, thumbnail: child_post.thumbnail, + title: child_post.title, + description: child_post.description, + uploaded_at: child_post.uploaded_at, + media: child_post.media, tags: Some( get_tags_for_post(child_post.id) .all(&db) @@ -1410,6 +1489,10 @@ pub(crate) async fn submit_post_details( Some(parent_post) => Some(PostOverview { id: parent_id, thumbnail: parent_post.thumbnail, + title: parent_post.title, + description: parent_post.description, + uploaded_at: parent_post.uploaded_at, + media: parent_post.media, tags: Some( get_tags_for_post(post_id) .all(&db) diff --git a/templates/fragments/get_video_media.html b/templates/fragments/get_video_media.html index 7e130c0..feff536 100644 --- a/templates/fragments/get_video_media.html +++ b/templates/fragments/get_video_media.html @@ -1,6 +1,7 @@ diff --git a/templates/fragments/rss_entry.html b/templates/fragments/rss_entry.html new file mode 100644 index 0000000..246e3b3 --- /dev/null +++ b/templates/fragments/rss_entry.html @@ -0,0 +1,11 @@ +

+ {% if let Some(title) = post.title %}{{ title }}{% else %}Details{% endif %} +

+{% match post.media_type.as_ref() %}{% when "image" %} + +{% when "video" %} + +{% else %}{% endmatch %}{% if let Some(description) = post.description %} +

Description

+
{{ description | markdown }}
+{% endif %} diff --git a/templates/fragments/submit_post_details.html b/templates/fragments/submit_post_details.html index 567950a..8a7d39c 100644 --- a/templates/fragments/submit_post_details.html +++ b/templates/fragments/submit_post_details.html @@ -18,10 +18,17 @@ parent_post %} {% else %} {% endif %} - +
+

Tags

+ {% if tags.is_empty() %} +

No tags in post. Consider adding some!

+ {% else %} + + {% endif %} +
diff --git a/templates/pages/posts.html b/templates/pages/posts.html index 4e448c3..2356019 100644 --- a/templates/pages/posts.html +++ b/templates/pages/posts.html @@ -9,6 +9,7 @@ {% if age_confirmation %}{% include "fragments/age_restricted_check.html" %}{% endif %} +

Search

diff --git a/templates/pages/settings.html b/templates/pages/settings.html index ff79c13..39ff1af 100644 --- a/templates/pages/settings.html +++ b/templates/pages/settings.html @@ -20,6 +20,10 @@ value="{{ application_name }}" /> +
+ + +
{% endif %} -
+

Tags

{% if tags.is_empty() %}

No tags in post. Consider adding some!

{% else %} -