diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a4a65..3404cd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,3 @@ -- Initial Matrix implementation - - Allows bots to know, when a new meme was uploaded - - First step towards decentralization \ No newline at end of file +- Refactoring +- Initial V2 API +- Added Dockerfilegit \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..582a63c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM rust:buster as builder + +RUN apt update && apt install -y libssl-dev + +WORKDIR /usr/src/jmserver + +COPY Cargo.toml ./ +COPY src/ src/ +COPY templates/ templates/ + +RUN cargo build --release + +FROM debian:buster + +COPY --from=builder /usr/src/jmserver/target/release/jmserver /usr/bin + +RUN apt update && apt install -y libssl1.1 dumb-init + +VOLUME ["/data"] + +ENTRYPOINT ["/usr/bin/dumb-init", "--", "/usr/bin/jmserver", "--config", "/data/config.toml"] \ No newline at end of file diff --git a/spec/v2.json b/spec/v2.json new file mode 100644 index 0000000..887adb2 --- /dev/null +++ b/spec/v2.json @@ -0,0 +1,573 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "2.0.0", + "title": "JensMemes" + }, + "servers": [ + { + "url": "https://api.tilera.xyz/jensmemes/v2" + } + ], + "paths": { + "/memes": { + "get": { + "summary": "List all memes on JensMemes", + "parameters": [ + { + "name": "category", + "in": "query", + "description": "Filter category of the memes", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "user", + "in": "query", + "description": "Filter user of the memes", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "description": "Search for memes", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "How many memes should be returned at maximum", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "after", + "in": "query", + "description": "ID of the meme after which the returned memes should start", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Meme list response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Meme" + } + } + } + } + }, + "default": { + "description": "Some error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "post": { + "summary": "Upload an image or video to JensMemes", + "security": [ + { + "discord": [] + }, + { + "token": [] + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "The ID of the category of the meme" + }, + "file": { + "oneOf": [ + { + "type": "string", + "format": "binary" + }, + { + "type": "array", + "items": { + "type": "string", + "format": "binary" + } + } + ], + "description": "The file or files to upload to JensMemes" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Response of the upload", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Meme" + } + } + } + } + }, + "default": { + "description": "Some error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/memes/{id}": { + "get": { + "summary": "Gives a specific meme by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The ID of the meme", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Meme response of this meme", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Meme" + } + } + } + }, + "default": { + "description": "Some error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/memes/random": { + "get": { + "summary": "Gives a random meme", + "parameters": [ + { + "name": "category", + "in": "query", + "description": "Only give a random meme from this category ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "user", + "in": "query", + "description": "Only give a random meme from this user", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Meme response of a random meme", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Meme" + } + } + } + }, + "default": { + "description": "Some error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/memes/stream": { + "get": { + "summary": "Returns a stream of new uploaded memes", + "responses": { + "200": { + "description": "Stream of memes", + "content": { + "application/x-json-stream": { + "schema": { + "$ref": "#/components/schemas/Meme" + } + } + } + }, + "default": { + "description": "Some error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/categories": { + "get": { + "summary": "Get all categories available on JensMemes", + "responses": { + "200": { + "description": "List of all categories on JensMemes", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Category" + } + } + } + } + }, + "default": { + "description": "Some error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/categories/{id}": { + "get": { + "summary": "Get a specific category by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the category", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The requested category", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Category" + } + } + } + }, + "default": { + "description": "Some error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/users": { + "get": { + "summary": "Get all users registered on JensMemes", + "responses": { + "200": { + "description": "All users on JensMemes", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "default": { + "description": "Some error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/users/{id}": { + "get": { + "summary": "Get a specific user on JensMemes", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The ID of the user", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The requested user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "default": { + "description": "Some error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/clips": { + "get": { + "summary": "WIP", + "parameters": [ + { + "name": "streamer", + "in": "query", + "description": "Twitch username of the streamer", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Clip" + } + } + } + } + } + } + }, + "post": { + "summary": "WIP", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "link": { + "type": "string" + } + } + } + } + } + }, + "security": [ + { + "discord": [] + }, + { + "token": [] + } + ], + "responses": { + "201": { + "description": "Uploaded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Clip" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Meme": { + "type": "object", + "properties": { + "link": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "category": { + "type": "string" + }, + "user": { + "type": "string" + }, + "timestamp": { + "type": "integer" + } + } + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "dayuploads": { + "type": "integer" + } + } + }, + "Clip": { + "type": "object", + "properties": { + "link": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "streamer": { + "type": "string" + }, + "user": { + "type": "string" + }, + "timestamp": { + "type": "integer" + } + } + }, + "ErrorResponse": { + "type": "object", + "required": [ + "status", + "error" + ], + "properties": { + "status": { + "type": "integer", + "minimum": 200, + "maximum": 500 + }, + "error": { + "type": "string" + } + } + } + }, + "securitySchemes": { + "discord": { + "type": "apiKey", + "in": "header", + "name": "Authorization", + "description": "A Discord OAuth Token, prefix with 'Discord '" + }, + "token": { + "type": "apiKey", + "in": "header", + "name": "Authorization", + "description": "A JWT Token from the bot, prefix with 'Token '" + } + } + } + } \ No newline at end of file diff --git a/src/cdn/mod.rs b/src/cdn/mod.rs index 9880a3c..3654d2f 100644 --- a/src/cdn/mod.rs +++ b/src/cdn/mod.rs @@ -12,7 +12,6 @@ use reqwest::{ header::{HeaderName, CONTENT_LENGTH}, StatusCode, }; -use sqlx::MySqlPool; use crate::JMService; @@ -35,12 +34,11 @@ pub fn routes() -> Router { async fn image( Path((user, filename)): Path<(String, String)>, - Extension(db_pool): Extension, Extension(service): Extension, ) -> Result { let filename = urlencoding::decode(&filename)?.into_owned(); - let cid = sql::get_cid(user, filename.clone(), &db_pool).await?; - let res = service.cat(cid).await?; + let cid = sql::get_cid(user, filename.clone(), &service.db_pool).await?; + let res = service.ipfs_cat(cid).await?; let clength = res .headers() .get(HeaderName::from_static("x-content-length")) @@ -58,11 +56,8 @@ async fn image( )) } -async fn users( - Extension(db_pool): Extension, - Extension(service): Extension, -) -> Result { - let users = sql::get_users(&db_pool).await?; +async fn users(Extension(service): Extension) -> Result { + let users = sql::get_users(&service.db_pool).await?; Ok(HtmlTemplate(DirTemplate { entries: users, prefix: service.cdn_url(), @@ -72,9 +67,9 @@ async fn users( async fn memes( Path(user): Path, - Extension(db_pool): Extension, + Extension(service): Extension, ) -> Result { - let memes = sql::get_memes(user, &db_pool).await?; + let memes = sql::get_memes(user, &service.db_pool).await?; Ok(HtmlTemplate(DirTemplate { entries: memes, prefix: ".".to_string(), diff --git a/src/config.rs b/src/config.rs index 1d2850f..7f456c6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ use reqwest::Url; use serde::Deserialize; +use sqlx::MySqlPool; use std::{net::SocketAddr, sync::Arc}; use crate::{error::JMError, JMService, JMServiceInner}; @@ -16,10 +17,11 @@ pub struct Config { } impl Config { - pub fn service(&self) -> Result { + pub fn service(&self, db_pool: MySqlPool) -> Result { let client = reqwest::ClientBuilder::new().user_agent("curl").build()?; Ok(Arc::new(JMServiceInner { client, + db_pool, ipfs_url: self.ipfs_api.clone(), cdn_url: self.cdn.clone(), matrix_url: self.matrix_url.clone(), diff --git a/src/error.rs b/src/error.rs index aad6309..591c4cc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,6 @@ +use std::string::FromUtf8Error; + +use axum::extract::{multipart::MultipartError, rejection::QueryRejection}; use hyper::StatusCode; use thiserror::Error; use url::ParseError; @@ -25,3 +28,27 @@ pub enum ServiceError { #[error("Invalid response code: {0}")] InvalidResponse(StatusCode), } + +#[derive(Error, Debug)] +pub enum APIError { + #[error("SQL error: {0}")] + Sql(#[from] sqlx::Error), + #[error("Multipart form error: {0}")] + Multipart(#[from] MultipartError), + #[error("{0}")] + BadRequest(String), + #[error("{0}")] + Unauthorized(String), + #[error("{0}")] + Forbidden(String), + #[error("{0}")] + NotFound(String), + #[error("{0}")] + Internal(String), + #[error("JMService error: {0}")] + Service(#[from] ServiceError), + #[error("Query rejection: {0}")] + Query(#[from] QueryRejection), + #[error("Decode error: {0}")] + Decode(#[from] FromUtf8Error), +} diff --git a/src/ipfs/mod.rs b/src/ipfs/mod.rs index 7edcc85..f0e06ab 100644 --- a/src/ipfs/mod.rs +++ b/src/ipfs/mod.rs @@ -35,7 +35,7 @@ pub struct PinQuery { } impl JMServiceInner { - pub async fn cat(&self, cid: String) -> Result { + pub async fn ipfs_cat(&self, cid: String) -> Result { let request = self .client .post(self.ipfs_url.join("/api/v0/cat")?) @@ -43,7 +43,7 @@ impl JMServiceInner { Ok(request.send().await?) } - pub async fn add(&self, file: Bytes, filename: String) -> Result { + pub async fn ipfs_add(&self, file: Bytes, filename: String) -> Result { let request = self .client .post(self.ipfs_url.join("/api/v0/add")?) @@ -54,7 +54,7 @@ impl JMServiceInner { Ok(res) } - pub async fn pin(&self, cid: String) -> Result<(), ServiceError> { + pub async fn ipfs_pin(&self, cid: String) -> Result<(), ServiceError> { let request = self .client .post(self.ipfs_url.join("/api/v0/pin/add")?) diff --git a/src/main.rs b/src/main.rs index 3d04dc8..bc82828 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod matrix; mod models; mod sql; mod v1; +mod v2; #[derive(StructOpt)] struct Opt { @@ -34,6 +35,7 @@ struct Opt { pub struct JMServiceInner { client: Client, + db_pool: MySqlPool, ipfs_url: Url, cdn_url: String, matrix_url: Url, @@ -50,12 +52,12 @@ async fn main() -> Result<(), JMError> { let config = toml::from_slice::(&config)?; let db_pool = MySqlPool::new(&config.database).await?; - let service = config.service()?; + let service = config.service(db_pool)?; let app = Router::new() .nest("/api/v1", v1::routes()) + .nest("/api/v2", v2::routes()) .nest("/cdn", cdn::routes()) - .layer(AddExtensionLayer::new(db_pool)) .layer(AddExtensionLayer::new(service)) .layer(SetResponseHeaderLayer::<_, Request>::if_not_present( header::ACCESS_CONTROL_ALLOW_ORIGIN, diff --git a/src/models.rs b/src/models.rs index da63347..aca533d 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,4 +1,4 @@ -use serde::{Deserialize, Serialize}; +use serde::Serialize; #[derive(Serialize)] pub struct Meme { @@ -33,9 +33,24 @@ pub enum UserIdentifier { Null, } -#[derive(Deserialize)] -pub struct MemeFilter { +pub struct MemeOptions { pub category: Option, - pub user: Option, + pub user_id: Option, + pub username: Option, pub search: Option, + pub limit: Option, + pub after: Option, +} + +impl MemeOptions { + pub fn empty() -> Self { + Self { + category: None, + user_id: None, + username: None, + search: None, + limit: None, + after: None, + } + } } diff --git a/src/sql.rs b/src/sql.rs index f5a90fa..0b50af4 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -1,85 +1,10 @@ use crate::ipfs::IPFSFile; -use crate::models::{Category, Meme, MemeFilter, User, UserIdentifier}; +use crate::models::{Category, Meme, MemeOptions, User, UserIdentifier}; +use crate::JMServiceInner; use sqlx::mysql::MySqlRow; use sqlx::{MySqlPool, Result, Row}; -impl Meme { - pub async fn get(id: i32, pool: &MySqlPool) -> Result> { - let q: Option = sqlx::query("SELECT memes.id, user, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.user = users.id AND memes.id=?").bind(id) - .map(|row: MySqlRow| Self { - id: row.get("id"), - filename: row.get("filename"), - username: row.get("name"), - userid: row.get("user"), - category: row.get("category"), - timestamp: row.get("ts"), - ipfs: row.get("cid"), - }) - .fetch_optional(pool).await?; - Ok(q) - } - - pub async fn get_all(filter: MemeFilter, pool: &MySqlPool) -> Result> { - let q: Vec = sqlx::query("SELECT memes.id, user, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.user = users.id AND (category LIKE ? AND name LIKE ? AND filename LIKE ?) ORDER BY memes.id") - .bind(filter.category.unwrap_or_else(|| String::from("%"))) - .bind(format!("%{}%", filter.user.unwrap_or_else(String::new))) - .bind(format!("%{}%", filter.search.unwrap_or_else(String::new))) - .map(|row: MySqlRow| Self { - id: row.get("id"), - filename: row.get("filename"), - username: row.get("name"), - userid: row.get("user"), - category: row.get("category"), - timestamp: row.get("ts"), - ipfs: row.get("cid"), - }) - .fetch_all(pool).await?; - Ok(q) - } - - pub async fn get_random(filter: MemeFilter, pool: &MySqlPool) -> Result { - let q: Self = sqlx::query("SELECT memes.id, user, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.user = users.id AND (category LIKE ? AND name LIKE ? AND filename LIKE ?) ORDER BY RAND() LIMIT 1") - .bind(filter.category.unwrap_or_else(|| String::from("%"))) - .bind(format!("%{}%", filter.user.unwrap_or_else(String::new))) - .bind(format!("%{}%", filter.search.unwrap_or_else(String::new))) - .map(|row: MySqlRow| Self { - id: row.get("id"), - filename: row.get("filename"), - username: row.get("name"), - userid: row.get("user"), - category: row.get("category"), - timestamp: row.get("ts"), - ipfs: row.get("cid"), - }) - .fetch_one(pool).await?; - Ok(q) - } -} - impl Category { - pub async fn get(id: &String, pool: &MySqlPool) -> Result> { - let q: Option = sqlx::query("SELECT * FROM categories WHERE id=?") - .bind(id) - .map(|row: MySqlRow| Self { - id: row.get("id"), - name: row.get("name"), - }) - .fetch_optional(pool) - .await?; - Ok(q) - } - - pub async fn get_all(pool: &MySqlPool) -> Result> { - let q: Vec = sqlx::query("SELECT * FROM categories ORDER BY num") - .map(|row: MySqlRow| Self { - id: row.get("id"), - name: row.get("name"), - }) - .fetch_all(pool) - .await?; - Ok(q) - } - pub async fn add_meme( &self, user: &User, @@ -104,42 +29,160 @@ impl Category { } } -impl User { - pub async fn get(identifier: UserIdentifier, pool: &MySqlPool) -> Result> { +impl JMServiceInner { + pub async fn get_meme(&self, id: i32) -> Result> { + let q: Option = sqlx::query("SELECT memes.id, user, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.user = users.id AND memes.id=?").bind(id) + .map(|row: MySqlRow| Meme { + id: row.get("id"), + filename: row.get("filename"), + username: row.get("name"), + userid: row.get("user"), + category: row.get("category"), + timestamp: row.get("ts"), + ipfs: row.get("cid"), + }) + .fetch_optional(&self.db_pool).await?; + Ok(q) + } + + pub async fn get_memes(&self, filter: MemeOptions) -> Result> { + let q: Vec = sqlx::query("SELECT memes.id, user, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.user = users.id AND (category LIKE ? AND name LIKE ? AND filename LIKE ? AND memes.user LIKE ? AND memes.id > ?) ORDER BY memes.id") + .bind(filter.category.unwrap_or_else(|| String::from("%"))) + .bind(format!("%{}%", filter.username.unwrap_or_else(String::new))) + .bind(format!("%{}%", filter.search.unwrap_or_else(String::new))) + .bind(filter.user_id.unwrap_or_else(|| String::from("%"))) + .bind(filter.after.unwrap_or(0)) + .map(|row: MySqlRow| Meme { + id: row.get("id"), + filename: row.get("filename"), + username: row.get("name"), + userid: row.get("user"), + category: row.get("category"), + timestamp: row.get("ts"), + ipfs: row.get("cid"), + }) + .fetch_all(&self.db_pool).await?; + Ok(q) + } + + pub async fn get_random_meme(&self, filter: MemeOptions) -> Result { + let q: Meme = sqlx::query("SELECT memes.id, user, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.user = users.id AND (category LIKE ? AND name LIKE ? AND filename LIKE ? AND memes.user LIKE ? AND memes.id > ?) ORDER BY RAND() LIMIT 1") + .bind(filter.category.unwrap_or_else(|| String::from("%"))) + .bind(format!("%{}%", filter.username.unwrap_or_else(String::new))) + .bind(format!("%{}%", filter.search.unwrap_or_else(String::new))) + .bind(filter.user_id.unwrap_or_else(|| String::from("%"))) + .bind(filter.after.unwrap_or(0)) + .map(|row: MySqlRow| Meme { + id: row.get("id"), + filename: row.get("filename"), + username: row.get("name"), + userid: row.get("user"), + category: row.get("category"), + timestamp: row.get("ts"), + ipfs: row.get("cid"), + }) + .fetch_one(&self.db_pool).await?; + Ok(q) + } + + pub async fn get_user_meme(&self, user_id: String, filename: String) -> Result> { + let q: Option = sqlx::query("SELECT memes.id, user, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.user = users.id AND memes.user = ? AND filename = ? ORDER BY memes.id DESC") + .bind(user_id) + .bind(filename) + .map(|row: MySqlRow| Meme { + id: row.get("id"), + filename: row.get("filename"), + username: row.get("name"), + userid: row.get("user"), + category: row.get("category"), + timestamp: row.get("ts"), + ipfs: row.get("cid"), + }) + .fetch_optional(&self.db_pool).await?; + Ok(q) + } + + pub async fn get_category(&self, id: &String) -> Result> { + let q: Option = sqlx::query("SELECT * FROM categories WHERE id=?") + .bind(id) + .map(|row: MySqlRow| Category { + id: row.get("id"), + name: row.get("name"), + }) + .fetch_optional(&self.db_pool) + .await?; + Ok(q) + } + + pub async fn get_categories(&self) -> Result> { + let q: Vec = sqlx::query("SELECT * FROM categories ORDER BY num") + .map(|row: MySqlRow| Category { + id: row.get("id"), + name: row.get("name"), + }) + .fetch_all(&self.db_pool) + .await?; + Ok(q) + } + + pub async fn get_user(&self, identifier: UserIdentifier) -> Result> { let query = match identifier { UserIdentifier::Id(id) => sqlx::query("SELECT id, name, IFNULL(MD5(token), '0') AS hash, uploads FROM (SELECT id, name, IFNULL(count.uploads, 0) AS uploads FROM users LEFT JOIN (SELECT user, COUNT(*) AS uploads FROM memes WHERE DATE(timestamp) = CURDATE() GROUP BY (user)) AS count ON users.id = count.user) AS users LEFT JOIN token ON users.id = token.uid WHERE users.id = ?").bind(id), UserIdentifier::Token(token) => sqlx::query("SELECT id, name, IFNULL(MD5(token), '0') AS hash, uploads FROM (SELECT id, name, IFNULL(count.uploads, 0) AS uploads FROM users LEFT JOIN (SELECT user, COUNT(*) AS uploads FROM memes WHERE DATE(timestamp) = CURDATE() GROUP BY (user)) AS count ON users.id = count.user) AS users LEFT JOIN token ON users.id = token.uid WHERE token = ?").bind(token), UserIdentifier::Username(name) => sqlx::query("SELECT id, name, IFNULL(MD5(token), '0') AS hash, uploads FROM (SELECT id, name, IFNULL(count.uploads, 0) AS uploads FROM users LEFT JOIN (SELECT user, COUNT(*) AS uploads FROM memes WHERE DATE(timestamp) = CURDATE() GROUP BY (user)) AS count ON users.id = count.user) AS users LEFT JOIN token ON users.id = token.uid WHERE name = ?").bind(name), UserIdentifier::Null => sqlx::query("SELECT id, name, '0' AS hash, 0 AS uploads FROM users WHERE id = '000'"), }; - let q: Option = query - .map(|row: MySqlRow| Self { + let q: Option = query + .map(|row: MySqlRow| User { id: row.get("id"), name: row.get("name"), userdir: row.get("id"), tokenhash: row.get("hash"), dayuploads: row.get("uploads"), }) - .fetch_optional(pool) + .fetch_optional(&self.db_pool) .await?; Ok(q) } - pub async fn get_all(pool: &MySqlPool) -> Result> { - let q: Vec = sqlx::query("SELECT id, name, IFNULL(MD5(token), '0') AS hash, uploads FROM (SELECT id, name, IFNULL(count.uploads, 0) AS uploads FROM users LEFT JOIN (SELECT user, COUNT(*) AS uploads FROM memes WHERE DATE(timestamp) = CURDATE() GROUP BY (user)) AS count ON users.id = count.user) AS users LEFT JOIN token ON users.id = token.uid") - .map(|row: MySqlRow| Self { + pub async fn get_users(&self) -> Result> { + let q: Vec = sqlx::query("SELECT id, name, IFNULL(MD5(token), '0') AS hash, uploads FROM (SELECT id, name, IFNULL(count.uploads, 0) AS uploads FROM users LEFT JOIN (SELECT user, COUNT(*) AS uploads FROM memes WHERE DATE(timestamp) = CURDATE() GROUP BY (user)) AS count ON users.id = count.user) AS users LEFT JOIN token ON users.id = token.uid") + .map(|row: MySqlRow| User { id: row.get("id"), name: row.get("name"), userdir: row.get("id"), tokenhash: row.get("hash"), dayuploads: row.get("uploads"), }) - .fetch_all(pool).await?; + .fetch_all(&self.db_pool).await?; Ok(q) } - pub async fn check_token(token: &String, pool: &MySqlPool) -> Result> { - let user = Self::get(UserIdentifier::Token(token.clone()), pool).await?; + pub async fn check_token(&self, token: &String) -> Result> { + let user = self.get_user(UserIdentifier::Token(token.clone())).await?; Ok(user) } + + pub async fn add_meme_sql( + &self, + user: &User, + file: &IPFSFile, + ip: &String, + category: &Category, + ) -> Result { + let mut tx = self.db_pool.begin().await?; + sqlx::query("INSERT INTO memes (filename, user, category, timestamp, ip, cid) VALUES (?, ?, ?, NOW(), ?, ?)") + .bind(&file.name) + .bind(&user.id) + .bind(&category.id) + .bind(ip) + .bind(&file.hash) + .execute(&mut tx).await?; + let id: u64 = sqlx::query("SELECT LAST_INSERT_ID() as id") + .map(|row: MySqlRow| row.get("id")) + .fetch_one(&mut tx) + .await?; + tx.commit().await?; + Ok(id) + } } diff --git a/src/v1/error.rs b/src/v1/error.rs index 2864ca1..b5309bc 100644 --- a/src/v1/error.rs +++ b/src/v1/error.rs @@ -2,37 +2,13 @@ use std::convert::Infallible; use axum::{ body::{Bytes, Full}, - extract::{multipart::MultipartError, rejection::QueryRejection}, response::IntoResponse, Json, }; use reqwest::StatusCode; -use thiserror::Error; use super::models::ErrorResponse; -use crate::error::ServiceError; - -#[derive(Error, Debug)] -pub enum APIError { - #[error("SQL error: {0}")] - Sql(#[from] sqlx::Error), - #[error("Multipart form error: {0}")] - Multipart(#[from] MultipartError), - #[error("{0}")] - BadRequest(String), - #[error("{0}")] - Unauthorized(String), - #[error("{0}")] - Forbidden(String), - #[error("{0}")] - NotFound(String), - #[error("{0}")] - Internal(String), - #[error("JMService error: {0}")] - Service(#[from] ServiceError), - #[error("Query rejection: {0}")] - Query(#[from] QueryRejection), -} +use crate::error::APIError; impl ErrorResponse { fn new(status: StatusCode, message: Option) -> Self { @@ -63,6 +39,7 @@ impl IntoResponse for APIError { APIError::Internal(err) => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, Some(err)), APIError::Service(_) => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, None), APIError::Query(_) => ErrorResponse::new(StatusCode::BAD_REQUEST, None), + APIError::Decode(_) => ErrorResponse::new(StatusCode::BAD_REQUEST, None), }; let status = res.status; (status, Json(res)).into_response() diff --git a/src/v1/mod.rs b/src/v1/mod.rs index 21c085f..7831475 100644 --- a/src/v1/mod.rs +++ b/src/v1/mod.rs @@ -7,7 +7,7 @@ use axum::extract::{FromRequest, RequestParts}; pub use routes::routes; use serde::de::DeserializeOwned; -use self::error::APIError; +use crate::error::APIError; pub struct Query(pub T); diff --git a/src/v1/models.rs b/src/v1/models.rs index 7291dba..2bb85ab 100644 --- a/src/v1/models.rs +++ b/src/v1/models.rs @@ -1,7 +1,7 @@ use reqwest::StatusCode; use serde::{Deserialize, Serialize, Serializer}; -use crate::models::{Category, Meme, User, UserIdentifier}; +use crate::models::{Category, Meme, MemeOptions, User, UserIdentifier}; fn serialize_status(x: &StatusCode, s: S) -> Result where @@ -124,3 +124,23 @@ impl From for UserIdentifier { } } } + +#[derive(Deserialize)] +pub struct MemeFilter { + pub category: Option, + pub user: Option, + pub search: Option, +} + +impl From for MemeOptions { + fn from(filter: MemeFilter) -> Self { + Self { + category: filter.category, + user_id: None, + username: filter.user, + search: filter.search, + limit: None, + after: None, + } + } +} diff --git a/src/v1/routes.rs b/src/v1/routes.rs index 45db29b..8da47b8 100644 --- a/src/v1/routes.rs +++ b/src/v1/routes.rs @@ -1,6 +1,5 @@ use crate::ipfs::IPFSFile; use crate::lib::ExtractIP; -use crate::models::{Category, Meme, MemeFilter, User}; use crate::v1::models::*; use crate::JMService; @@ -10,18 +9,17 @@ use axum::response::IntoResponse; use axum::routing::BoxRoute; use axum::{Json, Router}; use hyper::StatusCode; -use sqlx::MySqlPool; -use super::error::APIError; use super::Query; +use crate::error::APIError; async fn meme( Query(params): Query, - Extension(db_pool): Extension, Extension(service): Extension, ) -> Result { let meme = V1Meme::new( - Meme::get(params.id, &db_pool) + service + .get_meme(params.id) .await? .ok_or_else(|| APIError::NotFound("Meme not found".to_string()))?, service.cdn_url(), @@ -35,10 +33,10 @@ async fn meme( async fn memes( Query(params): Query, - Extension(db_pool): Extension, Extension(service): Extension, ) -> Result { - let memes = Meme::get_all(params, &db_pool) + let memes = service + .get_memes(params.into()) .await? .into_iter() .map(|meme| V1Meme::new(meme, service.cdn_url())) @@ -52,9 +50,10 @@ async fn memes( async fn category( Query(params): Query, - Extension(db_pool): Extension, + Extension(service): Extension, ) -> Result { - let category = Category::get(¶ms.id, &db_pool) + let category = service + .get_category(¶ms.id) .await? .ok_or_else(|| APIError::NotFound("Category not found".to_string()))?; Ok(Json(CategoryResponse { @@ -65,9 +64,9 @@ async fn category( } async fn categories( - Extension(db_pool): Extension, + Extension(service): Extension, ) -> Result { - let categories = Category::get_all(&db_pool).await?; + let categories = service.get_categories().await?; Ok(Json(CategoriesResponse { status: 200, error: None, @@ -77,9 +76,10 @@ async fn categories( async fn user( Query(params): Query, - Extension(db_pool): Extension, + Extension(service): Extension, ) -> Result { - let user = User::get(params.into(), &db_pool) + let user = service + .get_user(params.into()) .await? .ok_or_else(|| APIError::NotFound("User not found".to_string()))?; Ok(Json(UserResponse { @@ -89,8 +89,8 @@ async fn user( })) } -async fn users(Extension(db_pool): Extension) -> Result { - let users = User::get_all(&db_pool).await?; +async fn users(Extension(service): Extension) -> Result { + let users = service.get_users().await?; Ok(Json(UsersResponse { status: 200, error: None, @@ -100,10 +100,12 @@ async fn users(Extension(db_pool): Extension) -> Result, - Extension(db_pool): Extension, Extension(service): Extension, ) -> Result { - let random = V1Meme::new(Meme::get_random(params, &db_pool).await?, service.cdn_url()); + let random = V1Meme::new( + service.get_random_meme(params.into()).await?, + service.cdn_url(), + ); Ok(Json(MemeResponse { status: 200, error: None, @@ -113,7 +115,6 @@ async fn random( async fn upload( ContentLengthLimit(mut form): ContentLengthLimit, - Extension(db_pool): Extension, Extension(service): Extension, ExtractIP(ip): ExtractIP, ) -> Result { @@ -134,7 +135,7 @@ async fn upload( APIError::BadRequest("A file field has no filename".to_string()) })? .to_string(); - let file = service.add(field.bytes().await?, filename).await?; + let file = service.ipfs_add(field.bytes().await?, filename).await?; files.push(file); } _ => (), @@ -143,7 +144,8 @@ async fn upload( let token = token.ok_or_else(|| APIError::Unauthorized("Missing token".to_string()))?; let category = category.ok_or_else(|| APIError::BadRequest("Missing category".to_string()))?; - let user = User::check_token(&token, &db_pool) + let user = service + .check_token(&token) .await? .ok_or_else(|| APIError::Forbidden("token not existing".to_string()))?; let total = (user.dayuploads as isize) + (files.len() as isize); @@ -152,7 +154,8 @@ async fn upload( return Err(APIError::Forbidden("Upload limit reached".to_string())); } - let cat = Category::get(&category, &db_pool) + let cat = service + .get_category(&category) .await? .ok_or_else(|| APIError::BadRequest("Category not existing".to_string()))?; @@ -161,7 +164,7 @@ async fn upload( let mut links: Vec = vec![]; for f in files { - let res = cat.add_meme(&user, &f, &ip, &db_pool).await?; + let res = service.add_meme_sql(&user, &f, &ip, &cat).await?; if res == 0 { return Err(APIError::Internal("Database insertion error".to_string())); @@ -175,7 +178,7 @@ async fn upload( res, ) .await?; - service.pin(f.hash).await?; + service.ipfs_pin(f.hash).await?; links.push(format!( "{}/{}/{}", service.cdn_url(), diff --git a/src/v2/mod.rs b/src/v2/mod.rs new file mode 100644 index 0000000..ac6442c --- /dev/null +++ b/src/v2/mod.rs @@ -0,0 +1,4 @@ +mod models; +mod routes; + +pub use routes::routes; diff --git a/src/v2/models.rs b/src/v2/models.rs new file mode 100644 index 0000000..018aeca --- /dev/null +++ b/src/v2/models.rs @@ -0,0 +1,54 @@ +use crate::models::{Meme, User}; +use serde::Serialize; + +#[derive(Serialize)] +pub struct V2Meme { + pub id: i32, + pub filename: String, + pub ipfs: String, + pub category: String, + pub user: String, + pub timestamp: i64, +} + +#[derive(Serialize)] +pub struct V2User { + pub id: String, + pub name: String, + pub dayuploads: i32, +} + +#[derive(Serialize)] +pub struct CDNEntry { + pub directories: Vec, + pub files: Vec, +} + +#[derive(Serialize)] +pub struct CDNFile { + pub cid: String, + pub filename: String, +} + +impl From for V2Meme { + fn from(meme: Meme) -> Self { + Self { + id: meme.id, + filename: meme.filename, + category: meme.category, + user: meme.userid, + timestamp: meme.timestamp, + ipfs: meme.ipfs, + } + } +} + +impl From for V2User { + fn from(user: User) -> Self { + Self { + id: user.id, + name: user.name, + dayuploads: user.dayuploads, + } + } +} diff --git a/src/v2/routes.rs b/src/v2/routes.rs new file mode 100644 index 0000000..ce60236 --- /dev/null +++ b/src/v2/routes.rs @@ -0,0 +1,127 @@ +use axum::{ + extract::{Extension, Path, Query}, + handler::get, + response::IntoResponse, + routing::BoxRoute, + Json, Router, +}; + +use crate::{ + error::APIError, + models::{MemeOptions, UserIdentifier}, + JMService, +}; + +use super::models::{V2Meme, V2User}; + +async fn get_meme( + Path(meme_id): Path, + Extension(service): Extension, +) -> Result { + Ok(Json(V2Meme::from( + service + .get_meme(meme_id) + .await? + .ok_or_else(|| APIError::NotFound("Meme not found".to_string()))?, + ))) +} + +async fn get_memes( + Extension(service): Extension, +) -> Result { + Ok(Json( + service + .get_memes(MemeOptions::empty()) + .await? + .into_iter() + .map(V2Meme::from) + .collect::>(), + )) +} + +async fn get_category( + Path(category_id): Path, + Extension(service): Extension, +) -> Result { + Ok(Json(service.get_category(&category_id).await?.ok_or_else( + || APIError::NotFound("Category not found".to_string()), + )?)) +} + +async fn get_categories( + Extension(service): Extension, +) -> Result { + Ok(Json(service.get_categories().await?)) +} + +async fn get_user( + Path(user_id): Path, + Extension(service): Extension, +) -> Result { + Ok(Json(V2User::from( + service + .get_user(UserIdentifier::Id(user_id)) + .await? + .ok_or_else(|| APIError::NotFound("User not found".to_string()))?, + ))) +} + +async fn get_users( + Extension(service): Extension, +) -> Result { + Ok(Json( + service + .get_users() + .await? + .into_iter() + .map(V2User::from) + .collect::>(), + )) +} + +async fn get_user_memes( + Path(user_id): Path, + Extension(service): Extension, +) -> Result { + Ok(Json( + service + .get_memes(MemeOptions { + category: None, + user_id: Some(user_id), + username: None, + search: None, + limit: None, + after: None, + }) + .await? + .into_iter() + .map(V2Meme::from) + .collect::>(), + )) +} + +async fn get_user_meme( + Path((user_id, filename)): Path<(String, String)>, + Extension(service): Extension, +) -> Result { + let decoded = urlencoding::decode(&filename)?.into_owned(); + Ok(Json(V2Meme::from( + service + .get_user_meme(user_id, decoded) + .await? + .ok_or_else(|| APIError::NotFound("Meme not found".to_string()))?, + ))) +} + +pub fn routes() -> Router { + Router::new() + .route("/memes", get(get_memes)) + .route("/memes/:meme_id", get(get_meme)) + .route("/categories", get(get_categories)) + .route("/categories/:category_id", get(get_category)) + .route("/users", get(get_users)) + .route("/users/:user_id", get(get_user)) + .route("/users/:user_id/memes", get(get_user_memes)) + .route("/users/:user_id/memes/:filename", get(get_user_meme)) + .boxed() +}