Compare commits

...

36 commits

Author SHA1 Message Date
916f1b4a45 feat: seperate internal and external cdn urls 2024-03-15 13:14:29 +01:00
b42e35da45 feat: return IPFS header for CDN 2023-07-26 17:36:04 +02:00
a031352ef9 feat: finish postgres migration 2023-07-07 16:03:59 +02:00
46773e9d17 feat: postgres migration 2023-07-06 17:35:23 +02:00
9bed0de064 Fix upload error 2022-07-26 16:06:14 +02:00
bf67cf62f9 Better error message for reqwest error 2022-07-26 11:59:52 +02:00
c4a8251147 Implement API pagination for API v2 2022-07-21 12:18:25 +02:00
e367c341b7 Better error response messages 2022-07-20 11:57:38 +02:00
1577b9a1ca started v3.0.0 development 2022-07-19 21:11:13 +02:00
a50d157394 Initial Matrix implementation 2022-01-17 21:58:33 +01:00
fbca4b7c06 Replace ConfVars with JMService 2022-01-16 22:43:45 +01:00
9133d1ea9e SQL improvements 2022-01-16 17:46:09 +01:00
467c00410c SQL add_meme returns meme ID 2022-01-16 13:31:16 +01:00
abc4345fc1 Added API spec to repo 2022-01-16 13:23:22 +01:00
1dbdc020b9 Custom query extractor response 2022-01-11 19:42:15 +01:00
614c23ca53 Update changelog 2022-01-10 23:36:52 +01:00
8f5cd2b714 First CI test 2022-01-10 20:08:23 +01:00
6e51ece84c Return token on upload (required by JM Web) 2022-01-10 19:07:57 +01:00
b191626ad9 Code improvements 2022-01-10 14:37:27 +01:00
5aeb937e6c Return error, when Database insertion failed 2022-01-08 23:52:15 +01:00
b6abcd7c90 Implement upload endpoint 2022-01-08 20:57:53 +01:00
ac17b66a8d Implement IP header extraction 2022-01-08 19:01:19 +01:00
8e5387600a Improve IPFS errors 2022-01-05 23:46:19 +01:00
fcce7d07c8 Replaced expect with error handling 2022-01-04 20:06:57 +01:00
0977a5ffcb Improve API error handling 2022-01-02 17:25:23 +01:00
c90d0b70b9 Improve error handling 2021-12-29 18:33:31 +01:00
8a3e569fa3 Update SQL schema 2021-12-29 14:34:03 +01:00
7f7a471ca4 Fix URL encoding for CDN 2021-12-23 11:32:48 +01:00
c479c24928 IPFS CDN directory listing 2021-12-18 21:15:06 +01:00
fc2fb200f0 Start implementing IPFS CDN 2021-12-18 20:04:34 +01:00
408bf0b3fa Changed config format 2021-12-17 23:50:03 +01:00
e0eeccefe5 Merge branch 'master' of https://tilera.xyz/git/JensMemes/jmserver into warp-port 2021-08-26 17:49:23 +02:00
2e608c7ece Initial axum port 2021-08-26 17:45:38 +02:00
5b28baebb8 add ports to testenv/README.md 2021-08-01 00:13:24 +02:00
80be0f98f6 add testenv README 2021-07-31 23:59:16 +02:00
95b9212e83 improve testenv 2021-07-31 23:51:07 +02:00
37 changed files with 2799 additions and 303 deletions

23
.drone.yml Normal file
View 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
View file

@ -2,3 +2,4 @@
.idea
*.iml
Cargo.lock
config.toml

3
CHANGELOG.md Normal file
View file

@ -0,0 +1,3 @@
- Refactoring
- Initial V2 API
- Added Dockerfilegit

View file

@ -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
View file

@ -0,0 +1,21 @@
FROM rust:buster as builder
RUN apt update && apt install -y libssl-dev
WORKDIR /usr/src/jmserver
COPY Cargo.toml ./
COPY src/ src/
COPY templates/ templates/
RUN cargo build --release
FROM debian:buster
COPY --from=builder /usr/src/jmserver/target/release/jmserver /usr/bin
RUN apt update && apt install -y libssl1.1 dumb-init curl
VOLUME ["/data"]
ENTRYPOINT ["/usr/bin/dumb-init", "--", "/usr/bin/jmserver", "--config", "/data/config.toml"]

View file

