diff --git a/server/Cargo.lock b/server/Cargo.lock index cd5e4b59b..d47192162 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1251,6 +1251,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + [[package]] name = "http" version = "1.3.1" @@ -2950,6 +2959,7 @@ dependencies = [ "flate2", "hex", "hmac", + "html-escape", "image", "lettre", "md5", @@ -3631,6 +3641,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8-width" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/server/Cargo.toml b/server/Cargo.toml index cc72df698..ec4095123 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -43,6 +43,7 @@ serde_with = "3.12.0" thiserror = "2.0.11" num-traits = "0.2.19" dotenvy = "0.15.7" +html-escape = "0.2.13" [dev-dependencies] axum-test = "17.3.0" diff --git a/server/config.toml.dist b/server/config.toml.dist index e2862fef0..fffd3debb 100644 --- a/server/config.toml.dist +++ b/server/config.toml.dist @@ -8,6 +8,8 @@ content_secret = "change" data_url = "data" data_dir = "/data" +# required to display embeds +client_dir = "/var/www" # Webhooks to call when events occur (such as post/tag/user/etc. changes) # the listed urls will be called with a HTTP POST request with a payload diff --git a/server/src/api/comment.rs b/server/src/api/comment.rs index 05d5fdba2..7c6a31293 100644 --- a/server/src/api/comment.rs +++ b/server/src/api/comment.rs @@ -1,4 +1,4 @@ -use crate::api::{ApiResult, DeleteBody, PageParams, PagedResponse, RatingBody, ResourceParams}; +use crate::api::{ApiResult, AppState, DeleteBody, PageParams, PagedResponse, RatingBody, ResourceParams}; use crate::auth::Client; use crate::model::comment::{NewComment, NewCommentScore}; use crate::model::enums::{ResourceType, Score}; @@ -14,7 +14,7 @@ use diesel::dsl::exists; use diesel::prelude::*; use serde::Deserialize; -pub fn routes() -> Router { +pub fn routes() -> Router { Router::new() .route("/comments", routing::get(list).post(create)) .route("/comment/{id}", routing::get(get).put(update).delete(delete)) diff --git a/server/src/api/embeds_api.rs b/server/src/api/embeds_api.rs new file mode 100644 index 000000000..42f115527 --- /dev/null +++ b/server/src/api/embeds_api.rs @@ -0,0 +1,141 @@ +use crate::api::{ApiResult, AppState, ResourceParams}; +use crate::auth::Client; +use crate::model::enums::ResourceType; +use crate::resource::post::{FieldTable, PostInfo}; +use crate::schema::post; +use crate::{api, config, db, resource}; +use axum::extract::{Path, Query, State}; +use axum::response::Html; +use axum::{routing, Extension, Json, Router}; +use diesel::dsl::exists; +use diesel::{Connection, QueryDsl, RunQueryDsl}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +pub fn routes() -> Router { + Router::new() + .route("/oembed", routing::get(get_oembed)) + .route("/index/post/{post_id}", routing::get(get_post)) +} + +#[skip_serializing_none] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Embed { + version: String, + #[serde(rename = "type")] + embed_type: String, + title: String, + author_name: Option, + provider_name: String, + provider_url: String, + thumbnail_url: String, + thumbnail_width: u32, + thumbnail_height: u32, + url: String, + width: u32, + height: u32, +} +#[derive(Deserialize)] +struct OEmbed { + url: Option +} + +// todo check permissions + +fn get_post_info(client: Client, post_id: i64, fields: &FieldTable) -> Json { + let a = db::get_connection().unwrap().transaction(|conn| { + let post_exists: bool = diesel::select(exists(post::table.find(post_id))).get_result(conn)?; + if !post_exists { + return Err(api::Error::NotFound(ResourceType::Post)); + } + PostInfo::new_from_id(conn, client, post_id, &fields) + .map(Json) + .map_err(api::Error::from) + }); + + a.unwrap() +} + +fn get_embed(post_info: &Json) -> Embed { + Embed { + version: "1.0".to_string(), + embed_type: "photo".to_string(), + title: format!("{} - Post #{}", config::get().public_info.name, post_info.id.unwrap()), + // todo + author_name: None, + provider_name: config::get().public_info.name.to_string(), + provider_url: config::get().domain.as_deref().unwrap().to_string(), + thumbnail_url: format!("{}/{}", config::get().domain.as_deref().unwrap(), post_info.thumbnail_url.clone().unwrap()), + thumbnail_width: config::get().thumbnails.post_width, + thumbnail_height: config::get().thumbnails.post_height, + url: format!("{}/{}", config::get().domain.as_deref().unwrap(), post_info.thumbnail_url.clone().unwrap()), + width: config::get().thumbnails.post_width, + height: config::get().thumbnails.post_height, + } +} + +async fn get_oembed(Extension(client): Extension, Query(params): Query, Query(url): Query) -> ApiResult> { + let re = Regex::new(r".*?/post/(?P\d+)").unwrap(); + // this will throw a very unhelpful error if the post_id is missing + if let Some(caps) = re.captures(&url.url.unwrap()) { + if let Some(post_id) = caps.name("post_id") { + let fields = resource::create_table(params.fields()).map_err::, _>(Box::from).unwrap(); + let post_info = get_post_info(client, post_id.as_str().parse::()?, &fields); + return Ok(Json(get_embed(&post_info))) + } + } + + return Err(api::Error::NotFound(ResourceType::Post)); +} + +async fn get_post(State(state): State, Extension(client): Extension, Path(post_id): Path, Query(params): Query) -> Html { + let fields = resource::create_table(params.fields()).map_err::, _>(Box::from).unwrap(); + let post_info = db::get_connection().unwrap().transaction(|conn| { + let post_exists: bool = diesel::select(exists(post::table.find(post_id))).get_result(conn)?; + if !post_exists { + return Err(api::Error::NotFound(ResourceType::Post)); + } + PostInfo::new_from_id(conn, client, post_id, &fields) + .map(Json) + .map_err(api::Error::from) + }).unwrap(); + + // let post_info = get_post_info(client, post_id, &fields); + let embed = get_embed(&post_info); + let url = format!("{}/post/{}", config::get().domain.as_deref().clone().unwrap(), post_id); + let meta = format!( + r#" + + + + + + + + + + + + + + "#, + site_name = html_escape::encode_text(&embed.provider_name), + url = url, + title = html_escape::encode_text(&embed.title), + image_url = html_escape::encode_text(&embed.url), + image_width = embed.width, + image_height = embed.height, + // todo + author = "", + site_url = config::get().domain.as_deref().unwrap(), + encoded_url = html_escape::encode_text(&url), + site_title = html_escape::encode_text(&config::get().public_info.name.to_string()), + ); + + let new_html = state.index_htm.unwrap().clone().replace("", &meta) + .replace("", r#""#) + .replace("Loading...", &format!("{}", &embed.title)); + Html(new_html) +} \ No newline at end of file diff --git a/server/src/api/info.rs b/server/src/api/info.rs index 4972d5561..b3b865c04 100644 --- a/server/src/api/info.rs +++ b/server/src/api/info.rs @@ -1,4 +1,4 @@ -use crate::api::{ApiResult, ResourceParams}; +use crate::api::{ApiResult, AppState, ResourceParams}; use crate::auth::Client; use crate::model::post::PostFeature; use crate::resource::post::PostInfo; @@ -12,7 +12,7 @@ use axum::routing::{self, Router}; use diesel::prelude::*; use serde::Serialize; -pub fn routes() -> Router { +pub fn routes() -> Router { Router::new().route("/info", routing::get(get)) } diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 0bf26ab3b..b3b9f5c59 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -1,5 +1,6 @@ -use crate::auth::Client; +use std::fs; use crate::auth::header::AuthenticationError; +use crate::auth::Client; use crate::config::{self, RegexType}; use crate::error::ErrorKind; use crate::model::enums::{MimeType, Rating, ResourceType, UserRank}; @@ -9,6 +10,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::{Json, Router}; use serde::{Deserialize, Deserializer, Serialize}; +use std::fs::File; use std::num::NonZero; use std::ops::Deref; use std::time::Duration; @@ -28,6 +30,7 @@ mod tag_category; mod upload; mod user; mod user_token; +mod embeds_api; pub type ApiResult = Result; @@ -258,7 +261,32 @@ pub fn verify_valid_email(email: Option<&str>) -> Result<(), lettre::address::Ad } } + +#[derive(Clone, Debug)] +struct AppState { + index_htm: Option +} + +fn get_app_state() -> AppState { + let message = fs::read_to_string(config::get().client_dir.to_string() + "/index.htm"); + match message { + Ok(v) => { + AppState { + index_htm: Some(v), + } + }, + Err(_e) => { + // todo logging + return AppState { + index_htm: None + } + } + } +} + pub fn routes() -> Router { + let shared_state = get_app_state(); + Router::new() .merge(comment::routes()) .merge(info::routes()) @@ -272,14 +300,18 @@ pub fn routes() -> Router { .merge(upload::routes()) .merge(user_token::routes()) .merge(user::routes()) + .merge(embeds_api::routes()) .layer(( TraceLayer::new_for_http(), // Graceful shutdown will wait for outstanding requests to complete. // Add a timeout so requests don't hang forever. TimeoutLayer::new(Duration::from_secs(60)), )) + .with_state(shared_state) .route_layer(axum::middleware::from_fn(middleware::auth)) .route_layer(axum::middleware::from_fn(middleware::post_to_webhooks)) + + } /// Represents body of a request to apply/change a score. diff --git a/server/src/api/password_reset.rs b/server/src/api/password_reset.rs index 8e4ee8cb6..e55752105 100644 --- a/server/src/api/password_reset.rs +++ b/server/src/api/password_reset.rs @@ -1,4 +1,4 @@ -use crate::api::ApiResult; +use crate::api::{ApiResult, AppState}; use crate::auth::password; use crate::content::hash; use crate::schema::user; @@ -18,7 +18,7 @@ use percent_encoding::NON_ALPHANUMERIC; use serde::{Deserialize, Serialize}; use std::str::FromStr; -pub fn routes() -> Router { +pub fn routes() -> Router { Router::new().route("/password-reset/{identifier}", routing::get(request_reset).post(reset_password)) } diff --git a/server/src/api/pool.rs b/server/src/api/pool.rs index 3a97855cd..506d70d53 100644 --- a/server/src/api/pool.rs +++ b/server/src/api/pool.rs @@ -1,4 +1,4 @@ -use crate::api::{ApiResult, DeleteBody, MergeBody, PageParams, PagedResponse, ResourceParams}; +use crate::api::{ApiResult, AppState, DeleteBody, MergeBody, PageParams, PagedResponse, ResourceParams}; use crate::auth::Client; use crate::model::enums::ResourceType; use crate::model::pool::{NewPool, Pool}; @@ -16,7 +16,7 @@ use diesel::dsl::exists; use diesel::prelude::*; use serde::Deserialize; -pub fn routes() -> Router { +pub fn routes() -> Router { Router::new() .route("/pools", routing::get(list)) .route("/pool", routing::post(create)) diff --git a/server/src/api/pool_category.rs b/server/src/api/pool_category.rs index f199e27be..0d596cd5e 100644 --- a/server/src/api/pool_category.rs +++ b/server/src/api/pool_category.rs @@ -1,4 +1,4 @@ -use crate::api::{ApiResult, DeleteBody, ResourceParams, UnpagedResponse}; +use crate::api::{ApiResult, AppState, DeleteBody, ResourceParams, UnpagedResponse}; use crate::auth::Client; use crate::config::RegexType; use crate::model::enums::ResourceType; @@ -13,7 +13,7 @@ use axum::{Json, Router, routing}; use diesel::prelude::*; use serde::Deserialize; -pub fn routes() -> Router { +pub fn routes() -> Router { Router::new() .route("/pool-categories", routing::get(list).post(create)) .route("/pool-category/{name}", routing::get(get).put(update).delete(delete)) diff --git a/server/src/api/post.rs b/server/src/api/post.rs index 9851e8e75..9275e12f8 100644 --- a/server/src/api/post.rs +++ b/server/src/api/post.rs @@ -1,4 +1,4 @@ -use crate::api::{ApiResult, DeleteBody, MergeBody, PageParams, PagedResponse, RatingBody, ResourceParams}; +use crate::api::{ApiResult, AppState, DeleteBody, MergeBody, PageParams, PagedResponse, RatingBody, ResourceParams}; use crate::auth::Client; use crate::content::hash::PostHash; use crate::content::thumbnail::{ThumbnailCategory, ThumbnailType}; @@ -25,7 +25,7 @@ use tokio::sync::Mutex as AsyncMutex; use tracing::info; use url::Url; -pub fn routes() -> Router { +pub fn routes() -> Router { Router::new() .route("/posts", routing::get(list).post(create_handler)) .route( diff --git a/server/src/api/snapshot.rs b/server/src/api/snapshot.rs index 58a226cb6..719dd6983 100644 --- a/server/src/api/snapshot.rs +++ b/server/src/api/snapshot.rs @@ -1,4 +1,4 @@ -use crate::api::{ApiResult, PageParams, PagedResponse}; +use crate::api::{ApiResult, AppState, PageParams, PagedResponse}; use crate::auth::Client; use crate::resource::snapshot::SnapshotInfo; use crate::search::Builder; @@ -8,7 +8,7 @@ use axum::extract::Query; use axum::{Extension, Json, Router, routing}; use diesel::prelude::*; -pub fn routes() -> Router { +pub fn routes() -> Router { Router::new().route("/snapshots", routing::get(list)) } diff --git a/server/src/api/tag.rs b/server/src/api/tag.rs index a776617d3..8cd24be3b 100644 --- a/server/src/api/tag.rs +++ b/server/src/api/tag.rs @@ -1,4 +1,4 @@ -use crate::api::{ApiResult, DeleteBody, MergeBody, PageParams, PagedResponse, ResourceParams}; +use crate::api::{ApiResult, AppState, DeleteBody, MergeBody, PageParams, PagedResponse, ResourceParams}; use crate::auth::Client; use crate::model::enums::ResourceType; use crate::model::tag::{NewTag, Tag}; @@ -16,7 +16,7 @@ use diesel::dsl::count_star; use diesel::prelude::*; use serde::{Deserialize, Serialize}; -pub fn routes() -> Router { +pub fn routes() -> Router { Router::new() .route("/tags", routing::get(list).post(create)) .route("/tag/{name}", routing::get(get).put(update).delete(delete)) diff --git a/server/src/api/tag_category.rs b/server/src/api/tag_category.rs index c4c249548..4ec32b655 100644 --- a/server/src/api/tag_category.rs +++ b/server/src/api/tag_category.rs @@ -1,4 +1,4 @@ -use crate::api::{ApiResult, DeleteBody, ResourceParams, UnpagedResponse}; +use crate::api::{ApiResult, AppState, DeleteBody, ResourceParams, UnpagedResponse}; use crate::auth::Client; use crate::config::RegexType; use crate::model::enums::ResourceType; @@ -13,7 +13,7 @@ use axum::{Json, Router, routing}; use diesel::prelude::*; use serde::Deserialize; -pub fn routes() -> Router { +pub fn routes() -> Router { Router::new() .route("/tag-categories", routing::get(list).post(create)) .route("/tag-category/{name}", routing::get(get).put(update).delete(delete)) diff --git a/server/src/api/upload.rs b/server/src/api/upload.rs index cf8c50716..7fff368ba 100644 --- a/server/src/api/upload.rs +++ b/server/src/api/upload.rs @@ -1,4 +1,4 @@ -use crate::api::ApiResult; +use crate::api::{ApiResult, AppState}; use crate::auth::Client; use crate::content::upload::{self, MAX_UPLOAD_SIZE, PartName}; use crate::content::{JsonOrMultipart, download}; @@ -8,7 +8,7 @@ use axum::{Json, Router, routing}; use serde::{Deserialize, Serialize}; use url::Url; -pub fn routes() -> Router { +pub fn routes() -> Router { Router::new() .route("/uploads", routing::post(upload_handler)) .route_layer(DefaultBodyLimit::max(MAX_UPLOAD_SIZE)) diff --git a/server/src/api/user.rs b/server/src/api/user.rs index 2181b55ff..29c6d23e0 100644 --- a/server/src/api/user.rs +++ b/server/src/api/user.rs @@ -1,4 +1,4 @@ -use crate::api::{ApiResult, DeleteBody, PageParams, PagedResponse, ResourceParams}; +use crate::api::{ApiResult, AppState, DeleteBody, PageParams, PagedResponse, ResourceParams}; use crate::auth::Client; use crate::auth::password; use crate::config::RegexType; @@ -22,7 +22,7 @@ use diesel::prelude::*; use serde::Deserialize; use url::Url; -pub fn routes() -> Router { +pub fn routes() -> Router { Router::new() .route("/users", routing::get(list).post(create_handler)) .route( diff --git a/server/src/api/user_token.rs b/server/src/api/user_token.rs index 7701fc4a0..4efc0642c 100644 --- a/server/src/api/user_token.rs +++ b/server/src/api/user_token.rs @@ -1,4 +1,4 @@ -use crate::api::{ApiResult, ResourceParams, UnpagedResponse}; +use crate::api::{ApiResult, AppState, ResourceParams, UnpagedResponse}; use crate::auth::Client; use crate::model::enums::AvatarStyle; use crate::model::user::{NewUserToken, UserToken}; @@ -14,7 +14,7 @@ use diesel::prelude::*; use serde::Deserialize; use uuid::Uuid; -pub fn routes() -> Router { +pub fn routes() -> Router { Router::new() .route("/user-tokens/{username}", routing::get(list)) .route("/user-token/{username}", routing::post(create)) diff --git a/server/src/config.rs b/server/src/config.rs index fb3d10be5..cb276353d 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -183,6 +183,7 @@ pub struct PublicInfo { pub struct Config { data_dir: SmallString, pub data_url: SmallString, + pub client_dir: SmallString, #[serde(default)] pub webhooks: Vec, pub password_secret: SmallString, diff --git a/server/src/resource/post.rs b/server/src/resource/post.rs index 2ad081117..d8ca1c1f1 100644 --- a/server/src/resource/post.rs +++ b/server/src/resource/post.rs @@ -118,42 +118,42 @@ impl BoolFill for FieldTable { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct PostInfo { - version: Option, - id: Option, - user: Option>, - file_size: Option, - canvas_width: Option, - canvas_height: Option, - safety: Option, - type_: Option, - mime_type: Option, - checksum: Option, + pub version: Option, + pub id: Option, + pub user: Option>, + pub file_size: Option, + pub canvas_width: Option, + pub canvas_height: Option, + pub safety: Option, + pub type_: Option, + pub mime_type: Option, + pub checksum: Option, #[serde(rename = "checksumMD5")] - checksum_md5: Option, - flags: Option, - source: Option, - description: Option, - creation_time: Option, - last_edit_time: Option, - content_url: Option, - thumbnail_url: Option, - tags: Option>, - comments: Option>, - relations: Option>, - pools: Option>, - notes: Option>, - score: Option, - own_score: Option, - own_favorite: Option, - tag_count: Option, - comment_count: Option, - relation_count: Option, - note_count: Option, - favorite_count: Option, - feature_count: Option, - last_feature_time: Option>, - favorited_by: Option>, - has_custom_thumbnail: Option, + pub checksum_md5: Option, + pub flags: Option, + pub source: Option, + pub description: Option, + pub creation_time: Option, + pub last_edit_time: Option, + pub content_url: Option, + pub thumbnail_url: Option, + pub tags: Option>, + pub comments: Option>, + pub relations: Option>, + pub pools: Option>, + pub notes: Option>, + pub score: Option, + pub own_score: Option, + pub own_favorite: Option, + pub tag_count: Option, + pub comment_count: Option, + pub relation_count: Option, + pub note_count: Option, + pub favorite_count: Option, + pub feature_count: Option, + pub last_feature_time: Option>, + pub favorited_by: Option>, + pub has_custom_thumbnail: Option, } impl PostInfo {