Compare commits

...

11 commits
v2.2 ... master

Author SHA1 Message Date
916f1b4a45 feat: seperate internal and external cdn urls
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
continuous-integration/drone Build is passing
ci/woodpecker/manual/central-override Pipeline was successful
ci/woodpecker/tag/central-override Pipeline failed
2024-03-15 13:14:29 +01:00
b42e35da45 feat: return IPFS header for CDN
All checks were successful
ci/woodpecker/push/central-override Pipeline was successful
continuous-integration/drone/push Build is passing
2023-07-26 17:36:04 +02:00
a031352ef9 feat: finish postgres migration
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
ci/woodpecker/manual/central-override Pipeline was successful
2023-07-07 16:03:59 +02:00
46773e9d17 feat: postgres migration
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-06 17:35:23 +02:00
9bed0de064 Fix upload error
All checks were successful
continuous-integration/drone/push Build is passing
2022-07-26 16:06:14 +02:00
bf67cf62f9 Better error message for reqwest error
All checks were successful
continuous-integration/drone/push Build is passing
2022-07-26 11:59:52 +02:00
c4a8251147 Implement API pagination for API v2
All checks were successful
continuous-integration/drone/push Build is passing
2022-07-21 12:18:25 +02:00
e367c341b7 Better error response messages
All checks were successful
continuous-integration/drone/push Build is passing
2022-07-20 11:57:38 +02:00
1577b9a1ca started v3.0.0 development
All checks were successful
continuous-integration/drone/push Build is passing
2022-07-19 21:11:13 +02:00
a50d157394 Initial Matrix implementation
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-17 21:58:33 +01:00
fbca4b7c06 Replace ConfVars with JMService
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2022-01-16 22:43:45 +01:00
23 changed files with 1505 additions and 247 deletions

View file

@ -1 +1,3 @@
- Return API error response on missing query parameter - Refactoring
- Initial V2 API
- Added Dockerfilegit

View file

