Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions server/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions server/config.toml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions server/src/api/comment.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -14,7 +14,7 @@ use diesel::dsl::exists;
use diesel::prelude::*;
use serde::Deserialize;

pub fn routes() -> Router {
pub fn routes() -> Router<AppState> {
Router::new()
.route("/comments", routing::get(list).post(create))
.route("/comment/{id}", routing::get(get).put(update).delete(delete))
Expand Down
141 changes: 141 additions & 0 deletions server/src/api/embeds_api.rs
Original file line number Diff line number Diff line change
@@ -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<AppState> {
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,
Comment thread
Yochyo marked this conversation as resolved.
title: String,
author_name: Option<String>,
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<String>
Comment thread
Yochyo marked this conversation as resolved.
}

// todo check permissions

fn get_post_info(client: Client, post_id: i64, fields: &FieldTable<bool>) -> Json<PostInfo> {
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)
Comment thread
Yochyo marked this conversation as resolved.
.map(Json)
.map_err(api::Error::from)
});

a.unwrap()
}

fn get_embed(post_info: &Json<PostInfo>) -> 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(),

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default config file doesn't have a domain field, so this unwrap will panic. Consider using unwrap_or with some reasonable default instead. Better yet, you could factor out

let domain = if let Some(domain) = config::get().domain.as_deref() {
domain.to_string()
} else if let Ok(domain) = std::env::var("HTTP_ORIGIN") {
domain
} else if let Ok(domain) = std::env::var("HTTP_REFERER") {
domain
} else if let Ok(port) = std::env::var("PORT") {
format!("http://localhost:{port}")
} else {
String::new()
};
let domain = domain.trim_end_matches('/');
into it's own function and use it here.

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we only use id and thumbnail_url from post_info, querying for PostInfo is a bit overkill. We can actually get away with just the post_id because we can create a PostHash object from an ID, which has a thumbnail_url method. So maybe the role of get_post_info can be changed to just check if the post exists in the database.

This approach also has the benefit of not having to call unwrap() on post_info fields.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The oembed spec requires a width and height (section 2.3.4.1). So while it pains my heart to waste cpu cycles on querying for a PostInfo object, it's probably the best solution for now.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nevermind, forget what I said. I haven't touched the code for a bit and got confused. I will finish everything after I'm back from the office today.


async fn get_oembed(Extension(client): Extension<Client>, Query(params): Query<ResourceParams>, Query(url): Query<OEmbed>) -> ApiResult<Json<Embed>> {
let re = Regex::new(r".*?/post/(?P<post_id>\d+)").unwrap();
Comment thread
Yochyo marked this conversation as resolved.
// 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") {
Comment thread
Yochyo marked this conversation as resolved.
let fields = resource::create_table(params.fields()).map_err::<Box<dyn std::error::Error>, _>(Box::from).unwrap();
let post_info = get_post_info(client, post_id.as_str().parse::<i64>()?, &fields);
return Ok(Json(get_embed(&post_info)))
}
}

return Err(api::Error::NotFound(ResourceType::Post));
Comment thread
Yochyo marked this conversation as resolved.
}

async fn get_post(State(state): State<AppState>, Extension(client): Extension<Client>, Path(post_id): Path<i64>, Query(params): Query<ResourceParams>) -> Html<String> {
let fields = resource::create_table(params.fields()).map_err::<Box<dyn std::error::Error>, _>(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#"
<meta property="og:site_name" content="{site_name}">
<meta property="og:url" content="{url}">
<meta property="og:type" content="article">
<meta property="og:title" content="{title}">
<meta name="twitter:title" content="{title}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="{image_url}">
<meta property="og:image:url" content="{image_url}">
<meta property="og:image:width" content="{image_width}">
<meta property="og:image:height" content="{image_height}">
<meta property="article:author" content="{author}">
<link rel="alternate" type="application/json+oembed" href="{site_url}/api/oembed?url={encoded_url}" title="{site_title}">
</head>
"#,
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("</head>", &meta)
.replace("<html>", r#"<html prefix="og: http://ogp.me/ns#">"#)
.replace("<title>Loading...</title>", &format!("<title>{}</title>", &embed.title));
Html(new_html)
}
4 changes: 2 additions & 2 deletions server/src/api/info.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,7 +12,7 @@ use axum::routing::{self, Router};
use diesel::prelude::*;
use serde::Serialize;

pub fn routes() -> Router {
pub fn routes() -> Router<AppState> {
Router::new().route("/info", routing::get(get))
}

Expand Down
34 changes: 33 additions & 1 deletion server/src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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;
Comment thread
Yochyo marked this conversation as resolved.
use std::num::NonZero;
use std::ops::Deref;
use std::time::Duration;
Expand All @@ -28,6 +30,7 @@ mod tag_category;
mod upload;
mod user;
mod user_token;
mod embeds_api;

pub type ApiResult<T> = Result<T, Error>;

Expand Down Expand Up @@ -258,7 +261,32 @@ pub fn verify_valid_email(email: Option<&str>) -> Result<(), lettre::address::Ad
}
}


#[derive(Clone, Debug)]
struct AppState {
index_htm: Option<String>
}
Comment thread
Yochyo marked this conversation as resolved.

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 {
Comment thread
Yochyo marked this conversation as resolved.
index_htm: None
}
}
}
}

pub fn routes() -> Router {
let shared_state = get_app_state();

Router::new()
.merge(comment::routes())
.merge(info::routes())
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions server/src/api/password_reset.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<AppState> {
Router::new().route("/password-reset/{identifier}", routing::get(request_reset).post(reset_password))
}

Expand Down
4 changes: 2 additions & 2 deletions server/src/api/pool.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -16,7 +16,7 @@ use diesel::dsl::exists;
use diesel::prelude::*;
use serde::Deserialize;

pub fn routes() -> Router {
pub fn routes() -> Router<AppState> {
Router::new()
.route("/pools", routing::get(list))
.route("/pool", routing::post(create))
Expand Down
4 changes: 2 additions & 2 deletions server/src/api/pool_category.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,7 +13,7 @@ use axum::{Json, Router, routing};
use diesel::prelude::*;
use serde::Deserialize;

pub fn routes() -> Router {
pub fn routes() -> Router<AppState> {
Router::new()
.route("/pool-categories", routing::get(list).post(create))
.route("/pool-category/{name}", routing::get(get).put(update).delete(delete))
Expand Down
4 changes: 2 additions & 2 deletions server/src/api/post.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -25,7 +25,7 @@ use tokio::sync::Mutex as AsyncMutex;
use tracing::info;
use url::Url;

pub fn routes() -> Router {
pub fn routes() -> Router<AppState> {
Router::new()
.route("/posts", routing::get(list).post(create_handler))
.route(
Expand Down
4 changes: 2 additions & 2 deletions server/src/api/snapshot.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<AppState> {
Router::new().route("/snapshots", routing::get(list))
}

Expand Down
4 changes: 2 additions & 2 deletions server/src/api/tag.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -16,7 +16,7 @@ use diesel::dsl::count_star;
use diesel::prelude::*;
use serde::{Deserialize, Serialize};

pub fn routes() -> Router {
pub fn routes() -> Router<AppState> {
Router::new()
.route("/tags", routing::get(list).post(create))
.route("/tag/{name}", routing::get(get).put(update).delete(delete))
Expand Down
Loading