RSS for posts

This commit is contained in:
Bad Manners 2025-04-19 09:03:40 -03:00
parent bb118f6144
commit 8fac396d7e
13 changed files with 233 additions and 14 deletions

83
Cargo.lock generated
View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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,
})
}

View file

@ -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))

View file

@ -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<String>,
pub(crate) description: Option<String>,
pub(crate) uploaded_at: NaiveDateTime,
pub(crate) tags: Option<String>,
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)

View file

@ -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<AppState>,
Query(query): Query<PostsQuery>,
) -> Result<impl IntoResponse, SameyError> {
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::<Vec<_>>());
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<bool>,
}
@ -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)

View file

@ -1,6 +1,7 @@
<video
id="media"
src="/files/{{ post.media }}"
controls="controls"
:style="{ width: '100%', height: '100%', 'max-width': width + 'px', 'max-height': height + 'px', 'aspect-ratio': width + ' / ' + height }"
controls="true"
style="width: 100%; height: 100%"
:style="{ 'max-width': width + 'px', 'max-height': height + 'px', 'aspect-ratio': width + ' / ' + height }"
></video>

View file

@ -0,0 +1,11 @@
<h1>
{% if let Some(title) = post.title %}{{ title }}{% else %}Details{% endif %}
</h1>
{% match post.media_type.as_ref() %}{% when "image" %}
<img src="{{ base_url }}/files/{{ post.media }}" />
{% when "video" %}
<video src="{{ base_url }}/files/{{ post.media }}" controls="true"></video>
{% else %}{% endmatch %}{% if let Some(description) = post.description %}
<h2>Description</h2>
<div>{{ description | markdown }}</div>
{% endif %}

View file

@ -18,10 +18,17 @@ parent_post %}
{% else %}
<article id="parent-post" hx-swap-oob="outerHTML" hidden></article>
{% endif %}
<ul id="tags-list" hx-swap-oob="outerHTML">
{% for tag in tags %}
<li>
<a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
</li>
{% endfor %}
</ul>
<article id="tags-list" hx-swap-oob="outerHTML">
<h2>Tags</h2>
{% if tags.is_empty() %}
<p>No tags in post. Consider adding some!</p>
{% else %}
<ul>
{% for tag in tags %}
<li>
<a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</article>

View file

@ -9,6 +9,7 @@
{% if age_confirmation %}{% include "fragments/age_restricted_check.html"
%}{% endif %}
<div><a href="/">&lt; To home</a></div>
<div><a href="{% if let Some(tags_text) = tags_text %}/posts.xml?tags={{ tags_text.replace(' ', "+") }}{% else %}/posts.xml{% endif %}">RSS feed</a></div>
<article>
<h2>Search</h2>
<form method="get" action="/posts">

View file

@ -20,6 +20,10 @@
value="{{ application_name }}"
/>
</div>
<div>
<label>Base URL</label>
<input name="base_url" type="text" value="{{ base_url }}" />
</div>
<div>
<label>Ask for age confirmation?</label>
<input

View file

@ -92,12 +92,12 @@
</ul>
</article>
{% endif %}
<article>
<article id="tags-list">
<h2>Tags</h2>
{% if tags.is_empty() %}
<p>No tags in post. Consider adding some!</p>
{% else %}
<ul id="tags-list">
<ul>
{% for tag in tags %}
<li>
<a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>