forked from Anvilcraft/jmserver
Compare commits
36 commits
Author | SHA1 | Date | |
---|---|---|---|
916f1b4a45 | |||
b42e35da45 | |||
a031352ef9 | |||
46773e9d17 | |||
9bed0de064 | |||
bf67cf62f9 | |||
c4a8251147 | |||
e367c341b7 | |||
1577b9a1ca | |||
a50d157394 | |||
fbca4b7c06 | |||
9133d1ea9e | |||
467c00410c | |||
abc4345fc1 | |||
1dbdc020b9 | |||
614c23ca53 | |||
8f5cd2b714 | |||
6e51ece84c | |||
b191626ad9 | |||
5aeb937e6c | |||
b6abcd7c90 | |||
ac17b66a8d | |||
8e5387600a | |||
fcce7d07c8 | |||
0977a5ffcb | |||
c90d0b70b9 | |||
8a3e569fa3 | |||
7f7a471ca4 | |||
c479c24928 | |||
fc2fb200f0 | |||
408bf0b3fa | |||
e0eeccefe5 | |||
2e608c7ece | |||
5b28baebb8 | |||
80be0f98f6 | |||
95b9212e83 |
37 changed files with 2799 additions and 303 deletions
23
.drone.yml
Normal file
23
.drone.yml
Normal file
|
@ -0,0 +1,23 @@
|
|||
kind: pipeline
|
||||
type: docker
|
||||
name: release
|
||||
steps:
|
||||
- name: release-linux
|
||||
image: rust
|
||||
commands:
|
||||
- apt update
|
||||
- cargo build --release -v
|
||||
- name: publish
|
||||
image: plugins/gitea-release
|
||||
settings:
|
||||
base_url: https://tilera.xyz/git
|
||||
api_key:
|
||||
from_secret: gitea_token
|
||||
note: CHANGELOG.md
|
||||
title: tag-${DRONE_TAG}
|
||||
files:
|
||||
- target/release/jmserver
|
||||
when:
|
||||
event: tag
|
||||
depends_on:
|
||||
- release-linux
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -2,3 +2,4 @@
|
|||
.idea
|
||||
*.iml
|
||||
Cargo.lock
|
||||
config.toml
|
3
CHANGELOG.md
Normal file
3
CHANGELOG.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
- Refactoring
|
||||
- Initial V2 API
|
||||
- Added Dockerfilegit
|
18
Cargo.toml
18
Cargo.toml
|
@ -7,8 +7,22 @@ edition = "2018"
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
actix-web = "3"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
axum = { version = "0.2.8", features = ["headers", "multipart"] }
|
||||
hyper = "0.14.16"
|
||||
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"
|
||||
reqwest = { version = "0.11", features = ["stream", "multipart", "json"] }
|
||||
new_mime_guess = "3.0.2"
|
||||
headers = "0.3.5"
|
||||
url = {version = "2.2.2", features = ["serde"]}
|
||||
askama = "0.10"
|
||||
urlencoding = "2.1.0"
|
||||
thiserror = "1.0.30"
|
||||
async-trait = "0.1.51"
|
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), 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;
|
470
spec/v1.json
Normal file
470
spec/v1.json
Normal file
|
@ -0,0 +1,470 @@
|
|||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"version": "1.0.0",
|
||||
"title": "JensMemes"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://api.tilera.xyz/jensmemes/v1"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "Meme list response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MemesResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/meme": {
|
||||
"get": {
|
||||
"summary": "Gives a specific meme by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"description": "The ID of the meme",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "Meme response of this meme",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MemeResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": {
|
||||
"default": {
|
||||
"description": "Meme response of a random meme",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MemeResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/categories": {
|
||||
"get": {
|
||||
"summary": "Get all categories available on JensMemes",
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "List of all categories on JensMemes",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CategoriesResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/category": {
|
||||
"get": {
|
||||
"summary": "Get a specific category by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"description": "ID of the category",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "The requested category",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CategoryResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users": {
|
||||
"get": {
|
||||
"summary": "Get all users registered on JensMemes",
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "All users on JensMemes",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UsersResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user": {
|
||||
"get": {
|
||||
"summary": "Get a specific user on JensMemes",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"description": "The ID of the user",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "The requested user",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/upload": {
|
||||
"post": {
|
||||
"summary": "Upload an image or video to JensMemes",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "The ID of the category of the meme"
|
||||
},
|
||||
"token": {
|
||||
"type": "string",
|
||||
"description": "Your JensMemes token"
|
||||
},
|
||||
"file": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "The file or files to upload to JensMemes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "Response of the upload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UploadResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Meme": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"link": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"type": "string"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string"
|
||||
},
|
||||
"ipfs": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Category": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"User": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"userdir": {
|
||||
"type": "string"
|
||||
},
|
||||
"tokenhash": {
|
||||
"type": "string"
|
||||
},
|
||||
"dayuploads": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"MemesResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"minimum": 200,
|
||||
"maximum": 500
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"memes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Meme"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MemeResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"minimum": 200,
|
||||
"maximum": 500
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"meme": {
|
||||
"$ref": "#/components/schemas/Meme"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CategoriesResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"minimum": 200,
|
||||
"maximum": 500
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"memes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Category"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CategoryResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"minimum": 200,
|
||||
"maximum": 500
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"meme": {
|
||||
"$ref": "#/components/schemas/Category"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UsersResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"minimum": 200,
|
||||
"maximum": 500
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"memes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/User"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"UserResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"minimum": 200,
|
||||
"maximum": 500
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"meme": {
|
||||
"$ref": "#/components/schemas/User"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UploadResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"minimum": 201,
|
||||
"maximum": 500
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
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 '"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
39
src/cdn/error.rs
Normal file
39
src/cdn/error.rs
Normal file
|
@ -0,0 +1,39 @@
|
|||
use std::{convert::Infallible, string::FromUtf8Error};
|
||||
|
||||
use axum::{
|
||||
body::{Bytes, Empty},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use hyper::header::InvalidHeaderValue;
|
||||
use reqwest::StatusCode;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::error::ServiceError;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum CDNError {
|
||||
#[error("SQL error: {0}")]
|
||||
Sql(#[from] sqlx::Error),
|
||||
#[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,
|
||||
}
|
||||
|
||||
impl IntoResponse for CDNError {
|
||||
type Body = Empty<Bytes>;
|
||||
|
||||
type BodyError = Infallible;
|
||||
|
||||
fn into_response(self) -> axum::http::Response<Self::Body> {
|
||||
let status = match self {
|
||||
CDNError::Sql(sqlx::Error::RowNotFound) => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
status.into_response()
|
||||
}
|
||||
}
|
80
src/cdn/mod.rs
Normal file
80
src/cdn/mod.rs
Normal file
|
@ -0,0 +1,80 @@
|
|||
use axum::{
|
||||
body::Body,
|
||||
extract::{Extension, Path},
|
||||
handler::get,
|
||||
http::HeaderMap,
|
||||
response::IntoResponse,
|
||||
routing::BoxRoute,
|
||||
Router,
|
||||
};
|
||||
use headers::{ContentType, HeaderMapExt, HeaderValue};
|
||||
use reqwest::{
|
||||
header::{HeaderName, CONTENT_LENGTH},
|
||||
StatusCode,
|
||||
};
|
||||
|
||||
use crate::JMService;
|
||||
|
||||
use self::{
|
||||
error::CDNError,
|
||||
templates::{DirTemplate, HtmlTemplate},
|
||||
};
|
||||
|
||||
mod error;
|
||||
mod sql;
|
||||
mod templates;
|
||||
|
||||
pub fn routes() -> Router<BoxRoute> {
|
||||
Router::new()
|
||||
.route("/", get(users))
|
||||
.route("/:user/", get(memes))
|
||||
.route("/:user/:filename", get(image))
|
||||
.boxed()
|
||||
}
|
||||
|
||||
async fn image(
|
||||
Path((user, filename)): Path<(String, String)>,
|
||||
Extension(service): Extension<JMService>,
|
||||
) -> Result<impl IntoResponse, CDNError> {
|
||||
let filename = urlencoding::decode(&filename)?.into_owned();
|
||||
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"))
|
||||
.ok_or(CDNError::Internal)?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
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,
|
||||
headers,
|
||||
Body::wrap_stream(res.bytes_stream()),
|
||||
))
|
||||
}
|
||||
|
||||
async fn users(Extension(service): Extension<JMService>) -> Result<impl IntoResponse, CDNError> {
|
||||
let users = sql::get_users(&service.db_pool).await?;
|
||||
Ok(HtmlTemplate(DirTemplate {
|
||||
entries: users,
|
||||
prefix: service.int_cdn_url(),
|
||||
suffix: "/".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn memes(
|
||||
Path(user): Path<String>,
|
||||
Extension(service): Extension<JMService>,
|
||||
) -> Result<impl IntoResponse, CDNError> {
|
||||
let memes = sql::get_memes(user, &service.db_pool).await?;
|
||||
Ok(HtmlTemplate(DirTemplate {
|
||||
entries: memes,
|
||||
prefix: ".".to_string(),
|
||||
suffix: "".to_string(),
|
||||
}))
|
||||
}
|
30
src/cdn/sql.rs
Normal file
30
src/cdn/sql.rs
Normal file
|
@ -0,0 +1,30 @@
|
|||
use sqlx::{postgres::PgRow, PgPool, Result, Row};
|
||||
|
||||
pub async fn get_cid(user: String, filename: String, pool: &PgPool) -> Result<String> {
|
||||
let q: String =
|
||||
sqlx::query("SELECT cid FROM memes WHERE userid = $1 AND filename = $2 ORDER BY id DESC")
|
||||
.bind(user)
|
||||
.bind(filename)
|
||||
.map(|row: PgRow| row.get("cid"))
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
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: &PgPool) -> Result<Vec<String>> {
|
||||
let q: Vec<String> = sqlx::query("SELECT id FROM users ORDER BY id")
|
||||
.map(|row: PgRow| row.get("id"))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(q)
|
||||
}
|
30
src/cdn/templates.rs
Normal file
30
src/cdn/templates.rs
Normal file
|
@ -0,0 +1,30 @@
|
|||
use askama::Template;
|
||||
use axum::body::{Bytes, Full};
|
||||
use axum::http::{Response, StatusCode};
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use std::convert::Infallible;
|
||||
|
||||
pub struct HtmlTemplate<T>(pub T);
|
||||
|
||||
impl<T> IntoResponse for HtmlTemplate<T>
|
||||
where
|
||||
T: Template,
|
||||
{
|
||||
type Body = Full<Bytes>;
|
||||
type BodyError = Infallible;
|
||||
|
||||
fn into_response(self) -> Response<Self::Body> {
|
||||
match self.0.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "").into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "dir.html")]
|
||||
pub struct DirTemplate {
|
||||
pub entries: Vec<String>,
|
||||
pub prefix: String,
|
||||
pub suffix: String,
|
||||
}
|
44
src/config.rs
Normal file
44
src/config.rs
Normal file
|
@ -0,0 +1,44 @@
|
|||
use reqwest::Url;
|
||||
use serde::Deserialize;
|
||||
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 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 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 JMServiceInner {
|
||||
pub fn int_cdn_url(&self) -> String {
|
||||
self.int_cdn.clone()
|
||||
}
|
||||
|
||||
pub fn ext_cdn_url(&self) -> String {
|
||||
self.ext_cdn.clone()
|
||||
}
|
||||
}
|
54
src/error.rs
Normal file
54
src/error.rs
Normal file
|
@ -0,0 +1,54 @@
|
|||
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 {
|
||||
#[error("File read error: {0}")]
|
||||
Read(#[from] std::io::Error),
|
||||
#[error("Deserialize error: {0}")]
|
||||
Deserialize(#[from] toml::de::Error),
|
||||
#[error("Database connection error: {0}")]
|
||||
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),
|
||||
}
|
84
src/ipfs/mod.rs
Normal file
84
src/ipfs/mod.rs
Normal file
|
@ -0,0 +1,84 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use reqwest::{
|
||||
multipart::{Form, Part},
|
||||
Response,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{error::ServiceError, JMServiceInner};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct IPFSFile {
|
||||
#[serde(rename = "Hash")]
|
||||
pub hash: String,
|
||||
#[serde(rename = "Name")]
|
||||
pub name: String,
|
||||
#[serde(rename = "Size")]
|
||||
pub size: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CatQuery {
|
||||
pub arg: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AddQuery {
|
||||
pub pin: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PinQuery {
|
||||
pub arg: String,
|
||||
}
|
||||
|
||||
impl JMServiceInner {
|
||||
pub async fn ipfs_cat(&self, cid: String) -> Result<Response, ServiceError> {
|
||||
let request = self
|
||||
.client
|
||||
.post(self.ipfs_url.join("/api/v0/cat")?)
|
||||
.query(&CatQuery::new(cid));
|
||||
Ok(request.send().await?)
|
||||
}
|
||||
|
||||
pub async fn ipfs_add(&self, file: Bytes, filename: String) -> Result<IPFSFile, ServiceError> {
|
||||
let request = self
|
||||
.client
|
||||
.post(self.ipfs_url.join("/api/v0/add")?)
|
||||
.query(&AddQuery::new(false))
|
||||
.multipart(Form::new().part("file", Part::stream(file).file_name(filename)));
|
||||
let response = request.send().await?;
|
||||
let res: IPFSFile = response.json().await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn ipfs_pin(&self, cid: String) -> Result<(), ServiceError> {
|
||||
let request = self
|
||||
.client
|
||||
.post(self.ipfs_url.join("/api/v0/pin/add")?)
|
||||
.query(&PinQuery::new(cid))
|
||||
.timeout(Duration::from_secs(60));
|
||||
request.send().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl CatQuery {
|
||||
pub fn new(cid: String) -> Self {
|
||||
Self { arg: cid }
|
||||
}
|
||||
}
|
||||
|
||||
impl AddQuery {
|
||||
pub fn new(pin: bool) -> Self {
|
||||
Self { pin }
|
||||
}
|
||||
}
|
||||
|
||||
impl PinQuery {
|
||||
pub fn new(cid: String) -> Self {
|
||||
Self { arg: cid }
|
||||
}
|
||||
}
|
28
src/lib/error.rs
Normal file
28
src/lib/error.rs
Normal file
|
@ -0,0 +1,28 @@
|
|||
use std::{convert::Infallible, net::AddrParseError};
|
||||
|
||||
use axum::{
|
||||
body::{Bytes, Full},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use hyper::{header::ToStrError, StatusCode};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ExtractError {
|
||||
#[error("`{0}` header is missing")]
|
||||
HeaderMissing(String),
|
||||
#[error("Header value contains illegal character: {0}")]
|
||||
ToStr(#[from] ToStrError),
|
||||
#[error("Header value is not a valid IP address: {0}")]
|
||||
IPParse(#[from] AddrParseError),
|
||||
}
|
||||
|
||||
impl IntoResponse for ExtractError {
|
||||
type Body = Full<Bytes>;
|
||||
|
||||
type BodyError = Infallible;
|
||||
|
||||
fn into_response(self) -> hyper::Response<Self::Body> {
|
||||
(StatusCode::BAD_REQUEST, self.to_string()).into_response()
|
||||
}
|
||||
}
|
33
src/lib/ipheader.rs
Normal file
33
src/lib/ipheader.rs
Normal file
|
@ -0,0 +1,33 @@
|
|||
use std::{net::IpAddr, str::FromStr};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::extract::{FromRequest, RequestParts};
|
||||
|
||||
use super::error::ExtractError;
|
||||
|
||||
pub struct ExtractIP(pub IpAddr);
|
||||
|
||||
#[async_trait]
|
||||
impl<B> FromRequest<B> for ExtractIP
|
||||
where
|
||||
B: Send,
|
||||
{
|
||||
type Rejection = ExtractError;
|
||||
|
||||
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
|
||||
let header = req
|
||||
.headers()
|
||||
.and_then(|headers| headers.get("x-forwarded-for"));
|
||||
let header =
|
||||
header.ok_or_else(|| ExtractError::HeaderMissing("X-Forwarded-For".to_string()))?;
|
||||
let mut value = header.to_str()?;
|
||||
let pos = value.chars().position(|r| r == ',');
|
||||
value = match pos {
|
||||
Some(p) => &value[0..p],
|
||||
None => value,
|
||||
};
|
||||
let ip = IpAddr::from_str(value)?;
|
||||
|
||||
Ok(Self(ip))
|
||||
}
|
||||
}
|
4
src/lib/mod.rs
Normal file
4
src/lib/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
mod error;
|
||||
mod ipheader;
|
||||
|
||||
pub use ipheader::ExtractIP;
|
86
src/main.rs
86
src/main.rs
|
@ -1,21 +1,73 @@
|
|||
use actix_web::{HttpServer, App};
|
||||
use std::{io, env};
|
||||
use sqlx::MySqlPool;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{header, HeaderValue, Request},
|
||||
Router,
|
||||
};
|
||||
use config::Config;
|
||||
use error::JMError;
|
||||
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};
|
||||
|
||||
mod cdn;
|
||||
mod config;
|
||||
mod error;
|
||||
mod ipfs;
|
||||
mod lib;
|
||||
mod matrix;
|
||||
mod models;
|
||||
mod sql;
|
||||
mod v1;
|
||||
mod v2;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> io::Result<()>{
|
||||
|
||||
let database_url = env::var("DBURL").unwrap();
|
||||
let db_pool = MySqlPool::new(&database_url).await.unwrap();
|
||||
|
||||
let mut server = HttpServer::new(move || {
|
||||
App::new()
|
||||
.data(db_pool.clone())
|
||||
.configure(v1::init)
|
||||
});
|
||||
|
||||
server = server.bind(env::var("LISTEN").unwrap())?;
|
||||
server.run().await
|
||||
#[derive(StructOpt)]
|
||||
struct Opt {
|
||||
#[structopt(
|
||||
short,
|
||||
long,
|
||||
help = "config file to use",
|
||||
default_value = "./config.toml"
|
||||
)]
|
||||
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 = 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(service))
|
||||
.layer(SetResponseHeaderLayer::<_, Request<Body>>::if_not_present(
|
||||
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
HeaderValue::from_static("*"),
|
||||
));
|
||||
|
||||
axum::Server::bind(&config.addr)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
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()
|
||||
}
|
||||
}
|
61
src/models.rs
Normal file
61
src/models.rs
Normal file
|
@ -0,0 +1,61 @@
|
|||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Meme {
|
||||
pub id: i32,
|
||||
pub filename: String,
|
||||
pub userid: String,
|
||||
pub username: String,
|
||||
pub category: String,
|
||||
pub timestamp: i32,
|
||||
pub ipfs: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Category {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub userdir: String,
|
||||
pub tokenhash: String,
|
||||
pub dayuploads: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Count {
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
pub enum UserIdentifier {
|
||||
Id(String),
|
||||
Token(String),
|
||||
Username(String),
|
||||
Null,
|
||||
}
|
||||
|
||||
pub struct MemeOptions {
|
||||
pub category: 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,
|
||||
}
|
||||
}
|
||||
}
|
178
src/sql.rs
Normal file
178
src/sql.rs
Normal file
|
@ -0,0 +1,178 @@
|
|||
use crate::ipfs::IPFSFile;
|
||||
use crate::models::{Category, Count, Meme, MemeOptions, User, UserIdentifier};
|
||||
use crate::JMServiceInner;
|
||||
use sqlx::postgres::PgRow;
|
||||
use sqlx::{Result, Row};
|
||||
|
||||
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("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_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.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("userid"),
|
||||
category: row.get("category"),
|
||||
timestamp: row.get("ts"),
|
||||
ipfs: row.get("cid"),
|
||||
})
|
||||
.fetch_all(&self.db_pool).await?;
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
pub async fn get_random_meme(&self, filter: MemeOptions) -> Result<Meme> {
|
||||
let q: Meme = sqlx::query("SELECT memes.id, 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.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("userid"),
|
||||
category: row.get("category"),
|
||||
timestamp: row.get("ts"),
|
||||
ipfs: row.get("cid"),
|
||||
})
|
||||
.fetch_one(&self.db_pool).await?;
|
||||
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)
|
||||
}
|
||||
|
||||
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: PgRow| Category {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
})
|
||||
.fetch_optional(&self.db_pool)
|
||||
.await?;
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
pub async fn get_categories(&self) -> Result<Vec<Category>> {
|
||||
let q: Vec<Category> = sqlx::query("SELECT * FROM categories ORDER BY num")
|
||||
.map(|row: PgRow| Category {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
})
|
||||
.fetch_all(&self.db_pool)
|
||||
.await?;
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
pub async fn get_user(&self, identifier: UserIdentifier) -> Result<Option<User>> {
|
||||
let query = match identifier {
|
||||
UserIdentifier::Id(id) => sqlx::query("SELECT id, name, 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,
|
||||
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(&category.id)
|
||||
.bind(ip)
|
||||
.bind(&file.hash)
|
||||
.execute(&mut tx).await?;
|
||||
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)
|
||||
}
|
||||
}
|
77
src/v1/error.rs
Normal file
77
src/v1/error.rs
Normal file
|
@ -0,0 +1,77 @@
|
|||
use std::convert::Infallible;
|
||||
|
||||
use axum::{
|
||||
body::{Bytes, Full},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use super::models::ErrorResponse;
|
||||
use crate::error::{APIError, ServiceError};
|
||||
|
||||
impl ErrorResponse {
|
||||
fn new(status: StatusCode, message: Option<String>) -> Self {
|
||||
let reason = status.canonical_reason().unwrap_or_default();
|
||||
Self {
|
||||
status,
|
||||
error: message.unwrap_or_else(|| reason.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for APIError {
|
||||
type Body = Full<Bytes>;
|
||||
|
||||
type BodyError = Infallible;
|
||||
|
||||
fn into_response(self) -> axum::http::Response<Self::Body> {
|
||||
let res = match self {
|
||||
APIError::Sql(err) => match err {
|
||||
sqlx::Error::RowNotFound => ErrorResponse::new(StatusCode::NOT_FOUND, None),
|
||||
_ => 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::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),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,26 @@
|
|||
mod routes;
|
||||
mod error;
|
||||
pub mod models;
|
||||
mod sql;
|
||||
mod routes;
|
||||
|
||||
pub use routes::init;
|
||||
use async_trait::async_trait;
|
||||
use axum::extract::{FromRequest, RequestParts};
|
||||
pub use routes::routes;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::error::APIError;
|
||||
|
||||
pub struct Query<T>(pub T);
|
||||
|
||||
#[async_trait]
|
||||
impl<T, B> FromRequest<B> for Query<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Send,
|
||||
{
|
||||
type Rejection = APIError;
|
||||
|
||||
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
|
||||
let query = axum::extract::Query::<T>::from_request(req).await?;
|
||||
Ok(Self(query.0))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,27 +1,23 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
|
||||
use crate::models::{Category, Meme, MemeOptions, User, UserIdentifier};
|
||||
|
||||
fn serialize_status<S>(x: &StatusCode, s: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
s.serialize_u16(x.as_u16())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Meme {
|
||||
pub struct V1Meme {
|
||||
pub id: String,
|
||||
pub link: String,
|
||||
pub category: String,
|
||||
pub user: String,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Category {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub userdir: String,
|
||||
pub tokenhash: String,
|
||||
pub dayuploads: i32,
|
||||
pub ipfs: String,
|
||||
}
|
||||
|
||||
//Responses
|
||||
|
@ -30,14 +26,14 @@ pub struct User {
|
|||
pub struct MemesResponse {
|
||||
pub status: i32,
|
||||
pub error: Option<String>,
|
||||
pub memes: Option<Vec<Meme>>,
|
||||
pub memes: Option<Vec<V1Meme>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MemeResponse {
|
||||
pub status: i32,
|
||||
pub error: Option<String>,
|
||||
pub meme: Option<Meme>,
|
||||
pub meme: Option<V1Meme>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
@ -72,7 +68,15 @@ pub struct UserResponse {
|
|||
pub struct UploadResponse {
|
||||
pub status: i32,
|
||||
pub error: Option<String>,
|
||||
pub files: Option<Vec<String>>
|
||||
pub files: Option<Vec<String>>,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
#[serde(serialize_with = "serialize_status")]
|
||||
pub status: StatusCode,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
//Query
|
||||
|
@ -94,11 +98,49 @@ pub struct UserIDQuery {
|
|||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl V1Meme {
|
||||
pub fn new(meme: Meme, cdn: String) -> Self {
|
||||
Self {
|
||||
id: meme.id.to_string(),
|
||||
link: format!("{}/{}/{}", cdn, meme.userid, meme.filename),
|
||||
category: meme.category,
|
||||
user: meme.username,
|
||||
timestamp: meme.timestamp.to_string(),
|
||||
ipfs: meme.ipfs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserIDQuery> for UserIdentifier {
|
||||
fn from(query: UserIDQuery) -> Self {
|
||||
if let Some(id) = query.id {
|
||||
Self::Id(id)
|
||||
} else if let Some(token) = query.token {
|
||||
Self::Token(token)
|
||||
} else if let Some(name) = query.name {
|
||||
Self::Username(name)
|
||||
} else {
|
||||
Self::Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MemeFilterQuery {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
322
src/v1/routes.rs
322
src/v1/routes.rs
|
@ -1,140 +1,212 @@
|
|||
use actix_web::{web, get, Responder, HttpResponse};
|
||||
use crate::ipfs::IPFSFile;
|
||||
use crate::lib::ExtractIP;
|
||||
use crate::v1::models::*;
|
||||
use sqlx::{MySqlPool, Error};
|
||||
use crate::JMService;
|
||||
|
||||
#[get("/v1/meme")]
|
||||
async fn meme(params: web::Query<MemeIDQuery>, db_pool: web::Data<MySqlPool>) -> impl Responder {
|
||||
let q = Meme::get(params.id, db_pool.get_ref()).await;
|
||||
match q {
|
||||
Ok(meme) => HttpResponse::Ok().json(MemeResponse {
|
||||
status: 200,
|
||||
error: None,
|
||||
meme: Option::from(meme)
|
||||
}),
|
||||
Err(err) => match err {
|
||||
Error::RowNotFound => HttpResponse::NotFound().json(MemeResponse {
|
||||
status: 404,
|
||||
error: Option::from(String::from("Meme not found")),
|
||||
meme: None
|
||||
}),
|
||||
_ => HttpResponse::InternalServerError().json(MemeResponse {
|
||||
status: 500,
|
||||
error: Option::from(String::from("Internal Server Error")),
|
||||
meme: None
|
||||
})
|
||||
use axum::extract::{ContentLengthLimit, Extension, Multipart};
|
||||
use axum::handler::{get, post};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::BoxRoute;
|
||||
use axum::{Json, Router};
|
||||
use hyper::StatusCode;
|
||||
|
||||
use super::Query;
|
||||
use crate::error::APIError;
|
||||
|
||||
async fn meme(
|
||||
Query(params): Query<MemeIDQuery>,
|
||||
Extension(service): Extension<JMService>,
|
||||
) -> Result<impl IntoResponse, APIError> {
|
||||
let meme = V1Meme::new(
|
||||
service
|
||||
.get_meme(params.id)
|
||||
.await?
|
||||
.ok_or_else(|| APIError::NotFound("Meme not found".to_string()))?,
|
||||
service.ext_cdn_url(),
|
||||
);
|
||||
Ok(Json(MemeResponse {
|
||||
status: 200,
|
||||
error: None,
|
||||
meme: Some(meme),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn memes(
|
||||
Query(params): Query<MemeFilter>,
|
||||
Extension(service): Extension<JMService>,
|
||||
) -> Result<impl IntoResponse, APIError> {
|
||||
let memes = service
|
||||
.get_memes(params.into())
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|meme| V1Meme::new(meme, service.ext_cdn_url()))
|
||||
.collect();
|
||||
Ok(Json(MemesResponse {
|
||||
status: 200,
|
||||
error: None,
|
||||
memes: Some(memes),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn category(
|
||||
Query(params): Query<IDQuery>,
|
||||
Extension(service): Extension<JMService>,
|
||||
) -> Result<impl IntoResponse, APIError> {
|
||||
let category = service
|
||||
.get_category(¶ms.id)
|
||||
.await?
|
||||
.ok_or_else(|| APIError::NotFound("Category not found".to_string()))?;
|
||||
Ok(Json(CategoryResponse {
|
||||
status: 200,
|
||||
error: None,
|
||||
category: Some(category),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn categories(
|
||||
Extension(service): Extension<JMService>,
|
||||
) -> Result<impl IntoResponse, APIError> {
|
||||
let categories = service.get_categories().await?;
|
||||
Ok(Json(CategoriesResponse {
|
||||
status: 200,
|
||||
error: None,
|
||||
categories: Some(categories),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn user(
|
||||
Query(params): Query<UserIDQuery>,
|
||||
Extension(service): Extension<JMService>,
|
||||
) -> Result<impl IntoResponse, APIError> {
|
||||
let user = service
|
||||
.get_user(params.into())
|
||||
.await?
|
||||
.ok_or_else(|| APIError::NotFound("User not found".to_string()))?;
|
||||
Ok(Json(UserResponse {
|
||||
status: 200,
|
||||
error: None,
|
||||
user: Some(user),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn users(Extension(service): Extension<JMService>) -> Result<impl IntoResponse, APIError> {
|
||||
let users = service.get_users().await?;
|
||||
Ok(Json(UsersResponse {
|
||||
status: 200,
|
||||
error: None,
|
||||
users: Some(users),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn random(
|
||||
Query(params): Query<MemeFilter>,
|
||||
Extension(service): Extension<JMService>,
|
||||
) -> Result<impl IntoResponse, APIError> {
|
||||
let random = V1Meme::new(
|
||||
service.get_random_meme(params.into()).await?,
|
||||
service.ext_cdn_url(),
|
||||
);
|
||||
Ok(Json(MemeResponse {
|
||||
status: 200,
|
||||
error: None,
|
||||
meme: Some(random),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn upload(
|
||||
ContentLengthLimit(mut form): ContentLengthLimit<Multipart, { 1024 * 1024 * 1024 }>,
|
||||
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![];
|
||||
|
||||
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())
|
||||
})? {
|
||||
"token" => token = Some(field.text().await?),
|
||||
"category" => category = Some(field.text().await?),
|
||||
"file" | "file[]" => {
|
||||
let filename = field
|
||||
.file_name()
|
||||
.ok_or_else(|| {
|
||||
APIError::BadRequest("A file field has no filename".to_string())
|
||||
})?
|
||||
.to_string();
|
||||
let file = service.ipfs_add(field.bytes().await?, filename).await?;
|
||||
files.push(file);
|
||||
},
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/v1/memes")]
|
||||
async fn memes(params: web::Query<MemeFilterQuery>, db_pool: web::Data<MySqlPool>) -> impl Responder {
|
||||
let q = Meme::get_all(params.0, db_pool.get_ref()).await;
|
||||
match q {
|
||||
Ok(memes) => HttpResponse::Ok().json(MemesResponse {
|
||||
status: 200,
|
||||
error: None,
|
||||
memes: Option::from(memes)
|
||||
}),
|
||||
_ => HttpResponse::InternalServerError().json(MemesResponse {
|
||||
status: 500,
|
||||
error: Option::from(String::from("Internal Server Error")),
|
||||
memes: None
|
||||
})
|
||||
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 = 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);
|
||||
|
||||
if total > 20 {
|
||||
return Err(APIError::Forbidden("Upload limit reached".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/v1/category")]
|
||||
async fn category(params: web::Query<IDQuery>, db_pool: web::Data<MySqlPool>) -> impl Responder {
|
||||
let q = Category::get(¶ms.id, db_pool.get_ref()).await;
|
||||
match q {
|
||||
Ok(category) => HttpResponse::Ok().json(CategoryResponse { status: 200, error: None, category: Option::from(category)}),
|
||||
Err(err) => match err {
|
||||
Error::RowNotFound => HttpResponse::NotFound().json(CategoryResponse {
|
||||
status: 404,
|
||||
error: Option::from(String::from("Category not found")),
|
||||
category: None
|
||||
}),
|
||||
_ => HttpResponse::InternalServerError().json(CategoryResponse {
|
||||
status: 500,
|
||||
error: Option::from(String::from("Internal Server Error")),
|
||||
category: None
|
||||
})
|
||||
let cat = service
|
||||
.get_category(&category)
|
||||
.await?
|
||||
.ok_or_else(|| APIError::BadRequest("Category not existing".to_string()))?;
|
||||
|
||||
let ip = ip.to_string();
|
||||
|
||||
let mut links: Vec<String> = vec![];
|
||||
|
||||
for f in files {
|
||||
let res = service.add_meme_sql(&user, &f, &ip, &cat).await?;
|
||||
|
||||
if res == 0 {
|
||||
return Err(APIError::Internal("Database insertion error".to_string()));
|
||||
}
|
||||
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!(
|
||||
"{}/{}/{}",
|
||||
service.ext_cdn_url(),
|
||||
user.id.clone(),
|
||||
f.name.clone()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/v1/categories")]
|
||||
async fn categories(db_pool: web::Data<MySqlPool>) -> impl Responder {
|
||||
let q = Category::get_all(db_pool.get_ref()).await;
|
||||
match q {
|
||||
Ok(categories) => HttpResponse::Ok().json(CategoriesResponse { status: 200, error: None, categories: Option::from(categories)}),
|
||||
_ => HttpResponse::InternalServerError().json(CategoriesResponse {
|
||||
status: 500,
|
||||
error: Option::from(String::from("Internal Server Error")),
|
||||
categories: None
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/v1/user")]
|
||||
async fn user(params: web::Query<UserIDQuery>, db_pool: web::Data<MySqlPool>) -> impl Responder {
|
||||
let q = User::get(params.0,db_pool.get_ref()).await;
|
||||
match q {
|
||||
Ok(user) => HttpResponse::Ok().json(UserResponse { status: 200, error: None, user: Option::from(user)}),
|
||||
_ => HttpResponse::InternalServerError().json(UserResponse {
|
||||
status: 500,
|
||||
error: Option::from(String::from("Internal Server Error")),
|
||||
user: None
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/v1/users")]
|
||||
async fn users(db_pool: web::Data<MySqlPool>) -> impl Responder {
|
||||
let q = User::get_all(db_pool.get_ref()).await;
|
||||
match q {
|
||||
Ok(users) => HttpResponse::Ok().json(UsersResponse { status: 200, error: None, users: Option::from(users)}),
|
||||
_ => HttpResponse::InternalServerError().json(UsersResponse {
|
||||
status: 500,
|
||||
error: Option::from(String::from("Internal Server Error")),
|
||||
users: None
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/v1/random")]
|
||||
async fn random(params: web::Query<MemeFilterQuery>, db_pool: web::Data<MySqlPool>) -> impl Responder {
|
||||
let q = Meme::get_random(params.0, db_pool.get_ref()).await;
|
||||
match q {
|
||||
Ok(random) => HttpResponse::Ok().json(MemeResponse {
|
||||
status: 200,
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(UploadResponse {
|
||||
status: 201,
|
||||
error: None,
|
||||
meme: Some(random)
|
||||
files: Some(links),
|
||||
token,
|
||||
}),
|
||||
Err(err) => match err {
|
||||
Error::RowNotFound => HttpResponse::NotFound().json(MemeResponse {
|
||||
status: 404,
|
||||
error: Some(String::from("Meme not found")),
|
||||
meme: None
|
||||
}),
|
||||
_ => HttpResponse::InternalServerError().json(MemeResponse {
|
||||
status: 500,
|
||||
error: Some(String::from("Internal Server Error")),
|
||||
meme: None
|
||||
})
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
//TODO: Implement random meme endpoint
|
||||
//TODO: Implement upload endpoint
|
||||
|
||||
pub fn init(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(meme);
|
||||
cfg.service(memes);
|
||||
cfg.service(category);
|
||||
cfg.service(categories);
|
||||
cfg.service(user);
|
||||
cfg.service(users);
|
||||
cfg.service(random);
|
||||
pub fn routes() -> Router<BoxRoute> {
|
||||
Router::new()
|
||||
.route("/meme", get(meme))
|
||||
.route("/memes", get(memes))
|
||||
.route("/category", get(category))
|
||||
.route("/categories", get(categories))
|
||||
.route("/user", get(user))
|
||||
.route("/users", get(users))
|
||||
.route("/random", get(random))
|
||||
.route("/upload", post(upload))
|
||||
.boxed()
|
||||
}
|
||||
|
|
132
src/v1/sql.rs
132
src/v1/sql.rs
|
@ -1,132 +0,0 @@
|
|||
use crate::v1::models::{Meme, MemeFilterQuery, Category, User, UserIDQuery};
|
||||
use sqlx::{MySqlPool, Result, Row};
|
||||
use sqlx::mysql::MySqlRow;
|
||||
use std::env;
|
||||
|
||||
pub struct DBMeme {
|
||||
pub id: i32,
|
||||
pub filename: String,
|
||||
pub user: String,
|
||||
pub userdir: String,
|
||||
pub category: String,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
|
||||
impl Meme {
|
||||
pub async fn get(id: i32, pool: &MySqlPool) -> Result<Meme> {
|
||||
let q: Meme = sqlx::query("SELECT memes.id, user, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts FROM memes, users WHERE memes.user = users.id AND memes.id=?").bind(id)
|
||||
.map(|row: MySqlRow| Meme::from(DBMeme {
|
||||
id: row.get("id"),
|
||||
filename: row.get("filename"),
|
||||
user: row.get("name"),
|
||||
userdir: row.get("user"),
|
||||
category: row.get("category"),
|
||||
timestamp: row.get("ts"),
|
||||
}))
|
||||
.fetch_one(pool).await?;
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
pub async fn get_all(params: MemeFilterQuery, pool: &MySqlPool) -> Result<Vec<Meme>> {
|
||||
let q: Vec<Meme> = sqlx::query("SELECT memes.id, user, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts FROM memes, users WHERE memes.user = users.id AND (category LIKE ? AND name LIKE ? AND filename LIKE ?) ORDER BY memes.id")
|
||||
.bind(params.category.unwrap_or(String::from("%")))
|
||||
.bind(format!("%{}%", params.user.unwrap_or(String::from(""))))
|
||||
.bind(format!("%{}%", params.search.unwrap_or(String::from(""))))
|
||||
.map(|row: MySqlRow| Meme::from(DBMeme {
|
||||
id: row.get("id"),
|
||||
filename: row.get("filename"),
|
||||
user: row.get("name"),
|
||||
userdir: row.get("user"),
|
||||
category: row.get("category"),
|
||||
timestamp: row.get("ts"),
|
||||
}))
|
||||
.fetch_all(pool).await?;
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
pub async fn get_random(params: MemeFilterQuery, pool: &MySqlPool) -> Result<Meme> {
|
||||
let q: Meme = sqlx::query("SELECT memes.id, user, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts FROM memes, users WHERE memes.user = users.id AND (category LIKE ? AND name LIKE ? AND filename LIKE ?) ORDER BY RAND() LIMIT 1")
|
||||
.bind(params.category.unwrap_or(String::from("%")))
|
||||
.bind(format!("%{}%", params.user.unwrap_or(String::from(""))))
|
||||
.bind(format!("%{}%", params.search.unwrap_or(String::from(""))))
|
||||
.map(|row: MySqlRow| Meme::from(DBMeme {
|
||||
id: row.get("id"),
|
||||
filename: row.get("filename"),
|
||||
user: row.get("name"),
|
||||
userdir: row.get("user"),
|
||||
category: row.get("category"),
|
||||
timestamp: row.get("ts"),
|
||||
}))
|
||||
.fetch_one(pool).await?;
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl From<DBMeme> for Meme {
|
||||
fn from(meme: DBMeme) -> Self {
|
||||
Meme {
|
||||
id: meme.id.to_string(),
|
||||
link: format!("{}/{}/{}", env::var("CDNURL").unwrap(), meme.userdir, meme.filename),
|
||||
category: meme.category,
|
||||
user: meme.user,
|
||||
timestamp: meme.timestamp.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Category {
|
||||
pub async fn get(id: &String, pool: &MySqlPool) -> Result<Category> {
|
||||
let q: Category = sqlx::query("SELECT * FROM categories WHERE id=?").bind(id)
|
||||
.map(|row: MySqlRow| Category {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
})
|
||||
.fetch_one(pool).await?;
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
pub async fn get_all(pool: &MySqlPool) -> Result<Vec<Category>> {
|
||||
let q: Vec<Category> = sqlx::query("SELECT * FROM categories ORDER BY num")
|
||||
.map(|row: MySqlRow| Category {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
})
|
||||
.fetch_all(pool).await?;
|
||||
Ok(q)
|
||||
}
|
||||
}
|
||||
|
||||
impl User {
|
||||
|
||||
pub async fn get(params: UserIDQuery, pool: &MySqlPool) -> Result<User> {
|
||||
let q: User = sqlx::query("SELECT id, name, MD5(token) AS hash, uploads FROM (SELECT id, name, IFNULL(count.uploads, 0) AS uploads FROM users LEFT JOIN (SELECT user, COUNT(*) AS uploads FROM memes WHERE DATE(timestamp) = CURDATE() GROUP BY (user)) AS count ON users.id = count.user) AS users, token WHERE users.id = token.uid AND (users.id LIKE ? OR token LIKE ? OR name LIKE ?) UNION SELECT id, name, 0 AS hash, 0 AS uploads FROM users WHERE id = '000'")
|
||||
.bind(params.id.unwrap_or(String::from("")))
|
||||
.bind(params.token.unwrap_or(String::from("")))
|
||||
.bind(params.name.unwrap_or(String::from("")))
|
||||
.map(|row: MySqlRow| User {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
userdir: row.get("id"),
|
||||
tokenhash: row.get("hash"),
|
||||
dayuploads: row.get("uploads"),
|
||||
})
|
||||
.fetch_one(pool).await?;
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
pub async fn get_all(pool: &MySqlPool) -> Result<Vec<User>> {
|
||||
let q: Vec<User> = sqlx::query("SELECT id, name, MD5(token) AS hash, uploads FROM (SELECT id, name, IFNULL(count.uploads, 0) AS uploads FROM users LEFT JOIN (SELECT user, COUNT(*) AS uploads FROM memes WHERE DATE(timestamp) = CURDATE() GROUP BY (user)) AS count ON users.id = count.user) AS users, token WHERE users.id = token.uid UNION SELECT id, name, 0 AS hash, 0 AS uploads FROM users WHERE id = '000'")
|
||||
.map(|row: MySqlRow| User {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
userdir: row.get("id"),
|
||||
tokenhash: row.get("hash"),
|
||||
dayuploads: row.get("uploads"),
|
||||
})
|
||||
.fetch_all(pool).await?;
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
}
|
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()
|
||||
}
|
11
templates/dir.html
Normal file
11
templates/dir.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
{% for entry in entries %}
|
||||
<a href="{{prefix}}/{{entry}}{{suffix}}">{{entry}}</a><br>
|
||||
{% endfor %}
|
||||
</body>
|
||||
</html>
|
22
testenv/README.md
Normal file
22
testenv/README.md
Normal file
|
@ -0,0 +1,22 @@
|
|||
# JMServer testing environment
|
||||
This directory contains a setup that can be used to run the JMServer for debugging.
|
||||
|
||||
It contains a `docker-compose.yml` file which can run everything needed for JMServer in docker containers,
|
||||
and also a script to run JMServer configured to use these containers.
|
||||
|
||||
## How to use
|
||||
1. Run `docker-compose up` in the testenv directory, and wait for all the containers to start.
|
||||
2. Run the `debug_run.sh` shellscript which will start JMServer.
|
||||
|
||||
## Infos
|
||||
The docker compose file contains these containers:
|
||||
1. A mariadb databse with a `jensmemes` user, who has the password `snens`. This database is set-up with the scheme and some example data.
|
||||
2. A caddy HTTP server as a CDN. It serves just `/0/uff.png` as an example meme.
|
||||
3. An adminer admin interface for mariadb, allowing easy inspection and modification of the database.
|
||||
|
||||
Ports:
|
||||
- 8080: adminer
|
||||
- 8081: JM API
|
||||
- 8082: CDN
|
||||
- 3306: MariaDB
|
||||
|
BIN
testenv/cdn/0/uff.png
Normal file
BIN
testenv/cdn/0/uff.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
8
testenv/debug_run.sh
Executable file
8
testenv/debug_run.sh
Executable file
|
@ -0,0 +1,8 @@
|
|||
#!/bin/sh
|
||||
export DBURL="mysql://jensmemes:snens@127.0.0.1:3306/jensmemes"
|
||||
export CDNURL="http://127.0.0.1:8082"
|
||||
export LISTEN="127.0.0.1:8081"
|
||||
|
||||
export RUST_BACKTRACE=1
|
||||
|
||||
cargo run
|
|
@ -9,8 +9,17 @@ services:
|
|||
MARIADB_USER: jensmemes
|
||||
MARIADB_PASSWORD: snens
|
||||
MARIADB_DATABASE: jensmemes
|
||||
volumes:
|
||||
- "./mariadb_init:/docker-entrypoint-initdb.d"
|
||||
|
||||
adminer:
|
||||
image: adminer
|
||||
ports:
|
||||
- 8080:8080
|
||||
|
||||
cdn:
|
||||
image: caddy
|
||||
ports:
|
||||
- "8082:80"
|
||||
volumes:
|
||||
- "./cdn:/usr/share/caddy"
|
||||
|
|
4
testenv/mariadb_init/01-schema.sql
Normal file
4
testenv/mariadb_init/01-schema.sql
Normal file
|
@ -0,0 +1,4 @@
|
|||
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), 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));
|
4
testenv/mariadb_init/02-example_data.sql
Normal file
4
testenv/mariadb_init/02-example_data.sql
Normal file
|
@ -0,0 +1,4 @@
|
|||
INSERT INTO users (id, name, authsource) VALUES (0, 'alec', '{"name": "test authsource"}');
|
||||
INSERT INTO categories (num, id, name) VALUES (0, 'uff', 'janz viele ueffen!');
|
||||
INSERT INTO memes (id, filename, user, category, timestamp, ip) VALUES (0, 'uff.png', 0, 'uff', '2021-07-31', '127.0.0.1');
|
||||
INSERT INTO token (uid, token) VALUES (0, '42069');
|
Loading…
Reference in a new issue