started v3.0.0 development
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Timo Ley 2022-07-19 21:11:13 +02:00
parent a50d157394
commit 1577b9a1ca
17 changed files with 1025 additions and 162 deletions

View File

@ -1,3 +1,3 @@
- Initial Matrix implementation
- Allows bots to know, when a new meme was uploaded
- First step towards decentralization
- Refactoring
- Initial V2 API
- Added Dockerfilegit

21
Dockerfile Normal file
View File

@ -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"]

573
spec/v2.json Normal file
View File

@ -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 '"
}
}
}
}

View File

@ -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<BoxRoute> {
async fn image(
Path((user, filename)): Path<(String, String)>,
Extension(db_pool): Extension<MySqlPool>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, CDNError> {
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<MySqlPool>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, CDNError> {
let users = sql::get_users(&db_pool).await?;
async fn users(Extension(service): Extension<JMService>) -> Result<impl IntoResponse, CDNError> {
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<String>,
Extension(db_pool): Extension<MySqlPool>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, CDNError> {
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(),

View File

@ -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<JMService, JMError> {
pub fn service(&self, db_pool: MySqlPool) -> Result<JMService, JMError> {
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(),

View File

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

View File

@ -35,7 +35,7 @@ pub struct PinQuery {
}
impl JMServiceInner {
pub async fn cat(&self, cid: String) -> Result<Response, ServiceError> {
pub async fn ipfs_cat(&self, cid: String) -> Result<Response, ServiceError> {
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<IPFSFile, ServiceError> {
pub async fn ipfs_add(&self, file: Bytes, filename: String) -> Result<IPFSFile, ServiceError> {
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")?)

View File

@ -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>(&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<Body>>::if_not_present(
header::ACCESS_CONTROL_ALLOW_ORIGIN,

View File

@ -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<String>,
pub user: Option<String>,
pub user_id: Option<String>,
pub username: Option<String>,
pub search: Option<String>,
pub limit: Option<i32>,
pub after: Option<i32>,
}
impl MemeOptions {
pub fn empty() -> Self {
Self {
category: None,
user_id: None,
username: None,
search: None,
limit: None,
after: None,
}
}
}

View File

@ -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<Option<Self>> {
let q: Option<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 {
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<Vec<Self>> {
let q: Vec<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 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<Self> {
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<Option<Self>> {
let q: Option<Self> = 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<Vec<Self>> {
let q: Vec<Self> = 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<Option<Self>> {
impl JMServiceInner {
pub async fn get_meme(&self, id: i32) -> Result<Option<Meme>> {
let q: Option<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 {
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<Vec<Meme>> {
let q: Vec<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 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<Meme> {
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<Option<Meme>> {
let q: Option<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.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<Option<Category>> {
let q: Option<Category> = 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<Vec<Category>> {
let q: Vec<Category> = 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<Option<User>> {
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<Self> = query
.map(|row: MySqlRow| Self {
let q: Option<User> = 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<Vec<Self>> {
let q: Vec<Self> = 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<Vec<User>> {
let q: Vec<User> = 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<Option<Self>> {
let user = Self::get(UserIdentifier::Token(token.clone()), pool).await?;
pub async fn check_token(&self, token: &String) -> Result<Option<User>> {
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<u64> {
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)
}
}

View File

@ -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<String>) -> 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()

View File

@ -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<T>(pub T);

View File

@ -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<S>(x: &StatusCode, s: S) -> Result<S::Ok, S::Error>
where
@ -124,3 +124,23 @@ impl From<UserIDQuery> for UserIdentifier {
}
}
}
#[derive(Deserialize)]
pub struct MemeFilter {
pub category: Option<String>,
pub user: Option<String>,
pub search: Option<String>,
}
impl From<MemeFilter> for MemeOptions {
fn from(filter: MemeFilter) -> Self {
Self {
category: filter.category,
user_id: None,
username: filter.user,
search: filter.search,
limit: None,
after: None,
}
}
}

View File

@ -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<MemeIDQuery>,
Extension(db_pool): Extension<MySqlPool>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
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<MemeFilter>,
Extension(db_pool): Extension<MySqlPool>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
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<IDQuery>,
Extension(db_pool): Extension<MySqlPool>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
let category = Category::get(&params.id, &db_pool)
let category = service
.get_category(&params.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<MySqlPool>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
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<UserIDQuery>,
Extension(db_pool): Extension<MySqlPool>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
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<MySqlPool>) -> Result<impl IntoResponse, APIError> {
let users = User::get_all(&db_pool).await?;
async fn users(Extension(service): Extension<JMService>) -> Result<impl IntoResponse, APIError> {
let users = service.get_users().await?;
Ok(Json(UsersResponse {
status: 200,
error: None,
@ -100,10 +100,12 @@ async fn users(Extension(db_pool): Extension<MySqlPool>) -> Result<impl IntoResp
async fn random(
Query(params): Query<MemeFilter>,
Extension(db_pool): Extension<MySqlPool>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
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<Multipart, { 1024 * 1024 * 1024 }>,
Extension(db_pool): Extension<MySqlPool>,
Extension(service): Extension<JMService>,
ExtractIP(ip): ExtractIP,
) -> Result<impl IntoResponse, APIError> {
@ -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<String> = 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(),

4
src/v2/mod.rs Normal file
View File

@ -0,0 +1,4 @@
mod models;
mod routes;
pub use routes::routes;

54
src/v2/models.rs Normal file
View File

@ -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<String>,
pub files: Vec<String>,
}
#[derive(Serialize)]
pub struct CDNFile {
pub cid: String,
pub filename: String,
}
impl From<Meme> 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<User> for V2User {
fn from(user: User) -> Self {
Self {
id: user.id,
name: user.name,
dayuploads: user.dayuploads,
}
}
}

127
src/v2/routes.rs Normal file
View File

@ -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<i32>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
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<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(
service
.get_memes(MemeOptions::empty())
.await?
.into_iter()
.map(V2Meme::from)
.collect::<Vec<V2Meme>>(),
))
}
async fn get_category(
Path(category_id): Path<String>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
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<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(service.get_categories().await?))
}
async fn get_user(
Path(user_id): Path<String>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
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<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(
service
.get_users()
.await?
.into_iter()
.map(V2User::from)
.collect::<Vec<V2User>>(),
))
}
async fn get_user_memes(
Path(user_id): Path<String>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
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::<Vec<V2Meme>>(),
))
}
async fn get_user_meme(
Path((user_id, filename)): Path<(String, String)>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
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<BoxRoute> {
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()
}