Compare commits
No commits in common. "master" and "master" have entirely different histories.
37 changed files with 311 additions and 2807 deletions
23
.drone.yml
23
.drone.yml
|
@ -1,23 +0,0 @@
|
|||
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,4 +2,3 @@
|
|||
.idea
|
||||
*.iml
|
||||
Cargo.lock
|
||||
config.toml
|
|
@ -1,3 +0,0 @@
|
|||
- Refactoring
|
||||
- Initial V2 API
|
||||
- Added Dockerfilegit
|
18
Cargo.toml
18
Cargo.toml
|
@ -7,22 +7,8 @@ edition = "2018"
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
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"] }
|
||||
actix-web = "3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.51"
|
||||
sqlx = { version = "0.3", features = [ "postgres" ] }
|
||||
sqlx = { version = "0.3", features = [ "mysql" ] }
|
||||
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
21
Dockerfile
|
@ -1,21 +0,0 @@
|
|||
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,9 +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 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;
|
||||
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));
|
470
spec/v1.json
470
spec/v1.json
|
@ -1,470 +0,0 @@
|
|||
{
|
||||
"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
701
spec/v2.json
|
@ -1,701 +0,0 @@
|
|||
{
|
||||
"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 '"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
|
@ -1,80 +0,0 @@
|
|||
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(),
|
||||
}))
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
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)
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
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,
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
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
54
src/error.rs
|
@ -1,54 +0,0 @@
|
|||
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),
|
||||
}
|
|
@ -1,84 +0,0 @@
|
|||
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 }
|
||||
}
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
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))
|
||||
}
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
mod error;
|
||||
mod ipheader;
|
||||
|
||||
pub use ipheader::ExtractIP;
|
86
src/main.rs
86
src/main.rs
|
@ -1,73 +1,21 @@
|
|||
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};
|
||||
use actix_web::{HttpServer, App};
|
||||
use std::{io, env};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
mod cdn;
|
||||
mod config;
|
||||
mod error;
|
||||
mod ipfs;
|
||||
mod lib;
|
||||
mod matrix;
|
||||
mod models;
|
||||
mod sql;
|
||||
mod v1;
|
||||
mod v2;
|
||||
|
||||
#[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(())
|
||||
#[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
|
||||
}
|
||||
|
|
|
@ -1,158 +0,0 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{error::ServiceError, JMServiceInner};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Meme {
|
||||
pub category: String,
|
||||
pub filename: String,
|
||||
pub cid: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct UserID {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RoomID {
|
||||
pub room_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct EventID {
|
||||
pub event_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RegisterRequest {
|
||||
#[serde(rename = "type")]
|
||||
pub reg_type: String,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
impl JMServiceInner {
|
||||
pub async fn add_meme(
|
||||
&self,
|
||||
category: String,
|
||||
filename: String,
|
||||
cid: String,
|
||||
user: String,
|
||||
id: i64,
|
||||
) -> Result<(), ServiceError> {
|
||||
let meme = Meme {
|
||||
category,
|
||||
filename,
|
||||
cid,
|
||||
};
|
||||
let txid = meme.calc_txid(user.clone());
|
||||
let usr = self.check_user(user).await?;
|
||||
let room_id = self.join_room(&usr).await?;
|
||||
let path = format!(
|
||||
"/_matrix/client/r0/rooms/{}/send/es.jensmem.meme/{}",
|
||||
&room_id, txid
|
||||
);
|
||||
let url = self.matrix_url.join(path.as_str())?;
|
||||
let req = self
|
||||
.client
|
||||
.put(url)
|
||||
.bearer_auth(self.matrix_token.clone())
|
||||
.query(&usr)
|
||||
.json(&meme);
|
||||
let res = req.send().await?;
|
||||
if res.status().is_success() {
|
||||
let event: EventID = res.json().await?;
|
||||
let path = format!(
|
||||
"/_matrix/client/r0/rooms/{}/state/es.jensmem.index/{}",
|
||||
&room_id, id
|
||||
);
|
||||
let req = self
|
||||
.client
|
||||
.put(self.matrix_url.join(path.as_str())?)
|
||||
.bearer_auth(self.matrix_token.clone())
|
||||
.json(&event);
|
||||
let res = req.send().await?;
|
||||
if res.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ServiceError::InvalidResponse(res.status()))
|
||||
}
|
||||
} else {
|
||||
Err(ServiceError::InvalidResponse(res.status()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_user(&self, user: String) -> Result<UserID, ServiceError> {
|
||||
let username = format!("jm_{}", user);
|
||||
let user = self.get_mxid(username.clone());
|
||||
let req = self
|
||||
.client
|
||||
.get(self.matrix_url.join("/_matrix/client/r0/account/whoami")?)
|
||||
.bearer_auth(self.matrix_token.clone())
|
||||
.query(&user);
|
||||
let res = req.send().await?;
|
||||
if res.status().is_success() {
|
||||
let mxid: UserID = res.json().await?;
|
||||
Ok(mxid)
|
||||
} else {
|
||||
let mxid = self.register_user(username).await?;
|
||||
Ok(mxid)
|
||||
}
|
||||
}
|
||||
|
||||
async fn register_user(&self, username: String) -> Result<UserID, ServiceError> {
|
||||
let req = self
|
||||
.client
|
||||
.post(self.matrix_url.join("/_matrix/client/r0/register")?)
|
||||
.bearer_auth(self.matrix_token.clone())
|
||||
.json(&RegisterRequest::new(username));
|
||||
let res = req.send().await?;
|
||||
if res.status().is_success() {
|
||||
let user: UserID = res.json().await?;
|
||||
Ok(user)
|
||||
} else {
|
||||
Err(ServiceError::InvalidResponse(res.status()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn join_room(&self, user: &UserID) -> Result<String, ServiceError> {
|
||||
let req = self
|
||||
.client
|
||||
.post(
|
||||
self.matrix_url
|
||||
.join("/_matrix/client/r0/join/%23memes%3Atilera.org")?,
|
||||
)
|
||||
.bearer_auth(self.matrix_token.clone())
|
||||
.query(user);
|
||||
|
||||
let res = req.send().await?;
|
||||
if res.status().is_success() {
|
||||
let room: RoomID = res.json().await?;
|
||||
Ok(room.room_id)
|
||||
} else {
|
||||
Err(ServiceError::InvalidResponse(res.status()))
|
||||
}
|
||||
}
|
||||
|
||||
fn get_mxid(&self, username: String) -> UserID {
|
||||
UserID {
|
||||
user_id: format!("@{}:{}", username, self.matrix_domain.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RegisterRequest {
|
||||
pub fn new(username: String) -> Self {
|
||||
Self {
|
||||
reg_type: "m.login.application_service".to_string(),
|
||||
username,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Meme {
|
||||
pub fn calc_txid(&self, user: String) -> String {
|
||||
let txid = format!("{}/{}/{}/{}", user, self.category, self.filename, self.cid);
|
||||
urlencoding::encode(txid.as_str()).into_owned()
|
||||
}
|
||||
}
|
|
@ -1,61 +0,0 @@
|
|||
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
178
src/sql.rs
|
@ -1,178 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
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,26 +1,5 @@
|
|||
mod error;
|
||||
pub mod models;
|
||||
mod routes;
|
||||
pub mod models;
|
||||
mod sql;
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
pub use routes::init;
|
||||
|
|
|
@ -1,23 +1,27 @@
|
|||
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())
|
||||
}
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct V1Meme {
|
||||
pub struct Meme {
|
||||
pub id: String,
|
||||
pub link: String,
|
||||
pub category: String,
|
||||
pub user: String,
|
||||
pub timestamp: String,
|
||||
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,
|
||||
}
|
||||
|
||||
//Responses
|
||||
|
@ -26,14 +30,14 @@ pub struct V1Meme {
|
|||
pub struct MemesResponse {
|
||||
pub status: i32,
|
||||
pub error: Option<String>,
|
||||
pub memes: Option<Vec<V1Meme>>,
|
||||
pub memes: Option<Vec<Meme>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MemeResponse {
|
||||
pub status: i32,
|
||||
pub error: Option<String>,
|
||||
pub meme: Option<V1Meme>,
|
||||
pub meme: Option<Meme>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
@ -68,15 +72,7 @@ pub struct UserResponse {
|
|||
pub struct UploadResponse {
|
||||
pub status: i32,
|
||||
pub error: Option<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,
|
||||
pub files: Option<Vec<String>>
|
||||
}
|
||||
|
||||
//Query
|
||||
|
@ -98,49 +94,11 @@ 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 MemeFilter {
|
||||
pub struct MemeFilterQuery {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
338
src/v1/routes.rs
338
src/v1/routes.rs
|
@ -1,212 +1,140 @@
|
|||
use crate::ipfs::IPFSFile;
|
||||
use crate::lib::ExtractIP;
|
||||
use actix_web::{web, get, Responder, HttpResponse};
|
||||
use crate::v1::models::*;
|
||||
use crate::JMService;
|
||||
use sqlx::{MySqlPool, Error};
|
||||
|
||||
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);
|
||||
},
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
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()
|
||||
));
|
||||
}
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(UploadResponse {
|
||||
status: 201,
|
||||
#[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,
|
||||
files: Some(links),
|
||||
token,
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
#[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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
error: None,
|
||||
meme: Some(random)
|
||||
}),
|
||||
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);
|
||||
}
|
||||
|
|
132
src/v1/sql.rs
Normal file
132
src/v1/sql.rs
Normal file
|
@ -0,0 +1,132 @@
|
|||
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)
|
||||
}
|
||||
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
mod models;
|
||||
mod routes;
|
||||
|
||||
pub use routes::routes;
|
|
@ -1,76 +0,0 @@
|
|||
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
165
src/v2/routes.rs
|
@ -1,165 +0,0 @@
|
|||
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()
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
<!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>
|
|
@ -1,22 +0,0 @@
|
|||
# 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
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 1.6 KiB |
|
@ -1,8 +0,0 @@
|
|||
#!/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,17 +9,8 @@ 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"
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
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));
|
|
@ -1,4 +0,0 @@
|
|||
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