@ -1,4 +1,9 @@
CREATE TABLE IF NOT EXISTS categories (num INT UNIQUE NOT NULL , id varchar(255) NOT NULL , name TEXT, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS 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
View 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
View file

@ -0,0 +1,701 @@
{
"openapi": "3.0.0",
"info": {
"version": "2.0.0",
"title": "JensMemes"
},
"servers": [
{
"url": "https://api.tilera.xyz/jensmemes/v2"
}
],
"paths": {
"/memes": {
"get": {
"summary": "List all memes on JensMemes",
"parameters": [
{
"name": "category",
"in": "query",
"description": "Filter category of the memes",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "user",
"in": "query",
"description": "Filter user of the memes",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "search",
"in": "query",
"description": "Search for memes",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "limit",
"in": "query",
"description": "How many memes should be returned at maximum (-1 for no limit)",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "after",
"in": "query",
"description": "ID of the meme after which the returned memes should start",
"required": false,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Meme list response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Meme"
}
}
}
}
},
"default": {
"description": "Some error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
},
"post": {
"summary": "Upload an image or video to JensMemes (WIP)",
"security": [
{
"discord": []
},
{
"token": []
}
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "The ID of the category of the meme"
},
"file": {
"oneOf": [
{
"type": "string",
"format": "binary"
},
{
"type": "array",
"items": {
"type": "string",
"format": "binary"
}
}
],
"description": "The file or files to upload to JensMemes"
}
}
}
}
}
},
"responses": {
"201": {
"description": "Response of the upload",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Meme"
}
}
}
}
},
"default": {
"description": "Some error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/memes/{id}": {
"get": {
"summary": "Gives a specific meme by ID",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The ID of the meme",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Meme response of this meme",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Meme"
}
}
}
},
"default": {
"description": "Some error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/memes/random": {
"get": {
"summary": "Gives a random meme",
"parameters": [
{
"name": "category",
"in": "query",
"description": "Only give a random meme from this category ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "user",
"in": "query",
"description": "Only give a random meme from this user",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Meme response of a random meme",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Meme"
}
}
}
},
"default": {
"description": "Some error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/memes/count": {
"get": {
"summary": "Gives the total number of memes",
"parameters": [
{
"name": "category",
"in": "query",
"description": "Only count memes from this category ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "user",
"in": "query",
"description": "Only count memes from this user",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Amount of memes",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Count"
}
}
}
},
"default": {
"description": "Some error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/categories": {
"get": {
"summary": "Get all categories available on JensMemes",
"responses": {
"200": {
"description": "List of all categories on JensMemes",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Category"
}
}
}
}
},
"default": {
"description": "Some error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/categories/{id}": {
"get": {
"summary": "Get a specific category by ID",
"parameters": [
{
"name": "id",
"in": "path",
"description": "ID of the category",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The requested category",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Category"
}
}
}
},
"default": {
"description": "Some error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/users": {
"get": {
"summary": "Get all users registered on JensMemes",
"responses": {
"200": {
"description": "All users on JensMemes",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/User"
}
}
}
}
},
"default": {
"description": "Some error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/users/{id}": {
"get": {
"summary": "Get a specific user on JensMemes",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The ID of the user",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The requested user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
},
"default": {
"description": "Some error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/users/{id}/memes" : {
"get": {
"summary": "Get all memes of a user",
"parameters": [
{
"name": "limit",
"in": "query",
"description": "How many memes should be returned at maximum (-1 for no limit)",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "after",
"in": "query",
"description": "ID of the meme after which the returned memes should start",
"required": false,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Meme list response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Meme"
}
}
}
}
},
"default": {
"description": "Some error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/users/{id}/memes/{filename}": {
"get": {
"summary": "Gives a specific meme from a user by filename",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The ID of the user",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "filename",
"in": "path",
"description": "The filename of the meme",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Meme response of this meme",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Meme"
}
}
}
},
"default": {
"description": "Some error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/clips": {
"get": {
"summary": "WIP",
"parameters": [
{
"name": "streamer",
"in": "query",
"description": "Twitch username of the streamer",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Clip"
}
}
}
}
}
}
},
"post": {
"summary": "WIP",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"link": {
"type": "string"
}
}
}
}
}
},
"security": [
{
"discord": []
},
{
"token": []
}
],
"responses": {
"201": {
"description": "Uploaded",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Clip"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Meme": {
"type": "object",
"properties": {
"filename": {
"type": "string"
},
"id": {
"type": "integer"
},
"ipfs": {
"type": "string"
},
"category": {
"type": "string"
},
"user": {
"type": "string"
},
"timestamp": {
"type": "integer"
}
}
},
"Category": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"User": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"dayuploads": {
"type": "integer"
}
}
},
"Count": {
"type": "object",
"properties": {
"count": {
"type": "integer"
}
}
},
"Clip": {
"type": "object",
"properties": {
"link": {
"type": "string"
},
"id": {
"type": "integer"
},
"streamer": {
"type": "string"
},
"user": {
"type": "string"
},
"timestamp": {
"type": "integer"
}
}
},
"ErrorResponse": {
"type": "object",
"required": [
"status",
"error"
],
"properties": {
"status": {
"type": "integer",
"minimum": 200,
"maximum": 500
},
"error": {
"type": "string"
}
}
}
},
"securitySchemes": {
"discord": {
"type": "apiKey",
"in": "header",
"name": "Authorization",
"description": "A Discord OAuth Token, prefix with 'Discord '"
},
"token": {
"type": "apiKey",
"in": "header",
"name": "Authorization",
"description": "A JWT Token from the bot, prefix with 'Token '"
}
}
}
}