@ -14,7 +14,7 @@ tower = { version = "0.4", features = ["util", "timeout"] }
tower-http = { version = "0.1", features = ["add-extension", "trace", "fs", "set-header"] } tower-http = { version = "0.1", features = ["add-extension", "trace", "fs", "set-header"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.51" serde_json = "1.0.51"
sqlx = { version = "0.3", features = [ "mysql" ] } sqlx = { version = "0.3", features = [ "postgres" ] }
rand = "0.8.0" rand = "0.8.0"
structopt = "0.3.22" structopt = "0.3.22"
toml = "0.5.8" toml = "0.5.8"

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 curl
VOLUME ["/data"]
ENTRYPOINT ["/usr/bin/dumb-init", "--", "/usr/bin/jmserver", "--config", "/data/config.toml"]

View file

@ -1,4 +1,9 @@
CREATE TABLE IF NOT EXISTS categories (num INT UNIQUE NOT NULL , id varchar(255) NOT NULL , name TEXT, PRIMARY KEY (id)); CREATE TABLE IF NOT EXISTS categories (num INT UNIQUE NOT NULL , id varchar(255) NOT NULL , name TEXT, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS users (id varchar(255) NOT NULL, name TEXT, authsource JSON, PRIMARY KEY (id)); CREATE TABLE IF NOT EXISTS users (id varchar(255) NOT NULL, name TEXT, authsource JSON, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS memes (id INT NOT NULL AUTO_INCREMENT, filename varchar(255) NOT NULL, user varchar(255) NOT NULL, category varchar(255), timestamp DATETIME, ip varchar(255), cid varchar(255) NOT NULL, PRIMARY KEY (id), FOREIGN KEY (category) REFERENCES categories(id), FOREIGN KEY (user) REFERENCES users(id)); CREATE TABLE IF NOT EXISTS memes (id SERIAL, filename varchar(255) NOT NULL, userid varchar(255) NOT NULL, category varchar(255), timestamp TIMESTAMP, ip varchar(255), cid varchar(255) NOT NULL, PRIMARY KEY (id), FOREIGN KEY (category) REFERENCES categories(id), FOREIGN KEY (userid) REFERENCES users(id));
CREATE TABLE IF NOT EXISTS token (uid varchar(255) UNIQUE NOT NULL, token varchar(255), FOREIGN KEY (uid) REFERENCES users(id)); CREATE TABLE IF NOT EXISTS token (uid varchar(255) UNIQUE NOT NULL, token varchar(255), FOREIGN KEY (uid) REFERENCES users(id));
CREATE OR REPLACE FUNCTION UNIX_TIMESTAMP(ts TIMESTAMP) RETURNS INT AS $$
BEGIN
RETURN extract(epoch FROM ts)::integer;
END;
$$ LANGUAGE plpgsql;

701
spec/v2.json Normal file
View file

@ -0,0 +1,701 @@
{
"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 (-1 for no limit)",
"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 (WIP)",
"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/count": {
"get": {
"summary": "Gives the total number of memes",
"parameters": [
{
"name": "category",
"in": "query",
"description": "Only count memes from this category ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "user",
"in": "query",
"description": "Only count memes from this user",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Amount of memes",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Count"
}
}
}
},
"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"
}
}
}
}
}
}
},
"/users/{id}/memes" : {
"get": {
"summary": "Get all memes of a user",
"parameters": [
{
"name": "limit",
"in": "query",
"description": "How many memes should be returned at maximum (-1 for no limit)",
"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"
}
}
}
}
}
}
},
"/users/{id}/memes/{filename}": {
"get": {
"summary": "Gives a specific meme from a user by filename",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The ID of the user",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "filename",
"in": "path",
"description": "The filename of the meme",
"required": true,
"schema": {
"type": "string"
}
}
],
"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"
}
}
}
}
}
}
},
"/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": {
"filename": {
"type": "string"
},
"id": {
"type": "integer"
},
"ipfs": {
"type": "string"
},
"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"
}
}
},
"Count": {
"type": "object",
"properties": {
"count": {
"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

@ -4,19 +4,22 @@ use axum::{
body::{Bytes, Empty}, body::{Bytes, Empty},
response::IntoResponse, response::IntoResponse,
}; };
use hyper::header::InvalidHeaderValue;
use reqwest::StatusCode; use reqwest::StatusCode;
use thiserror::Error; use thiserror::Error;
use crate::ipfs::error::IPFSError; use crate::error::ServiceError;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum CDNError { pub enum CDNError {
#[error("SQL error: {0}")] #[error("SQL error: {0}")]
Sql(#[from] sqlx::Error), Sql(#[from] sqlx::Error),
#[error("IPFS error: {0}")] #[error("JMService error: {0}")]
Ipfs(#[from] IPFSError), Service(#[from] ServiceError),
#[error("Decode error: {0}")] #[error("Decode error: {0}")]
Decode(#[from] FromUtf8Error), Decode(#[from] FromUtf8Error),
#[error("Header error: {0}")]
Header(#[from] InvalidHeaderValue),
#[error("Internal server error")] #[error("Internal server error")]
Internal, Internal,
} }

View file

@ -7,14 +7,13 @@ use axum::{
routing::BoxRoute, routing::BoxRoute,
Router, Router,
}; };
use headers::{ContentType, HeaderMapExt}; use headers::{ContentType, HeaderMapExt, HeaderValue};
use reqwest::{ use reqwest::{
header::{HeaderName, CONTENT_LENGTH}, header::{HeaderName, CONTENT_LENGTH},
StatusCode, StatusCode,
}; };
use sqlx::MySqlPool;
use crate::config::ConfVars; use crate::JMService;
use self::{ use self::{
error::CDNError, error::CDNError,
@ -35,13 +34,12 @@ pub fn routes() -> Router<BoxRoute> {
async fn image( async fn image(
Path((user, filename)): Path<(String, String)>, Path((user, filename)): Path<(String, String)>,
Extension(db_pool): Extension<MySqlPool>, Extension(service): Extension<JMService>,
Extension(vars): Extension<ConfVars>,
) -> Result<impl IntoResponse, CDNError> { ) -> Result<impl IntoResponse, CDNError> {
let filename = urlencoding::decode(&filename)?.into_owned(); let filename = urlencoding::decode(&filename)?.into_owned();
let cid = sql::get_cid(user, filename.clone(), &db_pool).await?; let cid = sql::get_cid(user, filename.clone(), &service.db_pool).await?;
let ipfs = vars.ipfs_client()?; let ipfs_path = format!("/ipfs/{}", cid);
let res = ipfs.cat(cid).await?; let res = service.ipfs_cat(cid).await?;
let clength = res let clength = res
.headers() .headers()
.get(HeaderName::from_static("x-content-length")) .get(HeaderName::from_static("x-content-length"))
@ -51,6 +49,7 @@ async fn image(
let ctype = ContentType::from(new_mime_guess::from_path(filename).first_or_octet_stream()); let ctype = ContentType::from(new_mime_guess::from_path(filename).first_or_octet_stream());
headers.typed_insert(ctype); headers.typed_insert(ctype);
headers.insert(CONTENT_LENGTH, clength.clone()); headers.insert(CONTENT_LENGTH, clength.clone());
headers.insert("X-Ipfs-Path", HeaderValue::from_str(ipfs_path.as_str())?);
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
@ -59,23 +58,20 @@ async fn image(
)) ))
} }
async fn users( async fn users(Extension(service): Extension<JMService>) -> Result<impl IntoResponse, CDNError> {
Extension(db_pool): Extension<MySqlPool>, let users = sql::get_users(&service.db_pool).await?;
Extension(vars): Extension<ConfVars>,
) -> Result<impl IntoResponse, CDNError> {
let users = sql::get_users(&db_pool).await?;
Ok(HtmlTemplate(DirTemplate { Ok(HtmlTemplate(DirTemplate {
entries: users, entries: users,
prefix: vars.cdn, prefix: service.int_cdn_url(),
suffix: "/".to_string(), suffix: "/".to_string(),
})) }))
} }
async fn memes( async fn memes(
Path(user): Path<String>, Path(user): Path<String>,
Extension(db_pool): Extension<MySqlPool>, Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, CDNError> { ) -> 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 { Ok(HtmlTemplate(DirTemplate {
entries: memes, entries: memes,
prefix: ".".to_string(), prefix: ".".to_string(),

View file

@ -1,28 +1,29 @@
use sqlx::{mysql::MySqlRow, MySqlPool, Result, Row}; use sqlx::{postgres::PgRow, PgPool, Result, Row};
pub async fn get_cid(user: String, filename: String, pool: &MySqlPool) -> Result<String> { pub async fn get_cid(user: String, filename: String, pool: &PgPool) -> Result<String> {
let q: String = let q: String =
sqlx::query("SELECT cid FROM memes WHERE user = ? AND filename = ? ORDER BY id DESC") sqlx::query("SELECT cid FROM memes WHERE userid = $1 AND filename = $2 ORDER BY id DESC")
.bind(user) .bind(user)
.bind(filename) .bind(filename)
.map(|row: MySqlRow| row.get("cid")) .map(|row: PgRow| row.get("cid"))
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
Ok(q) Ok(q)
} }
pub async fn get_memes(user: String, pool: &MySqlPool) -> Result<Vec<String>> { pub async fn get_memes(user: String, pool: &PgPool) -> Result<Vec<String>> {
let q: Vec<String> = sqlx::query("SELECT filename FROM memes WHERE user = ? ORDER BY filename") let q: Vec<String> =
sqlx::query("SELECT filename FROM memes WHERE userid = $1 ORDER BY filename")
.bind(user) .bind(user)
.map(|row: MySqlRow| row.get("filename")) .map(|row: PgRow| row.get("filename"))
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
Ok(q) Ok(q)
} }
pub async fn get_users(pool: &MySqlPool) -> Result<Vec<String>> { pub async fn get_users(pool: &PgPool) -> Result<Vec<String>> {
let q: Vec<String> = sqlx::query("SELECT id FROM users ORDER BY id") let q: Vec<String> = sqlx::query("SELECT id FROM users ORDER BY id")
.map(|row: MySqlRow| row.get("id")) .map(|row: PgRow| row.get("id"))
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
Ok(q) Ok(q)

View file

@ -1,34 +1,44 @@
use reqwest::Url; use reqwest::Url;
use serde::Deserialize; use serde::Deserialize;
use std::net::SocketAddr; use sqlx::PgPool;
use std::{net::SocketAddr, sync::Arc};
use crate::{error::JMError, JMService, JMServiceInner};
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct Config { pub struct Config {
pub addr: SocketAddr, pub addr: SocketAddr,
pub database: String, pub database: String,
pub cdn: String, pub int_cdn: String,
pub ipfs_api: Url, pub ext_cdn: String,
}
pub struct ConfVars {
pub cdn: String,
pub ipfs_api: Url, pub ipfs_api: Url,
pub matrix_url: Url,
pub matrix_token: String,
pub matrix_domain: String,
} }
impl Config { impl Config {
pub fn vars(&self) -> ConfVars { pub fn service(&self, db_pool: PgPool) -> Result<JMService, JMError> {
ConfVars { let client = reqwest::ClientBuilder::new().user_agent("curl").build()?;
cdn: self.cdn.clone(), Ok(Arc::new(JMServiceInner {
ipfs_api: self.ipfs_api.clone(), client,
} db_pool,
ipfs_url: self.ipfs_api.clone(),
int_cdn: self.int_cdn.clone(),
ext_cdn: self.ext_cdn.clone(),
matrix_url: self.matrix_url.clone(),
matrix_token: self.matrix_token.clone(),
matrix_domain: self.matrix_domain.clone(),
}))
} }
} }
impl Clone for ConfVars { impl JMServiceInner {
fn clone(&self) -> Self { pub fn int_cdn_url(&self) -> String {
Self { self.int_cdn.clone()
cdn: self.cdn.clone(), }
ipfs_api: self.ipfs_api.clone(),
} pub fn ext_cdn_url(&self) -> String {
self.ext_cdn.clone()
} }
} }

View file

@ -1,4 +1,9 @@
use std::string::FromUtf8Error;
use axum::extract::{multipart::MultipartError, rejection::QueryRejection};
use hyper::StatusCode;
use thiserror::Error; use thiserror::Error;
use url::ParseError;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum JMError { pub enum JMError {
@ -10,4 +15,40 @@ pub enum JMError {
Database(#[from] sqlx::Error), Database(#[from] sqlx::Error),
#[error("Axum error: {0}")] #[error("Axum error: {0}")]
Axum(#[from] hyper::Error), Axum(#[from] hyper::Error),
#[error("Reqwest error: {0}")]
Reqwest(#[from] reqwest::Error),
}
#[derive(Error, Debug)]
pub enum ServiceError {
#[error("Reqwest error: {0}")]
Reqwest(#[from] reqwest::Error),
#[error("URL parse error: {0}")]
Url(#[from] ParseError),
#[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

@ -1,10 +0,0 @@
use thiserror::Error;
use url::ParseError;
#[derive(Error, Debug)]
pub enum IPFSError {
#[error("Reqwest error: {0}")]
Reqwest(#[from] reqwest::Error),
#[error("URL parse error: {0}")]
Url(#[from] ParseError),
}

View file

@ -3,15 +3,11 @@ use std::time::Duration;
use axum::body::Bytes; use axum::body::Bytes;
use reqwest::{ use reqwest::{
multipart::{Form, Part}, multipart::{Form, Part},
Client, Response, Url, Response,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::config::ConfVars; use crate::{error::ServiceError, JMServiceInner};
use self::error::IPFSError;
pub(crate) mod error;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct IPFSFile { pub struct IPFSFile {
@ -38,24 +34,19 @@ pub struct PinQuery {
pub arg: String, pub arg: String,
} }
pub struct IpfsClient { impl JMServiceInner {
url: Url, pub async fn ipfs_cat(&self, cid: String) -> Result<Response, ServiceError> {
client: Client,
}
impl IpfsClient {
pub async fn cat(&self, cid: String) -> Result<Response, IPFSError> {
let request = self let request = self
.client .client
.post(self.url.join("/api/v0/cat")?) .post(self.ipfs_url.join("/api/v0/cat")?)
.query(&CatQuery::new(cid)); .query(&CatQuery::new(cid));
Ok(request.send().await?) Ok(request.send().await?)
} }
pub async fn add(&self, file: Bytes, filename: String) -> Result<IPFSFile, IPFSError> { pub async fn ipfs_add(&self, file: Bytes, filename: String) -> Result<IPFSFile, ServiceError> {
let request = self let request = self
.client .client
.post(self.url.join("/api/v0/add")?) .post(self.ipfs_url.join("/api/v0/add")?)
.query(&AddQuery::new(false)) .query(&AddQuery::new(false))
.multipart(Form::new().part("file", Part::stream(file).file_name(filename))); .multipart(Form::new().part("file", Part::stream(file).file_name(filename)));
let response = request.send().await?; let response = request.send().await?;
@ -63,10 +54,10 @@ impl IpfsClient {
Ok(res) Ok(res)
} }
pub async fn pin(&self, cid: String) -> Result<(), IPFSError> { pub async fn ipfs_pin(&self, cid: String) -> Result<(), ServiceError> {
let request = self let request = self
.client .client
.post(self.url.join("/api/v0/pin/add")?) .post(self.ipfs_url.join("/api/v0/pin/add")?)
.query(&PinQuery::new(cid)) .query(&PinQuery::new(cid))
.timeout(Duration::from_secs(60)); .timeout(Duration::from_secs(60));
request.send().await?; request.send().await?;
@ -91,13 +82,3 @@ impl PinQuery {
Self { arg: cid } Self { arg: cid }
} }
} }
impl ConfVars {
pub fn ipfs_client(&self) -> Result<IpfsClient, IPFSError> {
let client = reqwest::ClientBuilder::new().user_agent("curl").build()?;
Ok(IpfsClient {
url: self.ipfs_api.clone(),
client,
})
}
}

View file

@ -5,8 +5,9 @@ use axum::{
}; };
use config::Config; use config::Config;
use error::JMError; use error::JMError;
use sqlx::MySqlPool; use reqwest::{Client, Url};
use std::path::PathBuf; use sqlx::PgPool;
use std::{path::PathBuf, sync::Arc};
use structopt::StructOpt; use structopt::StructOpt;
use tower_http::{add_extension::AddExtensionLayer, set_header::SetResponseHeaderLayer}; use tower_http::{add_extension::AddExtensionLayer, set_header::SetResponseHeaderLayer};
@ -15,9 +16,11 @@ mod config;
mod error; mod error;
mod ipfs; mod ipfs;
mod lib; mod lib;
mod matrix;
mod models; mod models;
mod sql; mod sql;
mod v1; mod v1;
mod v2;
#[derive(StructOpt)] #[derive(StructOpt)]
struct Opt { struct Opt {
@ -30,19 +33,33 @@ struct Opt {
config: PathBuf, config: PathBuf,
} }
pub struct JMServiceInner {
client: Client,
db_pool: PgPool,
ipfs_url: Url,
int_cdn: String,
ext_cdn: String,
matrix_url: Url,
matrix_token: String,
matrix_domain: String,
}
pub type JMService = Arc<JMServiceInner>;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), JMError> { async fn main() -> Result<(), JMError> {
let opt = Opt::from_args(); let opt = Opt::from_args();
let config = std::fs::read(&opt.config)?; let config = std::fs::read(&opt.config)?;
let config = toml::from_slice::<Config>(&config)?; let config = toml::from_slice::<Config>(&config)?;
let db_pool = MySqlPool::new(&config.database).await?; let db_pool = PgPool::new(&config.database).await?;
let service = config.service(db_pool)?;
let app = Router::new() let app = Router::new()
.nest("/api/v1", v1::routes()) .nest("/api/v1", v1::routes())
.nest("/api/v2", v2::routes())
.nest("/cdn", cdn::routes()) .nest("/cdn", cdn::routes())
.layer(AddExtensionLayer::new(db_pool)) .layer(AddExtensionLayer::new(service))
.layer(AddExtensionLayer::new(config.vars()))
.layer(SetResponseHeaderLayer::<_, Request<Body>>::if_not_present( .layer(SetResponseHeaderLayer::<_, Request<Body>>::if_not_present(
header::ACCESS_CONTROL_ALLOW_ORIGIN, header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("*"), HeaderValue::from_static("*"),

158
src/matrix/mod.rs Normal file
View file

@ -0,0 +1,158 @@
use serde::{Deserialize, Serialize};
use crate::{error::ServiceError, JMServiceInner};
#[derive(Serialize)]
pub struct Meme {
pub category: String,
pub filename: String,
pub cid: String,
}
#[derive(Serialize, Deserialize)]
pub struct UserID {
pub user_id: String,
}
#[derive(Deserialize)]
pub struct RoomID {
pub room_id: String,
}
#[derive(Deserialize, Serialize)]
pub struct EventID {
pub event_id: String,
}
#[derive(Serialize)]
pub struct RegisterRequest {
#[serde(rename = "type")]
pub reg_type: String,
pub username: String,
}
impl JMServiceInner {
pub async fn add_meme(
&self,
category: String,
filename: String,
cid: String,
user: String,
id: i64,
) -> Result<(), ServiceError> {
let meme = Meme {
category,
filename,
cid,
};
let txid = meme.calc_txid(user.clone());
let usr = self.check_user(user).await?;
let room_id = self.join_room(&usr).await?;
let path = format!(
"/_matrix/client/r0/rooms/{}/send/es.jensmem.meme/{}",
&room_id, txid
);
let url = self.matrix_url.join(path.as_str())?;
let req = self
.client
.put(url)
.bearer_auth(self.matrix_token.clone())
.query(&usr)
.json(&meme);
let res = req.send().await?;
if res.status().is_success() {
let event: EventID = res.json().await?;
let path = format!(
"/_matrix/client/r0/rooms/{}/state/es.jensmem.index/{}",
&room_id, id
);
let req = self
.client
.put(self.matrix_url.join(path.as_str())?)
.bearer_auth(self.matrix_token.clone())
.json(&event);
let res = req.send().await?;
if res.status().is_success() {
Ok(())
} else {
Err(ServiceError::InvalidResponse(res.status()))
}
} else {
Err(ServiceError::InvalidResponse(res.status()))
}
}
async fn check_user(&self, user: String) -> Result<UserID, ServiceError> {
let username = format!("jm_{}", user);
let user = self.get_mxid(username.clone());
let req = self
.client
.get(self.matrix_url.join("/_matrix/client/r0/account/whoami")?)
.bearer_auth(self.matrix_token.clone())
.query(&user);
let res = req.send().await?;
if res.status().is_success() {
let mxid: UserID = res.json().await?;
Ok(mxid)
} else {
let mxid = self.register_user(username).await?;
Ok(mxid)
}
}
async fn register_user(&self, username: String) -> Result<UserID, ServiceError> {
let req = self
.client
.post(self.matrix_url.join("/_matrix/client/r0/register")?)
.bearer_auth(self.matrix_token.clone())
.json(&RegisterRequest::new(username));
let res = req.send().await?;
if res.status().is_success() {
let user: UserID = res.json().await?;
Ok(user)
} else {
Err(ServiceError::InvalidResponse(res.status()))
}
}
async fn join_room(&self, user: &UserID) -> Result<String, ServiceError> {
let req = self
.client
.post(
self.matrix_url
.join("/_matrix/client/r0/join/%23memes%3Atilera.org")?,
)
.bearer_auth(self.matrix_token.clone())
.query(user);
let res = req.send().await?;
if res.status().is_success() {
let room: RoomID = res.json().await?;
Ok(room.room_id)
} else {
Err(ServiceError::InvalidResponse(res.status()))
}
}
fn get_mxid(&self, username: String) -> UserID {
UserID {
user_id: format!("@{}:{}", username, self.matrix_domain.clone()),
}
}
}
impl RegisterRequest {
pub fn new(username: String) -> Self {
Self {
reg_type: "m.login.application_service".to_string(),
username,
}
}
}
impl Meme {
pub fn calc_txid(&self, user: String) -> String {
let txid = format!("{}/{}/{}/{}", user, self.category, self.filename, self.cid);
urlencoding::encode(txid.as_str()).into_owned()
}
}

View file

@ -1,4 +1,4 @@
use serde::{Deserialize, Serialize}; use serde::Serialize;
#[derive(Serialize)] #[derive(Serialize)]
pub struct Meme { pub struct Meme {
@ -7,7 +7,7 @@ pub struct Meme {
pub userid: String, pub userid: String,
pub username: String, pub username: String,
pub category: String, pub category: String,
pub timestamp: i64, pub timestamp: i32,
pub ipfs: String, pub ipfs: String,
} }
@ -26,6 +26,11 @@ pub struct User {
pub dayuploads: i32, pub dayuploads: i32,
} }
#[derive(Serialize)]
pub struct Count {
pub count: i64,
}
pub enum UserIdentifier { pub enum UserIdentifier {
Id(String), Id(String),
Token(String), Token(String),
@ -33,9 +38,24 @@ pub enum UserIdentifier {
Null, Null,
} }
#[derive(Deserialize)] pub struct MemeOptions {
pub struct MemeFilter {
pub category: Option<String>, pub category: Option<String>,
pub user: Option<String>, pub user_id: Option<String>,
pub username: Option<String>,
pub search: 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,145 +1,178 @@
use crate::ipfs::IPFSFile; use crate::ipfs::IPFSFile;
use crate::models::{Category, Meme, MemeFilter, User, UserIdentifier}; use crate::models::{Category, Count, Meme, MemeOptions, User, UserIdentifier};
use sqlx::mysql::MySqlRow; use crate::JMServiceInner;
use sqlx::{MySqlPool, Result, Row}; use sqlx::postgres::PgRow;
use sqlx::{Result, Row};
impl Meme { impl JMServiceInner {
pub async fn get(id: i32, pool: &MySqlPool) -> Result<Option<Self>> { pub async fn get_meme(&self, id: i32) -> Result<Option<Meme>> {
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) let q: Option<Meme> = sqlx::query("SELECT memes.id, userid, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.userid = users.id AND memes.id=$1").bind(id)
.map(|row: MySqlRow| Self { .map(|row: PgRow| Meme {
id: row.get("id"), id: row.get("id"),
filename: row.get("filename"), filename: row.get("filename"),
username: row.get("name"), username: row.get("name"),
userid: row.get("user"), userid: row.get("userid"),
category: row.get("category"), category: row.get("category"),
timestamp: row.get("ts"), timestamp: row.get("ts"),
ipfs: row.get("cid"), ipfs: row.get("cid"),
}) })
.fetch_optional(pool).await?; .fetch_optional(&self.db_pool).await?;
Ok(q) Ok(q)
} }
pub async fn get_all(filter: MemeFilter, pool: &MySqlPool) -> Result<Vec<Self>> { pub async fn get_memes(&self, filter: MemeOptions) -> Result<Vec<Meme>> {
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") let q: Vec<Meme> = sqlx::query("SELECT memes.id, userid, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.userid = users.id AND (category LIKE $1 AND name LIKE $2 AND filename LIKE $3 AND memes.userid LIKE $4 AND memes.id > $5) ORDER BY memes.id LIMIT $6")
.bind(filter.category.unwrap_or_else(|| String::from("%"))) .bind(filter.category.unwrap_or_else(|| String::from("%")))
.bind(format!("%{}%", filter.user.unwrap_or_else(String::new))) .bind(format!("%{}%", filter.username.unwrap_or_default()))
.bind(format!("%{}%", filter.search.unwrap_or_else(String::new))) .bind(format!("%{}%", filter.search.unwrap_or_default()))
.map(|row: MySqlRow| Self { .bind(filter.user_id.unwrap_or_else(|| String::from("%")))
.bind(filter.after.unwrap_or(0))
.bind(filter.limit)
.map(|row: PgRow| Meme {
id: row.get("id"), id: row.get("id"),
filename: row.get("filename"), filename: row.get("filename"),
username: row.get("name"), username: row.get("name"),
userid: row.get("user"), userid: row.get("userid"),
category: row.get("category"), category: row.get("category"),
timestamp: row.get("ts"), timestamp: row.get("ts"),
ipfs: row.get("cid"), ipfs: row.get("cid"),
}) })
.fetch_all(pool).await?; .fetch_all(&self.db_pool).await?;
Ok(q) Ok(q)
} }
pub async fn get_random(filter: MemeFilter, pool: &MySqlPool) -> Result<Self> { pub async fn get_random_meme(&self, filter: MemeOptions) -> Result<Meme> {
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") let q: Meme = sqlx::query("SELECT memes.id, userid, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.userid = users.id AND (category LIKE $1 AND name LIKE $2 AND filename LIKE $3 AND memes.userid LIKE $4 AND memes.id > $5) ORDER BY RANDOM() LIMIT 1")
.bind(filter.category.unwrap_or_else(|| String::from("%"))) .bind(filter.category.unwrap_or_else(|| String::from("%")))
.bind(format!("%{}%", filter.user.unwrap_or_else(String::new))) .bind(format!("%{}%", filter.username.unwrap_or_default()))
.bind(format!("%{}%", filter.search.unwrap_or_else(String::new))) .bind(format!("%{}%", filter.search.unwrap_or_default()))
.map(|row: MySqlRow| Self { .bind(filter.user_id.unwrap_or_else(|| String::from("%")))
.bind(filter.after.unwrap_or(0))
.map(|row: PgRow| Meme {
id: row.get("id"), id: row.get("id"),
filename: row.get("filename"), filename: row.get("filename"),
username: row.get("name"), username: row.get("name"),
userid: row.get("user"), userid: row.get("userid"),
category: row.get("category"), category: row.get("category"),
timestamp: row.get("ts"), timestamp: row.get("ts"),
ipfs: row.get("cid"), ipfs: row.get("cid"),
}) })
.fetch_one(pool).await?; .fetch_one(&self.db_pool).await?;
Ok(q) Ok(q)
} }
pub async fn count_memes(&self, filter: MemeOptions) -> Result<Count> {
let q: Count = sqlx::query(
"SELECT COUNT(id) AS count FROM memes WHERE category LIKE $1 AND userid LIKE $2",
)
.bind(filter.category.unwrap_or_else(|| String::from("%")))
.bind(filter.user_id.unwrap_or_else(|| String::from("%")))
.map(|row: PgRow| Count {
count: row.get("count"),
})
.fetch_one(&self.db_pool)
.await?;
Ok(q)
} }
impl Category { pub async fn get_user_meme(&self, user_id: String, filename: String) -> Result<Option<Meme>> {
pub async fn get(id: &String, pool: &MySqlPool) -> Result<Option<Self>> { let q: Option<Meme> = sqlx::query("SELECT memes.id, userid, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.userid = users.id AND memes.userid = $1 AND filename = $2 ORDER BY memes.id DESC")
let q: Option<Self> = sqlx::query("SELECT * FROM categories WHERE id=?") .bind(user_id)
.bind(filename)
.map(|row: PgRow| Meme {
id: row.get("id"),
filename: row.get("filename"),
username: row.get("name"),
userid: row.get("userid"),
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=$1")
.bind(id) .bind(id)
.map(|row: MySqlRow| Self { .map(|row: PgRow| Category {
id: row.get("id"), id: row.get("id"),
name: row.get("name"), name: row.get("name"),
}) })
.fetch_optional(pool) .fetch_optional(&self.db_pool)
.await?; .await?;
Ok(q) Ok(q)
} }
pub async fn get_all(pool: &MySqlPool) -> Result<Vec<Self>> { pub async fn get_categories(&self) -> Result<Vec<Category>> {
let q: Vec<Self> = sqlx::query("SELECT * FROM categories ORDER BY num") let q: Vec<Category> = sqlx::query("SELECT * FROM categories ORDER BY num")
.map(|row: MySqlRow| Self { .map(|row: PgRow| Category {
id: row.get("id"), id: row.get("id"),
name: row.get("name"), name: row.get("name"),
}) })
.fetch_all(pool) .fetch_all(&self.db_pool)
.await?; .await?;
Ok(q) Ok(q)
} }
pub async fn add_meme( pub async fn get_user(&self, identifier: UserIdentifier) -> Result<Option<User>> {
let query = match identifier {
UserIdentifier::Id(id) => sqlx::query("SELECT id, name, COALESCE(MD5(token), '0') AS hash, uploads FROM (SELECT id, name, COALESCE(count.uploads, 0) AS uploads FROM users LEFT JOIN (SELECT userid, COUNT(*)::integer AS uploads FROM memes WHERE DATE(timestamp) = CURRENT_DATE GROUP BY (userid)) AS count ON users.id = count.userid) AS users LEFT JOIN token ON users.id = token.uid WHERE users.id = $1").bind(id),
UserIdentifier::Token(token) => sqlx::query("SELECT id, name, COALESCE(MD5(token), '0') AS hash, uploads FROM (SELECT id, name, COALESCE(count.uploads, 0) AS uploads FROM users LEFT JOIN (SELECT userid, COUNT(*)::integer AS uploads FROM memes WHERE DATE(timestamp) = CURRENT_DATE GROUP BY (userid)) AS count ON users.id = count.userid) AS users LEFT JOIN token ON users.id = token.uid WHERE token = $1").bind(token),
UserIdentifier::Username(name) => sqlx::query("SELECT id, name, COALESCE(MD5(token), '0') AS hash, uploads FROM (SELECT id, name, COALESCE(count.uploads, 0) AS uploads FROM users LEFT JOIN (SELECT userid, COUNT(*)::integer AS uploads FROM memes WHERE DATE(timestamp) = CURRENT_DATE GROUP BY (userid)) AS count ON users.id = count.userid) AS users LEFT JOIN token ON users.id = token.uid WHERE name = $1").bind(name),
UserIdentifier::Null => sqlx::query("SELECT id, name, '0' AS hash, 0 AS uploads FROM users WHERE id = '000'"),
};
let q: Option<User> = query
.map(|row: PgRow| User {
id: row.get("id"),
name: row.get("name"),
userdir: row.get("id"),
tokenhash: row.get("hash"),
dayuploads: row.get("uploads"),
})
.fetch_optional(&self.db_pool)
.await?;
Ok(q)
}
pub async fn get_users(&self) -> Result<Vec<User>> {
let q: Vec<User> = sqlx::query("SELECT id, name, COALESCE(MD5(token), '0') AS hash, uploads FROM (SELECT id, name, COALESCE(count.uploads, 0) AS uploads FROM users LEFT JOIN (SELECT userid, COUNT(*)::integer AS uploads FROM memes WHERE DATE(timestamp) = CURRENT_DATE GROUP BY (userid)) AS count ON users.id = count.userid) AS users LEFT JOIN token ON users.id = token.uid")
.map(|row: PgRow| User {
id: row.get("id"),
name: row.get("name"),
userdir: row.get("id"),
tokenhash: row.get("hash"),
dayuploads: row.get("uploads"),
})
.fetch_all(&self.db_pool).await?;
Ok(q)
}
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, &self,
user: &User, user: &User,
file: &IPFSFile, file: &IPFSFile,
ip: &String, ip: &String,
pool: &MySqlPool, category: &Category,
) -> Result<u64> { ) -> Result<i64> {
let mut tx = pool.begin().await?; let mut tx = self.db_pool.begin().await?;
sqlx::query("INSERT INTO memes (filename, user, category, timestamp, ip, cid) VALUES (?, ?, ?, NOW(), ?, ?)") sqlx::query("INSERT INTO memes (filename, userid, category, timestamp, ip, cid) VALUES ($1, $2, $3, NOW(), $4, $5)")
.bind(&file.name) .bind(&file.name)
.bind(&user.id) .bind(&user.id)
.bind(&self.id) .bind(&category.id)
.bind(ip) .bind(ip)
.bind(&file.hash) .bind(&file.hash)
.execute(&mut tx).await?; .execute(&mut tx).await?;
let id: u64 = sqlx::query("SELECT LAST_INSERT_ID() as id") let id: i64 = sqlx::query("SELECT LASTVAL() as id")
.map(|row: MySqlRow| row.get("id")) .map(|row: PgRow| row.get("id"))
.fetch_one(&mut tx) .fetch_one(&mut tx)
.await?; .await?;
tx.commit().await?; tx.commit().await?;
Ok(id) Ok(id)
} }
} }
impl User {
pub async fn get(identifier: UserIdentifier, pool: &MySqlPool) -> Result<Option<Self>> {
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 {
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)
}
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 {
id: row.get("id"),
name: row.get("name"),
userdir: row.get("id"),
tokenhash: row.get("hash"),
dayuploads: row.get("uploads"),
})
.fetch_all(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?;
Ok(user)
}
}

View file

@ -2,37 +2,13 @@ use std::convert::Infallible;
use axum::{ use axum::{
body::{Bytes, Full}, body::{Bytes, Full},
extract::{multipart::MultipartError, rejection::QueryRejection},
response::IntoResponse, response::IntoResponse,
Json, Json,
}; };
use reqwest::StatusCode; use reqwest::StatusCode;
use thiserror::Error;
use super::models::ErrorResponse; use super::models::ErrorResponse;
use crate::ipfs::error::IPFSError; use crate::error::{APIError, 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("IPFS error: {0}")]
Ipfs(#[from] IPFSError),
#[error("Query rejection: {0}")]
Query(#[from] QueryRejection),
}
impl ErrorResponse { impl ErrorResponse {
fn new(status: StatusCode, message: Option<String>) -> Self { fn new(status: StatusCode, message: Option<String>) -> Self {
@ -55,18 +31,47 @@ impl IntoResponse for APIError {
sqlx::Error::RowNotFound => ErrorResponse::new(StatusCode::NOT_FOUND, None), sqlx::Error::RowNotFound => ErrorResponse::new(StatusCode::NOT_FOUND, None),
_ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, None), _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, None),
}, },
APIError::Multipart(_) => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, None), APIError::Multipart(_) => ErrorResponse::new(
StatusCode::BAD_REQUEST,
Some("Invalid Multipart Form".to_string()),
),
APIError::BadRequest(err) => ErrorResponse::new(StatusCode::BAD_REQUEST, Some(err)), APIError::BadRequest(err) => ErrorResponse::new(StatusCode::BAD_REQUEST, Some(err)),
APIError::Unauthorized(err) => ErrorResponse::new(StatusCode::UNAUTHORIZED, Some(err)), APIError::Unauthorized(err) => ErrorResponse::new(StatusCode::UNAUTHORIZED, Some(err)),
APIError::Forbidden(err) => ErrorResponse::new(StatusCode::FORBIDDEN, Some(err)), APIError::Forbidden(err) => ErrorResponse::new(StatusCode::FORBIDDEN, Some(err)),
APIError::NotFound(err) => ErrorResponse::new(StatusCode::NOT_FOUND, Some(err)), APIError::NotFound(err) => ErrorResponse::new(StatusCode::NOT_FOUND, Some(err)),
APIError::Internal(err) => { APIError::Internal(err) => {
ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, Some(err)) ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, Some(err))
} },
APIError::Ipfs(_) => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, None), APIError::Service(err) => ErrorResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
Some(err.get_response_message()),
),
APIError::Query(_) => ErrorResponse::new(StatusCode::BAD_REQUEST, None), APIError::Query(_) => ErrorResponse::new(StatusCode::BAD_REQUEST, None),
APIError::Decode(_) => ErrorResponse::new(StatusCode::BAD_REQUEST, None),
}; };
let status = res.status; let status = res.status;
(status, Json(res)).into_response() (status, Json(res)).into_response()
} }
} }
impl ServiceError {
fn get_response_message(&self) -> String {
match self {
ServiceError::Reqwest(err) => {
format!(
"URL: {}, Status Code: {}",
match err.url() {
Some(url) => url.to_string(),
None => "No URL in error".to_string(),
},
match err.status() {
Some(code) => code.to_string(),
None => "No Status Code in error".to_string(),
}
)
},
ServiceError::Url(_) => "URL parse error".to_string(),
ServiceError::InvalidResponse(code) => format!("Invalid response code: {}", code),
}
}
}

View file

@ -7,7 +7,7 @@ use axum::extract::{FromRequest, RequestParts};
pub use routes::routes; pub use routes::routes;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use self::error::APIError; use crate::error::APIError;
pub struct Query<T>(pub T); pub struct Query<T>(pub T);

View file

@ -1,7 +1,7 @@
use reqwest::StatusCode; use reqwest::StatusCode;
use serde::{Deserialize, Serialize, Serializer}; 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> fn serialize_status<S>(x: &StatusCode, s: S) -> Result<S::Ok, S::Error>
where 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,8 +1,7 @@
use crate::config::ConfVars;
use crate::ipfs::IPFSFile; use crate::ipfs::IPFSFile;
use crate::lib::ExtractIP; use crate::lib::ExtractIP;
use crate::models::{Category, Meme, MemeFilter, User};
use crate::v1::models::*; use crate::v1::models::*;
use crate::JMService;
use axum::extract::{ContentLengthLimit, Extension, Multipart}; use axum::extract::{ContentLengthLimit, Extension, Multipart};
use axum::handler::{get, post}; use axum::handler::{get, post};
@ -10,21 +9,20 @@ use axum::response::IntoResponse;
use axum::routing::BoxRoute; use axum::routing::BoxRoute;
use axum::{Json, Router}; use axum::{Json, Router};
use hyper::StatusCode; use hyper::StatusCode;
use sqlx::MySqlPool;
use super::error::APIError;
use super::Query; use super::Query;
use crate::error::APIError;
async fn meme( async fn meme(
Query(params): Query<MemeIDQuery>, Query(params): Query<MemeIDQuery>,
Extension(db_pool): Extension<MySqlPool>, Extension(service): Extension<JMService>,
Extension(vars): Extension<ConfVars>,
) -> Result<impl IntoResponse, APIError> { ) -> Result<impl IntoResponse, APIError> {
let meme = V1Meme::new( let meme = V1Meme::new(
Meme::get(params.id, &db_pool) service
.get_meme(params.id)
.await? .await?
.ok_or_else(|| APIError::NotFound("Meme not found".to_string()))?, .ok_or_else(|| APIError::NotFound("Meme not found".to_string()))?,
vars.cdn, service.ext_cdn_url(),
); );
Ok(Json(MemeResponse { Ok(Json(MemeResponse {
status: 200, status: 200,
@ -35,13 +33,13 @@ async fn meme(
async fn memes( async fn memes(
Query(params): Query<MemeFilter>, Query(params): Query<MemeFilter>,
Extension(db_pool): Extension<MySqlPool>, Extension(service): Extension<JMService>,
Extension(vars): Extension<ConfVars>,
) -> Result<impl IntoResponse, APIError> { ) -> Result<impl IntoResponse, APIError> {
let memes = Meme::get_all(params, &db_pool) let memes = service
.get_memes(params.into())
.await? .await?
.into_iter() .into_iter()
.map(|meme| V1Meme::new(meme, vars.cdn.clone())) .map(|meme| V1Meme::new(meme, service.ext_cdn_url()))
.collect(); .collect();
Ok(Json(MemesResponse { Ok(Json(MemesResponse {
status: 200, status: 200,
@ -52,9 +50,10 @@ async fn memes(
async fn category( async fn category(
Query(params): Query<IDQuery>, Query(params): Query<IDQuery>,
Extension(db_pool): Extension<MySqlPool>, Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> { ) -> Result<impl IntoResponse, APIError> {
let category = Category::get(&params.id, &db_pool) let category = service
.get_category(&params.id)
.await? .await?
.ok_or_else(|| APIError::NotFound("Category not found".to_string()))?; .ok_or_else(|| APIError::NotFound("Category not found".to_string()))?;
Ok(Json(CategoryResponse { Ok(Json(CategoryResponse {
@ -65,9 +64,9 @@ async fn category(
} }
async fn categories( async fn categories(
Extension(db_pool): Extension<MySqlPool>, Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> { ) -> Result<impl IntoResponse, APIError> {
let categories = Category::get_all(&db_pool).await?; let categories = service.get_categories().await?;
Ok(Json(CategoriesResponse { Ok(Json(CategoriesResponse {
status: 200, status: 200,
error: None, error: None,
@ -77,9 +76,10 @@ async fn categories(
async fn user( async fn user(
Query(params): Query<UserIDQuery>, Query(params): Query<UserIDQuery>,
Extension(db_pool): Extension<MySqlPool>, Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> { ) -> Result<impl IntoResponse, APIError> {
let user = User::get(params.into(), &db_pool) let user = service
.get_user(params.into())
.await? .await?
.ok_or_else(|| APIError::NotFound("User not found".to_string()))?; .ok_or_else(|| APIError::NotFound("User not found".to_string()))?;
Ok(Json(UserResponse { Ok(Json(UserResponse {
@ -89,8 +89,8 @@ async fn user(
})) }))
} }
async fn users(Extension(db_pool): Extension<MySqlPool>) -> Result<impl IntoResponse, APIError> { async fn users(Extension(service): Extension<JMService>) -> Result<impl IntoResponse, APIError> {
let users = User::get_all(&db_pool).await?; let users = service.get_users().await?;
Ok(Json(UsersResponse { Ok(Json(UsersResponse {
status: 200, status: 200,
error: None, error: None,
@ -100,10 +100,12 @@ async fn users(Extension(db_pool): Extension<MySqlPool>) -> Result<impl IntoResp
async fn random( async fn random(
Query(params): Query<MemeFilter>, Query(params): Query<MemeFilter>,
Extension(db_pool): Extension<MySqlPool>, Extension(service): Extension<JMService>,
Extension(vars): Extension<ConfVars>,
) -> Result<impl IntoResponse, APIError> { ) -> Result<impl IntoResponse, APIError> {
let random = V1Meme::new(Meme::get_random(params, &db_pool).await?, vars.cdn); let random = V1Meme::new(
service.get_random_meme(params.into()).await?,
service.ext_cdn_url(),
);
Ok(Json(MemeResponse { Ok(Json(MemeResponse {
status: 200, status: 200,
error: None, error: None,
@ -113,16 +115,13 @@ async fn random(
async fn upload( async fn upload(
ContentLengthLimit(mut form): ContentLengthLimit<Multipart, { 1024 * 1024 * 1024 }>, ContentLengthLimit(mut form): ContentLengthLimit<Multipart, { 1024 * 1024 * 1024 }>,
Extension(db_pool): Extension<MySqlPool>, Extension(service): Extension<JMService>,
Extension(vars): Extension<ConfVars>,
ExtractIP(ip): ExtractIP, ExtractIP(ip): ExtractIP,
) -> Result<impl IntoResponse, APIError> { ) -> Result<impl IntoResponse, APIError> {
let mut category: Option<String> = None; let mut category: Option<String> = None;
let mut token: Option<String> = None; let mut token: Option<String> = None;
let mut files: Vec<IPFSFile> = vec![]; let mut files: Vec<IPFSFile> = vec![];
let ipfs = vars.ipfs_client()?;
while let Some(field) = form.next_field().await? { while let Some(field) = form.next_field().await? {
match field.name().ok_or_else(|| { match field.name().ok_or_else(|| {
APIError::BadRequest("A multipart-form field is missing a name".to_string()) APIError::BadRequest("A multipart-form field is missing a name".to_string())
@ -136,16 +135,17 @@ async fn upload(
APIError::BadRequest("A file field has no filename".to_string()) APIError::BadRequest("A file field has no filename".to_string())
})? })?
.to_string(); .to_string();
let file = ipfs.add(field.bytes().await?, filename).await?; let file = service.ipfs_add(field.bytes().await?, filename).await?;
files.push(file); files.push(file);
} },
_ => (), _ => (),
} }
} }
let token = token.ok_or_else(|| APIError::Unauthorized("Missing token".to_string()))?; 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 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? .await?
.ok_or_else(|| APIError::Forbidden("token not existing".to_string()))?; .ok_or_else(|| APIError::Forbidden("token not existing".to_string()))?;
let total = (user.dayuploads as isize) + (files.len() as isize); let total = (user.dayuploads as isize) + (files.len() as isize);
@ -154,7 +154,8 @@ async fn upload(
return Err(APIError::Forbidden("Upload limit reached".to_string())); return Err(APIError::Forbidden("Upload limit reached".to_string()));
} }
let cat = Category::get(&category, &db_pool) let cat = service
.get_category(&category)
.await? .await?
.ok_or_else(|| APIError::BadRequest("Category not existing".to_string()))?; .ok_or_else(|| APIError::BadRequest("Category not existing".to_string()))?;
@ -163,16 +164,24 @@ async fn upload(
let mut links: Vec<String> = vec![]; let mut links: Vec<String> = vec![];
for f in files { 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 { if res == 0 {
return Err(APIError::Internal("Database insertion error".to_string())); return Err(APIError::Internal("Database insertion error".to_string()));
} }
service
ipfs.pin(f.hash).await?; .add_meme(
cat.id.clone(),
f.name.clone(),
f.hash.clone(),
user.id.clone(),
res,
)
.await?;
service.ipfs_pin(f.hash).await?;
links.push(format!( links.push(format!(
"{}/{}/{}", "{}/{}/{}",
vars.cdn, service.ext_cdn_url(),
user.id.clone(), user.id.clone(),
f.name.clone() f.name.clone()
)); ));

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

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

76
src/v2/models.rs Normal file
View file

@ -0,0 +1,76 @@
use crate::models::{Meme, MemeOptions, User};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct V2Meme {
pub id: i32,
pub filename: String,
pub ipfs: String,
pub category: String,
pub user: String,
pub timestamp: i32,
}
#[derive(Serialize)]
pub struct V2User {
pub id: String,
pub name: String,
pub dayuploads: i32,
}
#[derive(Deserialize)]
pub struct MemeFilterQuery {
pub category: Option<String>,
pub user: Option<String>,
pub search: Option<String>,
pub limit: Option<i32>,
pub after: Option<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,
}
}
}
impl From<MemeFilterQuery> for MemeOptions {
fn from(query: MemeFilterQuery) -> Self {
Self {
category: query.category,
user_id: query.user,
username: None,
search: query.search,
limit: Some(query.limit.unwrap_or(100)),
after: query.after,
}
}
}

165
src/v2/routes.rs Normal file
View file

@ -0,0 +1,165 @@
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::{MemeFilterQuery, 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(
Query(filter): Query<MemeFilterQuery>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(
service
.get_memes(filter.into())
.await?
.into_iter()
.map(V2Meme::from)
.collect::<Vec<V2Meme>>(),
))
}
async fn get_random_meme(
Query(filter): Query<MemeFilterQuery>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(V2Meme::from(
service.get_random_meme(filter.into()).await?,
)))
}
async fn count_memes(
Query(filter): Query<MemeFilterQuery>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(service.count_memes(filter.into()).await?))
}
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(
Query(filter): Query<MemeFilterQuery>,
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: filter.limit,
after: filter.after,
})
.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()))?,
)))
}
fn meme_routes() -> Router<BoxRoute> {
Router::new()
.route("/", get(get_memes))
.route("/:meme_id", get(get_meme))
.route("/random", get(get_random_meme))
.route("/count", get(count_memes))
.boxed()
}
fn category_routes() -> Router<BoxRoute> {
Router::new()
.route("/", get(get_categories))
.route("/:category_id", get(get_category))
.boxed()
}
fn user_routes() -> Router<BoxRoute> {
Router::new()
.route("/", get(get_users))
.route("/:user_id", get(get_user))
.route("/:user_id/memes", get(get_user_memes))
.route("/:user_id/memes/:filename", get(get_user_meme))
.boxed()
}
pub fn routes() -> Router<BoxRoute> {
Router::new()
.nest("/memes", meme_routes())
.nest("/categories", category_routes())
.nest("/users", user_routes())
.boxed()
}