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-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" +} 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/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..84809d0 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; @@ -47,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)?; @@ -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,131 @@ 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 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(); + + 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(); + 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}", + &image + .timestamp + .and_utc() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + ) + .replace("{time_formatted}", &time), + )) +} + +#[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/endpoints/image_view.html b/src/endpoints/image_view.html new file mode 100644 index 0000000..d44a559 --- /dev/null +++ b/src/endpoints/image_view.html @@ -0,0 +1,208 @@ + + + + {filename} | AxolotlClient + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+

{filename}

+

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

+
+
+ Screenshot {filename} +
+ + + \ No newline at end of file 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))