From e299d26058857ee4b97ff0653c47a9d8a7d1d903 Mon Sep 17 00:00:00 2001 From: moehreag Date: Sun, 19 Jan 2025 00:28:31 +0100 Subject: [PATCH 1/4] implement oembed for images --- Cargo.lock | 1 + Cargo.toml | 1 + src/endpoints/image.rs | 142 ++++++++++++++++++++++++++++++++++++++++- src/main.rs | 2 + 4 files changed, 144 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 02210ca..0b94566 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,6 +155,7 @@ dependencies = [ "axum_garde", "base64", "blake2", + "bytes", "chrono", "clap", "dashmap", diff --git a/Cargo.toml b/Cargo.toml index d9b6749..0773124 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ reqwest = { version = "0.12.11", features = ["json"] } serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["net", "rt-multi-thread"] } uuid = { version = "1", features = ["serde", "v4"] } +bytes = "1.9.0" [dependencies.axum] version = "0.7" diff --git a/src/endpoints/image.rs b/src/endpoints/image.rs index 5a3cd67..1580a61 100644 --- a/src/endpoints/image.rs +++ b/src/endpoints/image.rs @@ -1,12 +1,14 @@ use axum::{ body::Bytes, - extract::{Path, State}, + extract::{Path, Query, State}, + response::Html, Json, }; use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine}; +use bytes::Buf; use chrono::{DateTime, Utc}; use reqwest::StatusCode; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sqlx::{query, PgPool}; use uuid::Uuid; @@ -60,6 +62,10 @@ pub async fn post( Path(filename): Path, body: Bytes, ) -> Result { + let png = PngInfo::create(&body).await; + if png.is_none() { + return Err(StatusCode::BAD_REQUEST)?; + } let id = Id::new(); query!( "INSERT INTO images (id, player, filename, file) VALUES ($1, $2, $3, $4)", @@ -80,3 +86,135 @@ pub async fn evict_expired(database: &PgPool) -> Result<(), TaskError> { .await?; Ok(()) } + +pub async fn get_view( + State(ApiState { database, .. }): State, + Path(id): Path, +) -> Result, ApiError> { + let image = query!("SELECT player, filename, file, timestamp FROM images WHERE id = $1", id as _) + .fetch_optional(&database) + .await? + .ok_or(StatusCode::NOT_FOUND)?; + + let filename = String::from_utf8(image.filename).unwrap(); + let base_url = "https://api.axolotlclient.com/v1/"; + let image_url = base_url.to_string() + "image/" + &id.to_string() + "/"; + + Ok(Html(format!( + r#" + + + {filename} + + + + +
+

{filename}

+
+ + + + "#, + &image_url, &image_url + ))) +} + +#[derive(Serialize)] +pub struct OEmbed { + version: &'static str, + #[serde(rename(serialize = "type"))] + _type: &'static str, + title: String, + url: String, + width: i32, + height: i32, + provider_name: &'static str, + provider_url: &'static str, +} + +impl OEmbed { + fn create(title: String, url: String, png: PngInfo) -> OEmbed { + OEmbed { + version: "1.0", + _type: "photo", + title, + url, + width: png.width, + height: png.height, + provider_name: "AxolotlClient", + provider_url: "https://axolotlclient.com", + } + } +} + +#[derive(Deserialize)] +pub struct OEmbedQuery { + format: String, +} + +pub async fn get_oembed( + State(ApiState { database, .. }): State, + Path(id): Path, + Query(OEmbedQuery { format }): Query, +) -> Result, ApiError> { + let image = query!("SELECT filename, file FROM images WHERE id = $1", id as _) + .fetch_optional(&database) + .await? + .ok_or(StatusCode::NOT_FOUND)?; + let png = PngInfo::create(&Bytes::from(image.file)).await; + + if png.is_none() { + return Err(StatusCode::BAD_REQUEST)?; + } + + let filename = String::from_utf8(image.filename).unwrap(); + + let embed = OEmbed::create( + filename, + "https://api.axolotlclient.com/v1/images/".to_owned() + &id.to_string() + "/raw", + png.unwrap(), + ); + Ok(if format == "json" { + Json(embed) + } else { + return Err(StatusCode::NOT_IMPLEMENTED)?; + }) +} + +struct PngInfo { + width: i32, + height: i32, +} + +impl PngInfo { + async fn create(reader: &Bytes) -> Option { + let mut bytes = reader.clone(); + let header = bytes.get_u64(); + if header != 0x89504E470D0A1A0A { + return None; + } + let ihdr_size = bytes.get_u32(); + if ihdr_size != 0x0D { + return None; + } + let ihdr_type = bytes.get_u32(); + if ihdr_type != 0x49484452 { + return None; + } + Some(PngInfo { + width: bytes.get_i32(), + height: bytes.get_i32(), + }) + } +} diff --git a/src/main.rs b/src/main.rs index f6ab62b..ab7780d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -136,6 +136,8 @@ async fn main() -> anyhow::Result<()> { .route("/account/relations/requests", get(account::get_requests)) .route("/image/:id", get(image::get).post(image::post)) .route("/image/:id/raw", get(image::get_raw)) + .route("/image/:id/view", get(image::get_view)) + .route("/image/:id/oembed", get(image::get_oembed)) .route("/hypixel", get(hypixel::get)) //.route("/report/:message", post(channel::report_message)) .route("/brew_coffee", get(brew_coffee).post(brew_coffee)) From 4278ae7e1f6efb20e7e8ce6013fa06f76d3c464b Mon Sep 17 00:00:00 2001 From: moehreag Date: Sun, 19 Jan 2025 00:38:18 +0100 Subject: [PATCH 2/4] update sqlx cache --- ...3638d192eb68aba5028f6241efa49fccfdf70.json | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .sqlx/query-4f447e7e7d21098ce672cdd2cfe3638d192eb68aba5028f6241efa49fccfdf70.json diff --git a/.sqlx/query-4f447e7e7d21098ce672cdd2cfe3638d192eb68aba5028f6241efa49fccfdf70.json b/.sqlx/query-4f447e7e7d21098ce672cdd2cfe3638d192eb68aba5028f6241efa49fccfdf70.json new file mode 100644 index 0000000..cad5c21 --- /dev/null +++ b/.sqlx/query-4f447e7e7d21098ce672cdd2cfe3638d192eb68aba5028f6241efa49fccfdf70.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT filename, file FROM images WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "filename", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "file", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "4f447e7e7d21098ce672cdd2cfe3638d192eb68aba5028f6241efa49fccfdf70" +} From 0d922d27cd76afd46f8df1662b885ee9dd1bb83b Mon Sep 17 00:00:00 2001 From: moehreag Date: Sun, 19 Jan 2025 23:57:19 +0100 Subject: [PATCH 3/4] improve html page --- ...f07d42f19dd3af5b46604049018c58ec9beec.json | 40 ++++ ...a3f0fc1f99b2aa5c1b1d62f8736b13d48c05c.json | 22 ++ src/endpoints/image.rs | 58 +++--- src/endpoints/image_view.html | 195 ++++++++++++++++++ 4 files changed, 283 insertions(+), 32 deletions(-) create mode 100644 .sqlx/query-4243e9da66d68fb7891b87614acf07d42f19dd3af5b46604049018c58ec9beec.json create mode 100644 .sqlx/query-ac5ff4d9b9767e3e46f4226641aa3f0fc1f99b2aa5c1b1d62f8736b13d48c05c.json create mode 100644 src/endpoints/image_view.html diff --git a/.sqlx/query-4243e9da66d68fb7891b87614acf07d42f19dd3af5b46604049018c58ec9beec.json b/.sqlx/query-4243e9da66d68fb7891b87614acf07d42f19dd3af5b46604049018c58ec9beec.json new file mode 100644 index 0000000..fcfc61c --- /dev/null +++ b/.sqlx/query-4243e9da66d68fb7891b87614acf07d42f19dd3af5b46604049018c58ec9beec.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT filename, player, timestamp, file FROM images WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "filename", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "player", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "timestamp", + "type_info": "Timestamp" + }, + { + "ordinal": 3, + "name": "file", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "4243e9da66d68fb7891b87614acf07d42f19dd3af5b46604049018c58ec9beec" +} diff --git a/.sqlx/query-ac5ff4d9b9767e3e46f4226641aa3f0fc1f99b2aa5c1b1d62f8736b13d48c05c.json b/.sqlx/query-ac5ff4d9b9767e3e46f4226641aa3f0fc1f99b2aa5c1b1d62f8736b13d48c05c.json new file mode 100644 index 0000000..bc39fb3 --- /dev/null +++ b/.sqlx/query-ac5ff4d9b9767e3e46f4226641aa3f0fc1f99b2aa5c1b1d62f8736b13d48c05c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT file FROM images WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "file", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ac5ff4d9b9767e3e46f4226641aa3f0fc1f99b2aa5c1b1d62f8736b13d48c05c" +} diff --git a/src/endpoints/image.rs b/src/endpoints/image.rs index 1580a61..c58c5d1 100644 --- a/src/endpoints/image.rs +++ b/src/endpoints/image.rs @@ -49,7 +49,7 @@ pub async fn get_raw( State(ApiState { database, .. }): State, Path(id): Path, ) -> Result, ApiError> { - let image = query!("SELECT player, filename, file, timestamp FROM images WHERE id = $1", id as _) + let image = query!("SELECT file FROM images WHERE id = $1", id as _) .fetch_optional(&database) .await? .ok_or(StatusCode::NOT_FOUND)?; @@ -91,43 +91,37 @@ pub async fn get_view( State(ApiState { database, .. }): State, Path(id): Path, ) -> Result, ApiError> { - let image = query!("SELECT player, filename, file, timestamp FROM images WHERE id = $1", id as _) + let image = query!("SELECT filename, player, timestamp, file FROM images WHERE id = $1", id as _) .fetch_optional(&database) .await? .ok_or(StatusCode::NOT_FOUND)?; let filename = String::from_utf8(image.filename).unwrap(); let base_url = "https://api.axolotlclient.com/v1/"; - let image_url = base_url.to_string() + "image/" + &id.to_string() + "/"; - - Ok(Html(format!( - r#" - - - {filename} - - - - -
-

{filename}

-
- - - - "#, - &image_url, &image_url - ))) + let image_url = base_url.to_string() + "image/" + &id.to_string(); + + let username = query!("SELECT username FROM players WHERE uuid = $1", image.player) + .fetch_one(&database) + .await? + .username; + + let time = image.timestamp.and_utc().format("%Y/%m/%d %H:%M").to_string(); + + Ok(Html( + include_str!("image_view.html") + .replace("{filename}", &filename) + .replace("{image_data}", &("data:image/png;base64,".to_string() + &STANDARD_NO_PAD.encode(image.file))) + .replace("{image_url}", &image_url) + .replace("{username}", &username) + .replace( + "{time}", + &image + .timestamp + .and_utc() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + ) + .replace("{time_formatted}", &time), + )) } #[derive(Serialize)] diff --git a/src/endpoints/image_view.html b/src/endpoints/image_view.html new file mode 100644 index 0000000..0ff07d3 --- /dev/null +++ b/src/endpoints/image_view.html @@ -0,0 +1,195 @@ + + + + {filename} | AxolotlClient + + + + + + + + + + +
+ +
+

{filename}

+

Shared by {username} at {time_formatted} (UTC)

+
+
+ Screenshot {filename} +
+ + + \ No newline at end of file From a1bfb0147fcd6590b9614df71148782335644228 Mon Sep 17 00:00:00 2001 From: moehreag Date: Mon, 20 Jan 2025 13:44:39 +0100 Subject: [PATCH 4/4] add ogp & twitter embed properties --- src/endpoints/image.rs | 4 +++- src/endpoints/image_view.html | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/endpoints/image.rs b/src/endpoints/image.rs index c58c5d1..84809d0 100644 --- a/src/endpoints/image.rs +++ b/src/endpoints/image.rs @@ -106,12 +106,14 @@ pub async fn get_view( .username; let time = image.timestamp.and_utc().format("%Y/%m/%d %H:%M").to_string(); - + let png = PngInfo::create(&Bytes::from(image.file.clone())).await.unwrap(); Ok(Html( include_str!("image_view.html") .replace("{filename}", &filename) .replace("{image_data}", &("data:image/png;base64,".to_string() + &STANDARD_NO_PAD.encode(image.file))) .replace("{image_url}", &image_url) + .replace("{image_width}", &png.width.to_string()) + .replace("{image_height}", &png.height.to_string()) .replace("{username}", &username) .replace( "{time}", diff --git a/src/endpoints/image_view.html b/src/endpoints/image_view.html index 0ff07d3..d44a559 100644 --- a/src/endpoints/image_view.html +++ b/src/endpoints/image_view.html @@ -2,7 +2,20 @@ {filename} | AxolotlClient - + + + + + + + + + + + + + +