39
src/cdn/error.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
mod error;
mod ipheader;
pub use ipheader::ExtractIP;

View file

@ -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
View file

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

61
src/models.rs Normal file
View 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
View 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
View 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),
}
}
}

View file

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

View file

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

View file

@ -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(&params.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(&params.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()
}

View file

@ -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
View file

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

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

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

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

@ -0,0 +1,165 @@
use axum::{
extract::{Extension, Path, Query},
handler::get,
response::IntoResponse,
routing::BoxRoute,
Json, Router,
};
use crate::{
error::APIError,
models::{MemeOptions, UserIdentifier},
JMService,
};
use super::models::{MemeFilterQuery, V2Meme, V2User};
async fn get_meme(
Path(meme_id): Path<i32>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(V2Meme::from(
service
.get_meme(meme_id)
.await?
.ok_or_else(|| APIError::NotFound("Meme not found".to_string()))?,
)))
}
async fn get_memes(
Query(filter): Query<MemeFilterQuery>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(
service
.get_memes(filter.into())
.await?
.into_iter()
.map(V2Meme::from)
.collect::<Vec<V2Meme>>(),
))
}
async fn get_random_meme(
Query(filter): Query<MemeFilterQuery>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(V2Meme::from(
service.get_random_meme(filter.into()).await?,
)))
}
async fn count_memes(
Query(filter): Query<MemeFilterQuery>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(service.count_memes(filter.into()).await?))
}
async fn get_category(
Path(category_id): Path<String>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(service.get_category(&category_id).await?.ok_or_else(
|| APIError::NotFound("Category not found".to_string()),
)?))
}
async fn get_categories(
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(service.get_categories().await?))
}
async fn get_user(
Path(user_id): Path<String>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(V2User::from(
service
.get_user(UserIdentifier::Id(user_id))
.await?
.ok_or_else(|| APIError::NotFound("User not found".to_string()))?,
)))
}
async fn get_users(
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(
service
.get_users()
.await?
.into_iter()
.map(V2User::from)
.collect::<Vec<V2User>>(),
))
}
async fn get_user_memes(
Query(filter): Query<MemeFilterQuery>,
Path(user_id): Path<String>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(
service
.get_memes(MemeOptions {
category: None,
user_id: Some(user_id),
username: None,
search: None,
limit: filter.limit,
after: filter.after,
})
.await?
.into_iter()
.map(V2Meme::from)
.collect::<Vec<V2Meme>>(),
))
}
async fn get_user_meme(
Path((user_id, filename)): Path<(String, String)>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
let decoded = urlencoding::decode(&filename)?.into_owned();
Ok(Json(V2Meme::from(
service
.get_user_meme(user_id, decoded)
.await?
.ok_or_else(|| APIError::NotFound("Meme not found".to_string()))?,
)))
}
fn meme_routes() -> Router<BoxRoute> {
Router::new()
.route("/", get(get_memes))
.route("/:meme_id", get(get_meme))
.route("/random", get(get_random_meme))
.route("/count", get(count_memes))
.boxed()
}
fn category_routes() -> Router<BoxRoute> {
Router::new()
.route("/", get(get_categories))
.route("/:category_id", get(get_category))
.boxed()
}
fn user_routes() -> Router<BoxRoute> {
Router::new()
.route("/", get(get_users))
.route("/:user_id", get(get_user))
.route("/:user_id/memes", get(get_user_memes))
.route("/:user_id/memes/:filename", get(get_user_meme))
.boxed()
}
pub fn routes() -> Router<BoxRoute> {
Router::new()
.nest("/memes", meme_routes())
.nest("/categories", category_routes())
.nest("/users", user_routes())
.boxed()
}

11
templates/dir.html Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

8
testenv/debug_run.sh Executable file
View 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

View file

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

View 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));

View 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');