Compare commits
11 commits
Author | SHA1 | Date | |
---|---|---|---|
916f1b4a45 | |||
b42e35da45 | |||
a031352ef9 | |||
46773e9d17 | |||
9bed0de064 | |||
bf67cf62f9 | |||
c4a8251147 | |||
e367c341b7 | |||
1577b9a1ca | |||
a50d157394 | |||
fbca4b7c06 |
23 changed files with 1505 additions and 247 deletions
|
@ -1 +1,3 @@
|
|||
- Return API error response on missing query parameter
|
||||
- Refactoring
|
||||
- Initial V2 API
|
||||
- Added Dockerfilegit
|
|
@ -14,7 +14,7 @@ tower = { version = "0.4", features = ["util", "timeout"] }
|
|||
tower-http = { version = "0.1", features = ["add-extension", "trace", "fs", "set-header"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.51"
|
||||
sqlx = { version = "0.3", features = [ "mysql" ] }
|
||||
sqlx = { version = "0.3", features = [ "postgres" ] }
|
||||
rand = "0.8.0"
|
||||
structopt = "0.3.22"
|
||||
toml = "0.5.8"
|
||||
|
|
21
Dockerfile
Normal file
21
Dockerfile
Normal 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"]
|
|
@ -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 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 token (uid varchar(255) UNIQUE NOT NULL, token varchar(255), FOREIGN KEY (uid) 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 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
701
spec/v2.json
Normal 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 '"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,19 +4,22 @@ use axum::{
|
|||
body::{Bytes, Empty},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use hyper::header::InvalidHeaderValue;
|
||||
use reqwest::StatusCode;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::ipfs::error::IPFSError;
|
||||
use crate::error::ServiceError;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum CDNError {
|
||||
#[error("SQL error: {0}")]
|
||||
Sql(#[from] sqlx::Error),
|
||||
#[error("IPFS error: {0}")]
|
||||
Ipfs(#[from] IPFSError),
|
||||
#[error("JMService error: {0}")]
|
||||
Service(#[from] ServiceError),
|
||||
#[error("Decode error: {0}")]
|
||||
Decode(#[from] FromUtf8Error),
|
||||
#[error("Header error: {0}")]
|
||||
Header(#[from] InvalidHeaderValue),
|
||||
#[error("Internal server error")]
|
||||
Internal,
|
||||
}
|
||||
|
|
|
@ -7,14 +7,13 @@ use axum::{
|
|||
routing::BoxRoute,
|
||||
Router,
|
||||
};
|
||||
use headers::{ContentType, HeaderMapExt};
|
||||
use headers::{ContentType, HeaderMapExt, HeaderValue};
|
||||
use reqwest::{
|
||||
header::{HeaderName, CONTENT_LENGTH},
|
||||
StatusCode,
|
||||
};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use crate::config::ConfVars;
|
||||
use crate::JMService;
|
||||
|
||||
use self::{
|
||||
error::CDNError,
|
||||
|
@ -35,13 +34,12 @@ pub fn routes() -> Router<BoxRoute> {
|
|||
|
||||
async fn image(
|
||||
Path((user, filename)): Path<(String, String)>,
|
||||
Extension(db_pool): Extension<MySqlPool>,
|
||||
Extension(vars): Extension<ConfVars>,
|
||||
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 ipfs = vars.ipfs_client()?;
|
||||
let res = ipfs.cat(cid).await?;
|
||||
let cid = sql::get_cid(user, filename.clone(), &service.db_pool).await?;
|
||||
let ipfs_path = format!("/ipfs/{}", cid);
|
||||
let res = service.ipfs_cat(cid).await?;
|
||||
let clength = res
|
||||
.headers()
|
||||
.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());
|
||||
headers.typed_insert(ctype);
|
||||
headers.insert(CONTENT_LENGTH, clength.clone());
|
||||
headers.insert("X-Ipfs-Path", HeaderValue::from_str(ipfs_path.as_str())?);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
|
@ -59,23 +58,20 @@ async fn image(
|
|||
))
|
||||
}
|
||||
|
||||
async fn users(
|
||||
Extension(db_pool): Extension<MySqlPool>,
|
||||
Extension(vars): Extension<ConfVars>,
|
||||
) -> 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: vars.cdn,
|
||||
prefix: service.int_cdn_url(),
|
||||
suffix: "/".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
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(),
|
||||
|
|
|
@ -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 =
|
||||
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(filename)
|
||||
.map(|row: MySqlRow| row.get("cid"))
|
||||
.map(|row: PgRow| row.get("cid"))
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
pub async fn get_memes(user: String, pool: &MySqlPool) -> Result<Vec<String>> {
|
||||
let q: Vec<String> = sqlx::query("SELECT filename FROM memes WHERE user = ? ORDER BY filename")
|
||||
.bind(user)
|
||||
.map(|row: MySqlRow| row.get("filename"))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
pub async fn get_memes(user: String, pool: &PgPool) -> Result<Vec<String>> {
|
||||
let q: Vec<String> =
|
||||
sqlx::query("SELECT filename FROM memes WHERE userid = $1 ORDER BY filename")
|
||||
.bind(user)
|
||||
.map(|row: PgRow| row.get("filename"))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
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")
|
||||
.map(|row: MySqlRow| row.get("id"))
|
||||
.map(|row: PgRow| row.get("id"))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(q)
|
||||
|
|
|
@ -1,34 +1,44 @@
|
|||
use reqwest::Url;
|
||||
use serde::Deserialize;
|
||||
use std::net::SocketAddr;
|
||||
use sqlx::PgPool;
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use crate::{error::JMError, JMService, JMServiceInner};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Config {
|
||||
pub addr: SocketAddr,
|
||||
pub database: String,
|
||||
pub cdn: String,
|
||||
pub ipfs_api: Url,
|
||||
}
|
||||
|
||||
pub struct ConfVars {
|
||||
pub cdn: String,
|
||||
pub int_cdn: String,
|
||||
pub ext_cdn: String,
|
||||
pub ipfs_api: Url,
|
||||
pub matrix_url: Url,
|
||||
pub matrix_token: String,
|
||||
pub matrix_domain: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn vars(&self) -> ConfVars {
|
||||
ConfVars {
|
||||
cdn: self.cdn.clone(),
|
||||
ipfs_api: self.ipfs_api.clone(),
|
||||
}
|
||||
pub fn service(&self, db_pool: PgPool) -> 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(),
|
||||
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 {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
cdn: self.cdn.clone(),
|
||||
ipfs_api: self.ipfs_api.clone(),
|
||||
}
|
||||
impl JMServiceInner {
|
||||
pub fn int_cdn_url(&self) -> String {
|
||||
self.int_cdn.clone()
|
||||
}
|
||||
|
||||
pub fn ext_cdn_url(&self) -> String {
|
||||
self.ext_cdn.clone()
|
||||
}
|
||||
}
|
||||
|
|
41
src/error.rs
41
src/error.rs
|
@ -1,4 +1,9 @@
|
|||
use std::string::FromUtf8Error;
|
||||
|
||||
use axum::extract::{multipart::MultipartError, rejection::QueryRejection};
|
||||
use hyper::StatusCode;
|
||||
use thiserror::Error;
|
||||
use url::ParseError;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum JMError {
|
||||
|
@ -10,4 +15,40 @@ pub enum JMError {
|
|||
Database(#[from] sqlx::Error),
|
||||
#[error("Axum error: {0}")]
|
||||
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),
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
}
|
|
@ -3,15 +3,11 @@ use std::time::Duration;
|
|||
use axum::body::Bytes;
|
||||
use reqwest::{
|
||||
multipart::{Form, Part},
|
||||
Client, Response, Url,
|
||||
Response,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::ConfVars;
|
||||
|
||||
use self::error::IPFSError;
|
||||
|
||||
pub(crate) mod error;
|
||||
use crate::{error::ServiceError, JMServiceInner};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct IPFSFile {
|
||||
|
@ -38,24 +34,19 @@ pub struct PinQuery {
|
|||
pub arg: String,
|
||||
}
|
||||
|
||||
pub struct IpfsClient {
|
||||
url: Url,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl IpfsClient {
|
||||
pub async fn cat(&self, cid: String) -> Result<Response, IPFSError> {
|
||||
impl JMServiceInner {
|
||||
pub async fn ipfs_cat(&self, cid: String) -> Result<Response, ServiceError> {
|
||||
let request = self
|
||||
.client
|
||||
.post(self.url.join("/api/v0/cat")?)
|
||||
.post(self.ipfs_url.join("/api/v0/cat")?)
|
||||
.query(&CatQuery::new(cid));
|
||||
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
|
||||
.client
|
||||
.post(self.url.join("/api/v0/add")?)
|
||||
.post(self.ipfs_url.join("/api/v0/add")?)
|
||||
.query(&AddQuery::new(false))
|
||||
.multipart(Form::new().part("file", Part::stream(file).file_name(filename)));
|
||||
let response = request.send().await?;
|
||||
|
@ -63,10 +54,10 @@ impl IpfsClient {
|
|||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn pin(&self, cid: String) -> Result<(), IPFSError> {
|
||||
pub async fn ipfs_pin(&self, cid: String) -> Result<(), ServiceError> {
|
||||
let request = self
|
||||
.client
|
||||
.post(self.url.join("/api/v0/pin/add")?)
|
||||
.post(self.ipfs_url.join("/api/v0/pin/add")?)
|
||||
.query(&PinQuery::new(cid))
|
||||
.timeout(Duration::from_secs(60));
|
||||
request.send().await?;
|
||||
|
@ -91,13 +82,3 @@ impl PinQuery {
|
|||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
27
src/main.rs
27
src/main.rs
|
@ -5,8 +5,9 @@ use axum::{
|
|||
};
|
||||
use config::Config;
|
||||
use error::JMError;
|
||||
use sqlx::MySqlPool;
|
||||
use std::path::PathBuf;
|
||||
use reqwest::{Client, Url};
|
||||
use sqlx::PgPool;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use structopt::StructOpt;
|
||||
use tower_http::{add_extension::AddExtensionLayer, set_header::SetResponseHeaderLayer};
|
||||
|
||||
|
@ -15,9 +16,11 @@ mod config;
|
|||
mod error;
|
||||
mod ipfs;
|
||||
mod lib;
|
||||
mod matrix;
|
||||
mod models;
|
||||
mod sql;
|
||||
mod v1;
|
||||
mod v2;
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct Opt {
|
||||
|
@ -30,19 +33,33 @@ struct Opt {
|
|||
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]
|
||||
async fn main() -> Result<(), JMError> {
|
||||
let opt = Opt::from_args();
|
||||
let config = std::fs::read(&opt.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()
|
||||
.nest("/api/v1", v1::routes())
|
||||
.nest("/api/v2", v2::routes())
|
||||
.nest("/cdn", cdn::routes())
|
||||
.layer(AddExtensionLayer::new(db_pool))
|
||||
.layer(AddExtensionLayer::new(config.vars()))
|
||||
.layer(AddExtensionLayer::new(service))
|
||||
.layer(SetResponseHeaderLayer::<_, Request<Body>>::if_not_present(
|
||||
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
HeaderValue::from_static("*"),
|
||||
|
|
158
src/matrix/mod.rs
Normal file
158
src/matrix/mod.rs
Normal 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()
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Meme {
|
||||
|
@ -7,7 +7,7 @@ pub struct Meme {
|
|||
pub userid: String,
|
||||
pub username: String,
|
||||
pub category: String,
|
||||
pub timestamp: i64,
|
||||
pub timestamp: i32,
|
||||
pub ipfs: String,
|
||||
}
|
||||
|
||||
|
@ -26,6 +26,11 @@ pub struct User {
|
|||
pub dayuploads: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Count {
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
pub enum UserIdentifier {
|
||||
Id(String),
|
||||
Token(String),
|
||||
|
@ -33,9 +38,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
195
src/sql.rs
195
src/sql.rs
|
@ -1,145 +1,178 @@
|
|||
use crate::ipfs::IPFSFile;
|
||||
use crate::models::{Category, Meme, MemeFilter, User, UserIdentifier};
|
||||
use sqlx::mysql::MySqlRow;
|
||||
use sqlx::{MySqlPool, Result, Row};
|
||||
use crate::models::{Category, Count, Meme, MemeOptions, User, UserIdentifier};
|
||||
use crate::JMServiceInner;
|
||||
use sqlx::postgres::PgRow;
|
||||
use sqlx::{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 {
|
||||
impl JMServiceInner {
|
||||
pub async fn get_meme(&self, id: i32) -> Result<Option<Meme>> {
|
||||
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: PgRow| Meme {
|
||||
id: row.get("id"),
|
||||
filename: row.get("filename"),
|
||||
username: row.get("name"),
|
||||
userid: row.get("user"),
|
||||
userid: row.get("userid"),
|
||||
category: row.get("category"),
|
||||
timestamp: row.get("ts"),
|
||||
ipfs: row.get("cid"),
|
||||
})
|
||||
.fetch_optional(pool).await?;
|
||||
.fetch_optional(&self.db_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")
|
||||
pub async fn get_memes(&self, filter: MemeOptions) -> Result<Vec<Meme>> {
|
||||
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(format!("%{}%", filter.user.unwrap_or_else(String::new)))
|
||||
.bind(format!("%{}%", filter.search.unwrap_or_else(String::new)))
|
||||
.map(|row: MySqlRow| Self {
|
||||
.bind(format!("%{}%", filter.username.unwrap_or_default()))
|
||||
.bind(format!("%{}%", filter.search.unwrap_or_default()))
|
||||
.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"),
|
||||
filename: row.get("filename"),
|
||||
username: row.get("name"),
|
||||
userid: row.get("user"),
|
||||
userid: row.get("userid"),
|
||||
category: row.get("category"),
|
||||
timestamp: row.get("ts"),
|
||||
ipfs: row.get("cid"),
|
||||
})
|
||||
.fetch_all(pool).await?;
|
||||
.fetch_all(&self.db_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")
|
||||
pub async fn get_random_meme(&self, filter: MemeOptions) -> Result<Meme> {
|
||||
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(format!("%{}%", filter.user.unwrap_or_else(String::new)))
|
||||
.bind(format!("%{}%", filter.search.unwrap_or_else(String::new)))
|
||||
.map(|row: MySqlRow| Self {
|
||||
.bind(format!("%{}%", filter.username.unwrap_or_default()))
|
||||
.bind(format!("%{}%", filter.search.unwrap_or_default()))
|
||||
.bind(filter.user_id.unwrap_or_else(|| String::from("%")))
|
||||
.bind(filter.after.unwrap_or(0))
|
||||
.map(|row: PgRow| Meme {
|
||||
id: row.get("id"),
|
||||
filename: row.get("filename"),
|
||||
username: row.get("name"),
|
||||
userid: row.get("user"),
|
||||
userid: row.get("userid"),
|
||||
category: row.get("category"),
|
||||
timestamp: row.get("ts"),
|
||||
ipfs: row.get("cid"),
|
||||
})
|
||||
.fetch_one(pool).await?;
|
||||
.fetch_one(&self.db_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=?")
|
||||
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)
|
||||
}
|
||||
|
||||
pub async fn get_user_meme(&self, user_id: String, filename: String) -> Result<Option<Meme>> {
|
||||
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")
|
||||
.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)
|
||||
.map(|row: MySqlRow| Self {
|
||||
.map(|row: PgRow| Category {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
})
|
||||
.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 * FROM categories ORDER BY num")
|
||||
.map(|row: MySqlRow| Self {
|
||||
pub async fn get_categories(&self) -> Result<Vec<Category>> {
|
||||
let q: Vec<Category> = sqlx::query("SELECT * FROM categories ORDER BY num")
|
||||
.map(|row: PgRow| Category {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
})
|
||||
.fetch_all(pool)
|
||||
.fetch_all(&self.db_pool)
|
||||
.await?;
|
||||
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,
|
||||
user: &User,
|
||||
file: &IPFSFile,
|
||||
ip: &String,
|
||||
pool: &MySqlPool,
|
||||
) -> Result<u64> {
|
||||
let mut tx = pool.begin().await?;
|
||||
sqlx::query("INSERT INTO memes (filename, user, category, timestamp, ip, cid) VALUES (?, ?, ?, NOW(), ?, ?)")
|
||||
category: &Category,
|
||||
) -> Result<i64> {
|
||||
let mut tx = self.db_pool.begin().await?;
|
||||
sqlx::query("INSERT INTO memes (filename, userid, category, timestamp, ip, cid) VALUES ($1, $2, $3, NOW(), $4, $5)")
|
||||
.bind(&file.name)
|
||||
.bind(&user.id)
|
||||
.bind(&self.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"))
|
||||
let id: i64 = sqlx::query("SELECT LASTVAL() as id")
|
||||
.map(|row: PgRow| row.get("id"))
|
||||
.fetch_one(&mut tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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::ipfs::error::IPFSError;
|
||||
|
||||
#[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),
|
||||
}
|
||||
use crate::error::{APIError, ServiceError};
|
||||
|
||||
impl ErrorResponse {
|
||||
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),
|
||||
_ => 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::Unauthorized(err) => ErrorResponse::new(StatusCode::UNAUTHORIZED, Some(err)),
|
||||
APIError::Forbidden(err) => ErrorResponse::new(StatusCode::FORBIDDEN, Some(err)),
|
||||
APIError::NotFound(err) => ErrorResponse::new(StatusCode::NOT_FOUND, Some(err)),
|
||||
APIError::Internal(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::Decode(_) => ErrorResponse::new(StatusCode::BAD_REQUEST, None),
|
||||
};
|
||||
let status = res.status;
|
||||
(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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
use crate::config::ConfVars;
|
||||
use crate::ipfs::IPFSFile;
|
||||
use crate::lib::ExtractIP;
|
||||
use crate::models::{Category, Meme, MemeFilter, User};
|
||||
use crate::v1::models::*;
|
||||
use crate::JMService;
|
||||
|
||||
use axum::extract::{ContentLengthLimit, Extension, Multipart};
|
||||
use axum::handler::{get, post};
|
||||
|
@ -10,21 +9,20 @@ 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(vars): Extension<ConfVars>,
|
||||
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()))?,
|
||||
vars.cdn,
|
||||
service.ext_cdn_url(),
|
||||
);
|
||||
Ok(Json(MemeResponse {
|
||||
status: 200,
|
||||
|
@ -35,13 +33,13 @@ async fn meme(
|
|||
|
||||
async fn memes(
|
||||
Query(params): Query<MemeFilter>,
|
||||
Extension(db_pool): Extension<MySqlPool>,
|
||||
Extension(vars): Extension<ConfVars>,
|
||||
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, vars.cdn.clone()))
|
||||
.map(|meme| V1Meme::new(meme, service.ext_cdn_url()))
|
||||
.collect();
|
||||
Ok(Json(MemesResponse {
|
||||
status: 200,
|
||||
|
@ -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(¶ms.id, &db_pool)
|
||||
let category = service
|
||||
.get_category(¶ms.id)
|
||||
.await?
|
||||
.ok_or_else(|| APIError::NotFound("Category not found".to_string()))?;
|
||||
Ok(Json(CategoryResponse {
|
||||
|
@ -65,9 +64,9 @@ async fn category(
|
|||
}
|
||||
|
||||
async fn categories(
|
||||
Extension(db_pool): Extension<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(vars): Extension<ConfVars>,
|
||||
Extension(service): Extension<JMService>,
|
||||
) -> 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 {
|
||||
status: 200,
|
||||
error: None,
|
||||
|
@ -113,16 +115,13 @@ async fn random(
|
|||
|
||||
async fn upload(
|
||||
ContentLengthLimit(mut form): ContentLengthLimit<Multipart, { 1024 * 1024 * 1024 }>,
|
||||
Extension(db_pool): Extension<MySqlPool>,
|
||||
Extension(vars): Extension<ConfVars>,
|
||||
Extension(service): Extension<JMService>,
|
||||
ExtractIP(ip): ExtractIP,
|
||||
) -> Result<impl IntoResponse, APIError> {
|
||||
let mut category: Option<String> = None;
|
||||
let mut token: Option<String> = None;
|
||||
let mut files: Vec<IPFSFile> = vec![];
|
||||
|
||||
let ipfs = vars.ipfs_client()?;
|
||||
|
||||
while let Some(field) = form.next_field().await? {
|
||||
match field.name().ok_or_else(|| {
|
||||
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())
|
||||
})?
|
||||
.to_string();
|
||||
let file = ipfs.add(field.bytes().await?, filename).await?;
|
||||
let file = service.ipfs_add(field.bytes().await?, filename).await?;
|
||||
files.push(file);
|
||||
}
|
||||
},
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
@ -154,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()))?;
|
||||
|
||||
|
@ -163,16 +164,24 @@ 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()));
|
||||
}
|
||||
|
||||
ipfs.pin(f.hash).await?;
|
||||
service
|
||||
.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!(
|
||||
"{}/{}/{}",
|
||||
vars.cdn,
|
||||
service.ext_cdn_url(),
|
||||
user.id.clone(),
|
||||
f.name.clone()
|
||||
));
|
||||
|
|
4
src/v2/mod.rs
Normal file
4
src/v2/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
mod models;
|
||||
mod routes;
|
||||
|
||||
pub use routes::routes;
|
76
src/v2/models.rs
Normal file
76
src/v2/models.rs
Normal 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
165
src/v2/routes.rs
Normal 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()
|
||||
}
|
Loading…
Reference in a new issue