diff --git a/src/v1/error.rs b/src/v1/error.rs index b470090..a386a37 100644 --- a/src/v1/error.rs +++ b/src/v1/error.rs @@ -18,8 +18,12 @@ pub enum APIError { Sql(#[from] sqlx::Error), #[error("Multipart form error: {0}")] Multipart(#[from] MultipartError), - #[error("Bad request: {0}")] + #[error("{0}")] BadRequest(String), + #[error("{0}")] + Unauthorized(String), + #[error("{0}")] + Forbidden(String), #[error("IPFS error: {0}")] IPFS(#[from] IPFSError), } @@ -47,6 +51,8 @@ impl IntoResponse for APIError { }, APIError::Multipart(_) => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, None), APIError::BadRequest(err) => ErrorResponse::new(StatusCode::BAD_REQUEST, Some(err)), + APIError::Unauthorized(err) => ErrorResponse::new(StatusCode::UNAUTHORIZED, Some(err)), + APIError::Forbidden(err) => ErrorResponse::new(StatusCode::FORBIDDEN, Some(err)), APIError::IPFS(_) => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, None), }; let status = res.status.clone(); diff --git a/src/v1/routes.rs b/src/v1/routes.rs index 974994c..5f58d2c 100644 --- a/src/v1/routes.rs +++ b/src/v1/routes.rs @@ -1,11 +1,14 @@ use crate::config::ConfVars; use crate::ipfs::IPFSFile; +use crate::lib::ExtractIP; use crate::v1::models::*; + use axum::extract::{ContentLengthLimit, Extension, Multipart, Query}; use axum::handler::{get, post}; use axum::response::IntoResponse; use axum::routing::BoxRoute; use axum::{Json, Router}; +use hyper::StatusCode; use sqlx::MySqlPool; use super::error::APIError; @@ -97,8 +100,70 @@ async fn upload( ContentLengthLimit(mut form): ContentLengthLimit, Extension(db_pool): Extension, Extension(vars): Extension, + ExtractIP(ip): ExtractIP, ) -> Result { - Ok(()) + let mut category: Option = None; + let mut token: Option = None; + let mut files: Vec = vec![]; + + let ipfs = vars.ipfs_client()?; + + while let Some(field) = form.next_field().await? { + match field.name().ok_or(APIError::BadRequest( + "A multipart-form field is missing a name".to_string(), + ))? { + "token" => token = Some(field.text().await?), + "category" => category = Some(field.text().await?), + "file" | "file[]" => { + let filename = field + .file_name() + .ok_or(APIError::BadRequest( + "A file field has no filename".to_string(), + ))? + .to_string(); + let file = ipfs.add(field.bytes().await?, filename).await?; + files.push(file); + } + _ => (), + } + } + + let token = token.ok_or(APIError::Unauthorized("Missing token".to_string()))?; + let category = category.ok_or(APIError::BadRequest("Missing category".to_string()))?; + let user = User::check_token(token, &db_pool) + .await? + .ok_or(APIError::Forbidden("token not existing".to_string()))?; + let total = (user.dayuploads as isize) + (files.len() as isize); + + if total > 20 { + return Err(APIError::Forbidden("Upload limit reached".to_string())); + } + + let cat = Category::get(&category, &db_pool).await?; + + let ip = ip.to_string(); + + let mut links: Vec = vec![]; + + for f in files { + let res = cat.add_meme(&user, &f, &ip, &db_pool).await?; + ipfs.pin(f.hash).await?; + links.push(format!( + "{}/{}/{}", + vars.cdn, + user.id.clone(), + f.name.clone() + )); + } + + Ok(( + StatusCode::CREATED, + Json(UploadResponse { + status: 201, + error: None, + files: Some(links), + }), + )) } //TODO: Implement upload endpoint diff --git a/src/v1/sql.rs b/src/v1/sql.rs index f603b6b..f9a4e52 100644 --- a/src/v1/sql.rs +++ b/src/v1/sql.rs @@ -1,3 +1,4 @@ +use crate::ipfs::IPFSFile; use crate::v1::models::{Category, Meme, MemeFilterQuery, User, UserIDQuery}; use sqlx::mysql::MySqlRow; use sqlx::{MySqlPool, Result, Row}; @@ -25,8 +26,8 @@ impl Meme { } pub async fn get(id: i32, pool: &MySqlPool, cdn: String) -> 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 memes.id=?").bind(id) - .map(|row: MySqlRow| Meme::new(DBMeme { + 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 memes.id=?").bind(id) + .map(|row: MySqlRow| Self::new(DBMeme { id: row.get("id"), filename: row.get("filename"), user: row.get("name"), @@ -43,12 +44,12 @@ impl Meme { params: MemeFilterQuery, pool: &MySqlPool, cdn: String, - ) -> Result> { + ) -> 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(params.category.unwrap_or(String::from("%"))) .bind(format!("%{}%", params.user.unwrap_or(String::from("")))) .bind(format!("%{}%", params.search.unwrap_or(String::from("")))) - .map(|row: MySqlRow| Meme::new(DBMeme { + .map(|row: MySqlRow| Self::new(DBMeme { id: row.get("id"), filename: row.get("filename"), user: row.get("name"), @@ -65,12 +66,12 @@ impl Meme { params: MemeFilterQuery, pool: &MySqlPool, cdn: String, - ) -> Result { + ) -> 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 ?) ORDER BY RAND() LIMIT 1") .bind(params.category.unwrap_or(String::from("%"))) .bind(format!("%{}%", params.user.unwrap_or(String::from("")))) .bind(format!("%{}%", params.search.unwrap_or(String::from("")))) - .map(|row: MySqlRow| Meme::new(DBMeme { + .map(|row: MySqlRow| Self::new(DBMeme { id: row.get("id"), filename: row.get("filename"), user: row.get("name"), @@ -85,10 +86,10 @@ impl Meme { } impl Category { - pub async fn get(id: &String, pool: &MySqlPool) -> Result { + pub async fn get(id: &String, pool: &MySqlPool) -> Result { let q: Category = sqlx::query("SELECT * FROM categories WHERE id=?") .bind(id) - .map(|row: MySqlRow| Category { + .map(|row: MySqlRow| Self { id: row.get("id"), name: row.get("name"), }) @@ -97,9 +98,9 @@ impl Category { Ok(q) } - pub async fn get_all(pool: &MySqlPool) -> Result> { + pub async fn get_all(pool: &MySqlPool) -> Result> { let q: Vec = sqlx::query("SELECT * FROM categories ORDER BY num") - .map(|row: MySqlRow| Category { + .map(|row: MySqlRow| Self { id: row.get("id"), name: row.get("name"), }) @@ -107,15 +108,32 @@ impl Category { .await?; Ok(q) } + + pub async fn add_meme( + &self, + user: &User, + file: &IPFSFile, + ip: &String, + pool: &MySqlPool, + ) -> Result { + let q = sqlx::query("INSERT INTO memes (filename, user, category, timestamp, ip, cid) VALUES (?, ?, ?, NOW(), ?, ?)") + .bind(&file.name) + .bind(&user.id) + .bind(&self.id) + .bind(ip) + .bind(&file.hash) + .execute(pool).await?; + Ok(q) + } } impl User { - pub async fn get(params: UserIDQuery, pool: &MySqlPool) -> Result { + pub async fn get(params: UserIDQuery, pool: &MySqlPool) -> Result { let q: User = sqlx::query("SELECT id, name, MD5(token) 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, token WHERE users.id = token.uid AND (users.id LIKE ? OR token LIKE ? OR name LIKE ?) UNION SELECT id, name, 0 AS hash, 0 AS uploads FROM users WHERE id = '000'") .bind(params.id.unwrap_or(String::from(""))) .bind(params.token.unwrap_or(String::from(""))) .bind(params.name.unwrap_or(String::from(""))) - .map(|row: MySqlRow| User { + .map(|row: MySqlRow| Self { id: row.get("id"), name: row.get("name"), userdir: row.get("id"), @@ -126,9 +144,9 @@ impl User { Ok(q) } - pub async fn get_all(pool: &MySqlPool) -> Result> { + pub async fn get_all(pool: &MySqlPool) -> Result> { let q: Vec = sqlx::query("SELECT id, name, MD5(token) 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, token WHERE users.id = token.uid UNION SELECT id, name, 0 AS hash, 0 AS uploads FROM users WHERE id = '000'") - .map(|row: MySqlRow| User { + .map(|row: MySqlRow| Self { id: row.get("id"), name: row.get("name"), userdir: row.get("id"), @@ -138,4 +156,18 @@ impl User { .fetch_all(pool).await?; Ok(q) } + + pub async fn check_token(token: String, pool: &MySqlPool) -> Result> { + let q: Option = sqlx::query("SELECT id, name, MD5(token) 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, token WHERE users.id = token.uid AND token = ?") + .bind(token) + .map(|row: MySqlRow| Self { + id: row.get("id"), + name: row.get("name"), + userdir: row.get("id"), + tokenhash: row.get("hash"), + dayuploads: row.get("uploads"), + }) + .fetch_optional(pool).await?; + Ok(q) + } }