diff --git a/Cargo.lock b/Cargo.lock index cb780278..d1690350 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -474,6 +474,19 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atom_syndication" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "571832dcff775e26562e8e6930cd483de5587301d40d3a3b85d532b6383e15a7" +dependencies = [ + "chrono", + "derive_builder", + "diligent-date-parser", + "never", + "quick-xml 0.30.0", +] + [[package]] name = "attohttpc" version = "0.22.0" @@ -1335,6 +1348,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "diligent-date-parser" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6cf7fe294274a222363f84bcb63cdea762979a0443b4cf1f4f8fd17c86b1182" +dependencies = [ + "chrono", +] + [[package]] name = "dirs" version = "4.0.0" @@ -2236,6 +2258,7 @@ dependencies = [ "actix-ws", "argon2", "async-trait", + "atom_syndication", "base64 0.21.2", "bitflags 1.3.2", "bytes", @@ -2653,6 +2676,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "never" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" + [[package]] name = "nix" version = "0.15.0" @@ -3192,6 +3221,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quote" version = "1.0.29" diff --git a/Cargo.toml b/Cargo.toml index 755adc38..00f36f5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,4 +91,6 @@ color-thief = "0.2.2" woothee = "0.13.0" -lettre = "0.10.4" \ No newline at end of file +lettre = "0.10.4" + +atom_syndication = "0.12.2" diff --git a/src/routes/updates.rs b/src/routes/updates.rs index d3674f35..9adb4842 100644 --- a/src/routes/updates.rs +++ b/src/routes/updates.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use actix_web::{get, web, HttpRequest, HttpResponse}; +use atom_syndication::{Category, Content, Entry, Feed, Generator, Link, Person, Text}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -8,12 +9,14 @@ use crate::auth::{filter_authorized_versions, get_user_from_headers, is_authoriz use crate::database; use crate::models::pats::Scopes; use crate::models::projects::VersionType; +use crate::models::teams::TeamMember; use crate::queue::session::AuthQueue; use super::ApiError; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(forge_updates); + cfg.service(atom_feed); } #[derive(Serialize, Deserialize)] @@ -113,3 +116,212 @@ pub async fn forge_updates( Ok(HttpResponse::Ok().json(response)) } + +#[get("{id}/feed.atom")] +pub async fn atom_feed( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let (id,) = info.into_inner(); + + let Some(project) = database::models::Project::get(&id, &**pool, &redis).await? else { + return Ok(HttpResponse::NotFound().body("")); + }; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if !is_authorized(&project.inner, &user_option, &pool).await? { + return Ok(HttpResponse::NotFound().body("")); + } + + let versions = database::models::Version::get_many(&project.versions, &**pool, &redis).await?; + + let mut versions = filter_authorized_versions(versions, &user_option, &pool).await?; + + versions.sort_by(|a, b| b.date_published.cmp(&a.date_published)); + + let members_data = { + let members_data = database::models::TeamMember::get_from_team_full( + project.inner.team_id, + &**pool, + &redis, + ) + .await?; + + let users = crate::database::models::User::get_many_ids( + &members_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + + members_data + .into_iter() + .flat_map(|x| { + users + .iter() + .find(|y| y.id == x.user_id) + .map(|y| TeamMember::from(x, y.clone(), true)) + }) + .collect::>() + }; + + fn team_member_to_person(member: &TeamMember) -> Person { + Person { + name: member + .user + .name + .as_ref() + .unwrap_or(&member.user.username) + .clone(), + email: None, + uri: Some(format!( + "{}/user/{}", + dotenvy::var("SITE_URL").unwrap_or_default(), + member.user.username + )), + } + } + + fn tag_to_category(tag: &str) -> Category { + Category { + term: tag.to_string(), + scheme: None, + label: Some(tag.to_string()), + } + } + + let project_id = crate::models::ids::ProjectId::from(project.inner.id); + let project_link = format!( + "{}/{}/{}", + dotenvy::var("SITE_URL").unwrap_or_default(), + project.project_type, + project_id + ); + + let feed = Feed { + title: Text::plain(project.inner.title.clone()), + id: project_link.clone(), + updated: project.inner.updated.into(), + authors: members_data + .iter() + .filter(|x| x.role == crate::models::teams::OWNER_ROLE) + .map(team_member_to_person) + .collect::>(), + categories: project + .categories + .iter() + .chain(project.additional_categories.iter()) + .map(|x| x.as_str()) + .map(tag_to_category) + .collect::>(), + contributors: members_data + .iter() + .filter(|x| x.role != crate::models::teams::OWNER_ROLE) + .map(team_member_to_person) + .collect::>(), + generator: Some(Generator { + value: "labrinth".to_string(), + uri: Some("https://github.com/modrinth/labrinth".to_string()), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + }), + icon: project.inner.icon_url.clone(), + links: vec![ + Link { + href: project_link, + rel: "alternate".to_string(), + hreflang: None, + mime_type: Some("text/html".to_string()), + title: None, + length: None, + }, + Link { + href: format!( + "{}/updates/{}/feed.atom", + dotenvy::var("SELF_ADDR").unwrap_or_default(), + project_id + ), + rel: "self".to_string(), + hreflang: None, + mime_type: Some("application/atom+xml".to_string()), + title: None, + length: None, + }, + ], + logo: None, + rights: None, + subtitle: Some(Text::plain(project.inner.description.clone())), + entries: versions + .iter() + .map(|v| { + let link = format!( + "{}/{}/{}/version/{}", + dotenvy::var("SITE_URL").unwrap_or_default(), + project.project_type, + project_id, + v.id + ); + + Entry { + title: Text::plain(v.name.clone()), + id: link.clone(), + updated: v.date_published.into(), + authors: members_data + .iter() + .find(|x| x.user.id == v.author_id) + .map(team_member_to_person) + .into_iter() + .collect::>(), + categories: v + .loaders + .iter() + .map(|x| x.0.as_str()) + .chain(v.game_versions.iter().map(|x| x.0.as_str())) + .map(tag_to_category) + .collect::>(), + contributors: vec![], + links: vec![Link { + href: link.clone(), + rel: "alternate".to_string(), + hreflang: None, + mime_type: Some("text/html".to_string()), + title: None, + length: None, + }], + published: Some(v.date_published.into()), + rights: None, + source: None, + summary: None, + content: Some(Content { + base: None, + lang: Some("en-us".to_string()), + value: None, + src: Some(link), + content_type: Some("text/html".to_string()), + }), + extensions: Default::default(), + } + }) + .collect::>(), + extensions: Default::default(), + namespaces: Default::default(), + base: None, + lang: Some("en-US".to_string()), + }; + + Ok(HttpResponse::Ok() + .content_type("application/atom+xml") + .body(feed.to_string())) +}