Compare commits

..

37 Commits

Author SHA1 Message Date
cef0b320d8 Added user register 2025-04-16 17:28:21 +02:00
c9dce27582 Added logout api route 2025-04-13 22:25:41 +02:00
d4ef24c99f Small formating 2025-04-11 23:50:16 +02:00
e95bb7d291 Rewrote login route (start of auth rewrite) 2025-04-11 23:49:11 +02:00
972a280229 Removed unimplemented functions 2025-04-11 23:48:50 +02:00
8d494c6756 Sqlx prepare 2025-04-11 23:48:33 +02:00
190543cd6a Minor changes to database schema 2025-04-11 23:48:12 +02:00
2ffe9c0c2b Started adding doc to better build the system 2025-04-09 22:48:11 +02:00
9347db69fd Removed database from git (and added it to gitignore) 2025-04-09 16:22:56 +02:00
7db7c1d755 Altered tables and added a new table for refresh_tokens 2025-04-09 16:02:23 +02:00
c2a0a671a5 Added money to inventories 2025-03-27 21:56:17 +01:00
c5b2a60d14 Formatted files 2025-03-27 21:56:05 +01:00
5bc0e08f59 Refactored user code 2025-03-27 21:55:50 +01:00
5f357d405b Added allow credentials 2025-03-17 22:32:00 +01:00
d62a8f7e06 Will now use auth header 2025-03-17 22:31:49 +01:00
8ef4a38c08 Added CORS allow credential header to options and responses 2025-03-17 22:31:33 +01:00
b9f02e9f6e Formatted files 2025-03-17 15:02:58 +01:00
1280c745c7 Added default headers (cors) 2025-03-17 15:00:02 +01:00
c33f4e6243 Changed one Route (have to add cors header to all routes) 2025-03-16 23:40:44 +01:00
b3bd9b6ed2 Changed dependencies 2025-03-16 23:40:23 +01:00
e5fcc4f59c Changed imports 2025-03-16 23:39:41 +01:00
f66df87168 Added options route for every route for client cors 2025-03-16 23:39:21 +01:00
c7c3c33686 Added endpoints documentation 2025-03-15 22:44:02 +01:00
9b19475e88 Fixed bug where items would be returned instead of cases 2025-03-15 21:20:34 +01:00
82176b83b2 Removed unnecessary derives 2025-03-15 21:20:17 +01:00
2c7787f7da Move keys script to /scripts 2025-03-15 21:20:04 +01:00
0c5b339c66 Added endpoints to server 2025-03-15 21:17:10 +01:00
cf92b4ede4 Added cases and items endpoints 2025-03-15 21:16:59 +01:00
dbde062e01 Added some structs 2025-03-15 21:16:39 +01:00
f53c086fa6 Added data in db 2025-03-15 21:16:28 +01:00
e6c1d58454 ignore images 2025-03-15 21:16:21 +01:00
9483d067a2 Changed table revoked to add expiry 2025-03-15 21:16:04 +01:00
411f5fbd7e Added expiration time for revoked tokens 2025-03-15 21:15:49 +01:00
b90eea57f0 Added the downloader for images 2025-03-15 19:56:23 +01:00
6e2e3c05ad Added items.json from dataset 2025-03-15 19:50:34 +01:00
920b48776e Created script to import all data into database 2025-03-15 19:50:15 +01:00
2d5e568b62 Changed database structure 2025-03-15 19:50:01 +01:00
26 changed files with 27035 additions and 156 deletions

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ database.db-shm
database.db-wal database.db-wal
priv.pem priv.pem
pub.pem pub.pem
images/*
database.db

View File

@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM items WHERE id = $1",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "uuid",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "rarity",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "image",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "price",
"ordinal": 5,
"type_info": "Float"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "1e9045f52c002a19b815351fee1c7ee7520478ff4f5886b4523fb0dc4df0e204"
}

View File

@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM cases WHERE uuid = $1",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "uuid",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "image",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "price",
"ordinal": 4,
"type_info": "Float"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "21875450b713d126c5fe3074b2f6ed2766bc92b6c047eb014106b0c82b905dd6"
}

View File

@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM cases WHERE id = $1",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "uuid",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "image",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "price",
"ordinal": 4,
"type_info": "Float"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "26ed513b4f9720a7cec47a9bcd3023cedb6888111199fd3c7d9a2bdcc6dd3398"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM items_cases WHERE \"item\" = $1",
"describe": {
"columns": [
{
"name": "item",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "case",
"ordinal": 1,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "3367d7df853d161f82b517e6601de33b7655c98bdade65ab8b7d985ee77b0779"
}

View File

@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM cases",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "uuid",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "image",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "price",
"ordinal": 4,
"type_info": "Float"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "3d549f4fa2e86c6eab7dfb482cf0ef6a80cfcff18c1504a81757c26920c03f55"
}

View File

@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM items",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "uuid",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "rarity",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "image",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "price",
"ordinal": 5,
"type_info": "Float"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "467dcd7fd379750cbf30a190d06dc1b3e61b1bd7ef288fbf6fbedec52c276434"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM items_cases WHERE \"case\" = $1",
"describe": {
"columns": [
{
"name": "item",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "case",
"ordinal": 1,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "4bc709613cface1482452d8bcebf70e822fd3854e4e441e01426fb79c3dfb489"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO refresh_tokens ('token', 'previous', 'user', 'expiry') VALUES (?1, ?2, ?3, ?4)",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "597ffc913008b9c86d1370a1d6ce3019b17c20e67400211000c395da08b21708"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT * FROM users WHERE username = $1", "query": "SELECT * FROM users WHERE 'id' = ?1 OR 'username' = ?2",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -30,7 +30,7 @@
} }
], ],
"parameters": { "parameters": {
"Right": 1 "Right": 2
}, },
"nullable": [ "nullable": [
false, false,
@@ -40,5 +40,5 @@
false false
] ]
}, },
"hash": "606364c79e0990deb07dfbe6c32b3d302d083ec5333f3a5ce04113c38a041100" "hash": "6826871e47d86547cc501c6bb14db889beb0dce0fce4ecd22826a482b7558a2f"
} }

View File

@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM items WHERE uuid = $1",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "uuid",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "rarity",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "image",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "price",
"ordinal": 5,
"type_info": "Float"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "9c9232680b6d9701e9646f546673e54a2ea3a9b6f53a32c79661e6f93c57dcd6"
}

View File

@@ -10,5 +10,6 @@ serde_json = "1.0.140"
sqlx = { version = "0.8.3", features = ["sqlite", "sqlx-macros", "runtime-tokio"] } sqlx = { version = "0.8.3", features = ["sqlite", "sqlx-macros", "runtime-tokio"] }
jsonwebtoken = { version = "9.3.1", features = ["use_pem"] } jsonwebtoken = { version = "9.3.1", features = ["use_pem"] }
uuid = { version = "1.15.1", features = ["v4"] } uuid = { version = "1.15.1", features = ["v4"] }
argon2 = "0.6.0-pre.1" argon2 = { version="0.6.0-pre.1", features = ["std"] }
rand = "0.9.0" rand = "0.9.0"
actix-cors = "0.7.0"

View File

@@ -1,3 +1,67 @@
# api-server # api-server
Le serveur API de gambling.aindustries.be Le serveur API de gambling.aindustries.be
## All API endpoints
### /items
##### Permet de récupérer tout les items de la base de données
Utilisation:
```ts
const {data, errors} = await fetch("/items").then(r => r.json());
```
### /item
##### Permet de récupérer les informations d'un item
Utilisation:
```ts
let uuid; // Some uuid
const {data, errors} = await fetch(`/item?uuid=${uuid}`).then(r => r.json());
```
### /item-cases
##### Permet de récupérer toutes les cases où un item est
Utilisation:
```ts
let uuid; // Some uuid
const {data, errors} = await fetch(`/item-cases?uuid=${uuid}`).then(r => r.json());
```
##### Le même endpoint existe pour les cases: /cases, /case, /case-items
## Data Representation
### Item
```json
{
"uuid": "eee91ea1-1827-482b-b298-63bd6eda0221",
"name": "AWP Lightning Strike",
"rarity": 5,
"image": "/images/items/AWP_Lightning_Strike.png",
"price": "85.36"
}
```
> [!NOTE]
> Rarity entre 0 et 6 (du moins rare au plus rare)
### Case
```json
{
"uuid": "26684b79-32ff-4fa2-b026-b4f26ab2132a",
"name": "CS:GO Weapon Case",
"image": "/images/cases/CS:GO_Weapon_Case.png",
"price": 85.36
}
```

Binary file not shown.

25915
items.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,22 @@
-- Add migration script here -- Add migration script here
CREATE TABLE users ( CREATE TABLE IF NOT EXISTS users (
'id' INTEGER PRIMARY KEY NOT NULL , 'id' INTEGER PRIMARY KEY NOT NULL,
'uuid' TEXT UNIQUE NOT NULL, 'uuid' TEXT UNIQUE NOT NULL,
'username' TEXT NOT NULL, 'username' TEXT NOT NULL,
'hash' TEXT NOT NULL, 'hash' TEXT NOT NULL,
'email' TEXT NOT NULL 'email' TEXT NOT NULL
); );
CREATE TABLE inventories ( CREATE TABLE IF NOT EXISTS inventories (
'id' INTEGER PRIMARY KEY NOT NULL , 'id' INTEGER PRIMARY KEY NOT NULL,
'uuid' TEXT UNIQUE NOT NULL, 'uuid' TEXT UNIQUE NOT NULL,
'user' INTEGER NOT NULL, 'user' INTEGER NOT NULL,
'money' FLOAT DEFAULT 0,
FOREIGN KEY ('user') REFERENCES users ('id') FOREIGN KEY ('user') REFERENCES users ('id')
); );
CREATE TABLE user_items ( CREATE TABLE IF NOT EXISTS user_items (
'id' INTEGER PRIMARY KEY NOT NULL , 'id' INTEGER PRIMARY KEY NOT NULL,
'uuid' TEXT UNIQUE NOT NULL, 'uuid' TEXT UNIQUE NOT NULL,
'inventory' INTEGER NOT NULL, 'inventory' INTEGER NOT NULL,
'item' INTEGER NOT NULL, 'item' INTEGER NOT NULL,
@@ -23,19 +24,36 @@ CREATE TABLE user_items (
FOREIGN KEY ('item') REFERENCES items ('id') FOREIGN KEY ('item') REFERENCES items ('id')
); );
CREATE TABLE items ( CREATE TABLE IF NOT EXISTS items (
'id' INTEGER PRIMARY KEY NOT NULL , 'id' INTEGER PRIMARY KEY NOT NULL,
'uuid' TEXT UNIQUE NOT NULL, 'uuid' TEXT UNIQUE NOT NULL,
'name' TEXT NOT NULL, 'name' TEXT NOT NULL,
'rarity' INTEGER NOT NULL, 'rarity' INTEGER NOT NULL,
'image' TEXT NOT NULL, 'image' TEXT NOT NULL,
'case' INTEGER NOT NULL, 'price' FLOAT NOT NULL
FOREIGN KEY ('case') REFERENCES cases ('id')
); );
CREATE TABLE cases ( CREATE TABLE IF NOT EXISTS cases (
'id' INTEGER PRIMARY KEY NOT NULL , 'id' INTEGER PRIMARY KEY NOT NULL,
'uuid' TEXT UNIQUE NOT NULL, 'uuid' TEXT UNIQUE NOT NULL,
'name' TEXT NOT NULL, 'name' TEXT NOT NULL,
'image' TEXT NOT NULL 'image' TEXT NOT NULL,
'price' FLOAT NOT NULL
);
CREATE TABLE IF NOT EXISTS items_cases (
'item' INTEGER NOT NULL,
'case' INTEGER NOT NULL,
FOREIGN KEY ('item') REFERENCES items ('id'),
FOREIGN KEY ('case') REFERENCES cases ('id'),
PRIMARY KEY ('item', 'case')
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
'id' INTEGER PRIMARY KEY,
'token' TEXT,
'previous' TEXT,
'user' INTEGER,
'expiry' INTEGER,
FOREIGN KEY ('user') REFERENCES users ('id')
); );

View File

@@ -1,5 +0,0 @@
-- Add migration script here
CREATE TABLE IF NOT EXISTS revoked (
'token_id' INTEGER NOT NULL,
'user_id' VARCHAR NOT NULL
)

58
scripts/downloader.py Normal file
View File

@@ -0,0 +1,58 @@
from pathlib import Path
from time import sleep
import requests
import json
import os
if not Path("images/").exists():
os.mkdir("images/")
if not Path("images/weapons/").exists():
os.mkdir("images/weapons/")
if not Path("images/cases/").exists():
os.mkdir("images/cases/")
with open("items.json") as f:
items = json.load(f)
weapon_images = []
case_images = []
for item in items:
weapon_images.append(item["img_url"])
for case in item["cases"]:
case_images.append(case["img_url"])
weapon_images = list(set(weapon_images))
case_images = list(set(case_images))
for url in weapon_images:
print(f"\rDownloading {url}")
r = requests.get(url.replace(".png", ".webp"))
r.cookies.set("cookie_notice_accepted", "true")
while r.status_code == 429:
sleep(0.2)
r = requests.get(url.replace(".png", ".webp"))
r.cookies.set("cookie_notice_accepted", "true")
if r.status_code == 200:
with open(f"images/weapons/{url.split('/')[-1]}", "wb") as f:
r.raw.decode_content = True
f.write(r.content)
else:
print(f"Error {r.reason}, {r.status_code}")
for url in case_images:
print(f"\rDownloading {url}")
r = requests.get(url.replace(".png", ".webp"))
r.cookies.set("cookie_notice_accepted", "true")
while r.status_code == 429:
sleep(0.2)
r = requests.get(url.replace(".png", ".webp"))
r.cookies.set("cookie_notice_accepted", "true")
if r.status_code == 200:
with open(f"images/cases/{url.split('/')[-1]}", "wb") as f:
r.raw.decode_content = True
f.write(r.content)
else:
print(f"Error {r.reason}, {r.status_code}")

50
scripts/import_data.py Normal file
View File

@@ -0,0 +1,50 @@
import json
import sqlite3
import uuid
RARITY_MAPPING = {
"Consumer": 0,
"Industrial": 1,
"Mil-spec": 2,
"Restricted": 3,
"Classified": 4,
"Covert": 5,
"Contraband": 6
}
with open("items.json", "r") as f:
items = json.load(f)
rcases = []
for item in items:
item_cases = item["cases"]
rcases.extend(item_cases)
cases = []
for case in rcases:
if case not in cases:
cases.append(case)
conn = sqlite3.connect("database.db")
cur = conn.cursor()
for case in cases:
uid = uuid.uuid4()
image = "/images/cases/" + case["img_url"].split("/")[-1]
case_id = conn.execute("INSERT INTO cases ('uuid', 'name', 'image', 'price') VALUES (?, ?, ?, ?) RETURNING id",
[str(uid), case["name"], image, case["price"]]).fetchone()
case["id"] = case_id[0]
for item in items:
uid = uuid.uuid4()
image = "/images/items/" + item["img_url"].split("/")[-1]
item_id = conn.execute(
"INSERT INTO items ('uuid', 'name', 'rarity', 'image', 'price') VALUES (?, ?, ?, ?, ?) RETURNING id",
[str(uid), item["name"], RARITY_MAPPING[item["rarity"]], image, item["price"]]).fetchone()
for case in item["cases"]:
for rcase in cases:
if rcase["name"] == case["name"]:
bcase = rcase
conn.execute("INSERT INTO items_cases ('item', 'case') VALUES (?, ?)", [item_id[0], bcase["id"]])
conn.commit()

72
src/cases.rs Normal file
View File

@@ -0,0 +1,72 @@
use crate::AppState;
use crate::types::*;
use actix_web::web::Data;
use actix_web::{HttpResponse, Responder, get, web};
use serde_json::to_string;
use sqlx::query_as;
#[get("/case")]
async fn get_case(query: web::Query<DataUuid>, app_state: Data<AppState>) -> impl Responder {
let case = query_as!(Case, "SELECT * FROM cases WHERE uuid = $1", query.uuid)
.fetch_one(&app_state.database)
.await;
if case.is_err() {
return HttpResponse::NotFound().finish();
}
let json = to_string(&case.unwrap());
if json.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().body(json.unwrap())
}
#[get("/cases")]
async fn get_cases(app_state: Data<AppState>) -> impl Responder {
let cases = query_as!(Case, "SELECT * FROM cases")
.fetch_all(&app_state.database)
.await;
if cases.is_err() {
return HttpResponse::NotFound().finish();
}
let json = to_string(&cases.unwrap());
if json.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().body(json.unwrap())
}
#[get("/case-items")]
async fn get_case_items(query: web::Query<DataUuid>, app_state: Data<AppState>) -> impl Responder {
let case = query_as!(Case, "SELECT * FROM cases WHERE uuid = $1", query.uuid)
.fetch_one(&app_state.database)
.await;
if case.is_err() {
return HttpResponse::NotFound().finish();
}
let case = case.unwrap();
let items_cases = query_as!(
ItemCases,
"SELECT * FROM items_cases WHERE \"case\" = $1",
case.id
)
.fetch_all(&app_state.database)
.await;
if items_cases.is_err() {
return HttpResponse::NotFound().finish();
}
let items_cases = items_cases.unwrap();
let mut items = vec![];
for item_case in items_cases {
if let Ok(item) = query_as!(Item, "SELECT * FROM items WHERE id = $1", item_case.item)
.fetch_one(&app_state.database)
.await
{
items.push(item);
}
}
let json = to_string(&items);
if json.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().body(json.unwrap())
}

74
src/items.rs Normal file
View File

@@ -0,0 +1,74 @@
use crate::AppState;
use crate::types::*;
use actix_web::web::Data;
use actix_web::{HttpResponse, Responder, get, web};
use serde_json::to_string;
use sqlx::query_as;
#[get("/item")]
async fn get_item(query: web::Query<DataUuid>, app_state: Data<AppState>) -> impl Responder {
let item = query_as!(Item, "SELECT * FROM items WHERE uuid = $1", query.uuid)
.fetch_one(&app_state.database)
.await;
if item.is_err() {
return HttpResponse::NotFound().finish();
}
let json = to_string(&item.unwrap());
if json.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok()
.append_header(("Access-Control-Allow-Origin", "*"))
.body(json.unwrap())
}
#[get("/items")]
async fn get_items(app_state: Data<AppState>) -> impl Responder {
let items = query_as!(Item, "SELECT * FROM items")
.fetch_all(&app_state.database)
.await;
if items.is_err() {
return HttpResponse::NotFound().finish();
}
let json = to_string(&items.unwrap());
if json.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().body(json.unwrap())
}
#[get("/item-cases")]
async fn get_item_cases(query: web::Query<DataUuid>, app_state: Data<AppState>) -> impl Responder {
let item = query_as!(Item, "SELECT * FROM items WHERE uuid = $1", query.uuid)
.fetch_one(&app_state.database)
.await;
if item.is_err() {
return HttpResponse::NotFound().finish();
}
let item = item.unwrap();
let items_cases = query_as!(
ItemCases,
"SELECT * FROM items_cases WHERE \"item\" = $1",
item.id
)
.fetch_all(&app_state.database)
.await;
if items_cases.is_err() {
return HttpResponse::NotFound().finish();
}
let items_cases = items_cases.unwrap();
let mut cases = vec![];
for item_case in items_cases {
if let Ok(item) = query_as!(Case, "SELECT * FROM cases WHERE id = $1", item_case.case)
.fetch_one(&app_state.database)
.await
{
cases.push(item);
}
}
let json = to_string(&cases);
if json.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().body(json.unwrap())
}

View File

@@ -1,15 +1,24 @@
mod cases;
mod items;
mod types;
mod users; mod users;
mod utils;
use cases::*;
use items::*;
use users::*; use users::*;
use utils::*;
use actix_web::web::Data; use actix_web::web::Data;
use actix_web::{App, HttpServer}; use actix_web::{App, HttpServer, middleware::DefaultHeaders};
use sqlx::sqlite::SqlitePool; use sqlx::sqlite::SqlitePool;
#[derive(Clone)] #[derive(Clone)]
struct AppState { struct AppState {
database: SqlitePool, database: SqlitePool,
token_expiration: u64, jwt_token_expiration: u64,
refresh_token_expiration: u64,
allow_origins: Vec<&'static str>,
} }
#[actix_web::main] #[actix_web::main]
@@ -19,13 +28,30 @@ async fn main() -> std::io::Result<()> {
.expect("Could not connect to db"); .expect("Could not connect to db");
let app_state = Data::new(AppState { let app_state = Data::new(AppState {
database: pool, database: pool,
token_expiration: 86400, jwt_token_expiration: 60,
refresh_token_expiration: 86400,
allow_origins: vec!["http://localhost:5173"],
}); });
HttpServer::new(move || { HttpServer::new(move || {
App::new() App::new()
.wrap(
DefaultHeaders::new()
.add((
"Access-Control-Allow-Origin",
app_state.allow_origins.join(","),
))
.add(("Access-Control-Allow-Credentials", "true")),
)
.service(login) .service(login)
.service(register)
.service(logout) .service(logout)
.service(register)
.service(get_case)
.service(get_cases)
.service(get_item)
.service(get_items)
.service(get_case_items)
.service(get_item_cases)
.service(options)
.app_data(app_state.clone()) .app_data(app_state.clone())
}) })
.bind(("127.0.0.1", 8000))? .bind(("127.0.0.1", 8000))?

58
src/types.rs Normal file
View File

@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
pub struct ItemCases {
pub item: i64,
pub case: i64,
}
#[derive(Deserialize)]
pub struct DataUuid {
pub uuid: String,
}
#[derive(Serialize)]
pub struct Case {
pub id: i64,
pub uuid: String,
pub name: String,
pub image: String,
pub price: f64,
}
#[derive(Serialize)]
pub struct Item {
pub id: i64,
pub uuid: String,
pub name: String,
pub rarity: i64,
pub image: String,
pub price: f64,
}
#[derive(Serialize, Deserialize)]
pub struct UserLogin {
pub username: String,
pub password: String,
}
#[derive(Serialize, Deserialize)]
pub struct UserRegister {
pub username: String,
pub password: String,
pub email: String,
}
pub struct User {
pub id: i64,
pub uuid: String,
pub username: String,
pub hash: String,
pub email: String,
}
#[derive(Serialize, Deserialize)]
pub struct UserTokenClaims {
pub exp: usize, // Required (validate_exp defaults to true in validation). Expiration time (as UTC timestamp)
pub kid: i64,
pub uid: String,
}

View File

@@ -1,159 +1,338 @@
use std::fs::File; //! # Authentication flow
use std::io::Read; //! Before being able to authenticate, the user needs to create an account
//! Here's how it can be done:
//! - The user submits information on the client
//! - The client sends a request to the "register" endpoint
//! - The endpoint ensures the information given is valid
//! - An account is created
//! - The user is redirected to the login page
//!
//! After, the user can authenticate
//! Here's how it works:
//! - The users submits information on the client
//! - The client sends a request to the "login" endpoint
//! - The endpoint makes sure the information given is valid (otherwise reject the request: no db call)
//! - Then it checks that the user exists
//! - Then it checks that the correct password was given
//! - Then it creates two tokens: a short-lived and a refresh-token
//! - The short-lived token must be saved in local-storage and the refresh-token must be saved as a http only cookie
//!
//! Once authenticated, the short-lived token can be renewed
//! Here's how it works:
//! - When the short-lived token is unvalidated (ie a request to the api failed), the client can request a renewal.
//! - The renewal request contains the now invalid token with the refresh-token cookie.
//! - The server checks that the refresh-token is valid (good user, not expired, ...)
//! - If checks pass, the server generates a new short-lived token.
//! - If it fails, the client redirects the user to login.
use crate::AppState; use crate::AppState;
use actix_web::cookie::Cookie; use actix_web::cookie::Cookie;
use actix_web::cookie::time::Duration;
use actix_web::web::{Data, Json}; use actix_web::web::{Data, Json};
use actix_web::{HttpRequest, HttpResponse, Responder, post}; use actix_web::{App, HttpRequest, HttpResponse, Responder, post};
use argon2::Argon2; use argon2::{Argon2, PasswordHasher};
use argon2::password_hash::{ use argon2::password_hash::{PasswordHash, PasswordVerifier, SaltString};
PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng, use argon2::password_hash::rand_core::OsRng;
}; use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp};
use jsonwebtoken::{
Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, get_current_timestamp,
};
use rand::Rng; use rand::Rng;
use serde::{Deserialize, Serialize}; use rand::distr::Alphanumeric;
use sqlx::{query, query_as}; use serde::{Deserialize, Deserializer};
use uuid::Uuid; use serde_json::{json, to_string};
use sqlx::sqlite::SqliteQueryResult;
use sqlx::{Pool, Sqlite, query, query_as};
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::fs::File;
use std::io::Read;
#[derive(Serialize, Deserialize)] #[derive(Debug)]
struct UserLogin { struct Password {
username: String, value: String,
password: String,
} }
#[derive(Serialize, Deserialize)] #[derive(Debug)]
struct UserRegister { struct Username {
username: String, value: String,
password: String,
email: String,
} }
#[derive(Clone)]
struct User { struct User {
id: i64, id: i64,
uuid: String, uuid: String,
username: String, username: String,
hash: String,
email: String, email: String,
hash: String,
} }
#[derive(Serialize, Deserialize)] impl User {
struct UserTokenClaims { async fn fetch_optional(
exp: usize, // Required (validate_exp defaults to true in validation). Expiration time (as UTC timestamp) database: &Pool<Sqlite>,
kid: i64, id: Option<i64>,
uid: String, uuid: Option<&str>,
username: Option<&Username>,
) -> Result<Option<User>, sqlx::Error> {
match username {
Some(username) => {
query_as!(
User,
"SELECT * FROM users WHERE 'id' = ?1 OR 'username' = ?2 or 'uuid' = ?3",
id,
uuid,
username.value
)
.fetch_optional(database)
.await
}
None => {
query_as!(
User,
"SELECT * FROM users WHERE 'id' = ?1 OR 'username' = ?2 OR 'uuid' = ?3",
id,
uuid,
None::<String>
)
.fetch_optional(database)
.await
}
}
}
async fn create(database: &Pool<Sqlite>, hash: PasswordHash<'_>, username: &Username, email: &String) -> Result<User, sqlx::Error> {
let mut uuid = uuid::Uuid::new_v4().to_string();
while let Ok(Some(_)) = query!("SELECT id FROM users WHERE 'uuid' = ?1", uuid).fetch_optional(database).await {
uuid = uuid::Uuid::new_v4().to_string();
}
let hash_string = hash.to_string();
query_as!(User,
"INSERT INTO users ('uuid', 'username', 'email', 'hash') VALUES (?1, ?2, ?3, ?4) RETURNING *",
uuid,
username.value,
email,
hash_string
)
.fetch_one(database)
.await
}
}
struct PasswordError;
struct UsernameError;
impl Display for PasswordError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "Invalid password.")
}
}
impl<'de> Deserialize<'de> for Password {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Password::try_from(value).map_err(serde::de::Error::custom)
}
}
impl<'de> Deserialize<'de> for Username {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Username::try_from(value).map_err(serde::de::Error::custom)
}
}
impl Display for UsernameError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "Invalid username.")
}
}
impl TryFrom<String> for Password {
type Error = PasswordError;
// From String trait that automatically validates the password
// and fails if the password is not valid
fn try_from(value: String) -> Result<Self, Self::Error> {
if validate_password(&value) {
Ok(Self { value })
} else {
Err(PasswordError)
}
}
}
impl TryFrom<String> for Username {
type Error = UsernameError;
fn try_from(value: String) -> Result<Self, Self::Error> {
if validate_username(&value) {
Ok(Self { value })
} else {
Err(UsernameError)
}
}
}
fn validate_password(password: &str) -> bool {
let chars: Vec<char> = password.chars().collect();
chars.len() < 8 && !chars.iter().any(|x| !x.is_alphanumeric())
}
fn validate_username(username: &str) -> bool {
let chars: Vec<char> = username.chars().collect();
chars.len() >= 3
}
#[derive(Deserialize, Debug)]
struct LoginInfo {
username: Username,
password: Password,
} }
#[post("/login")] #[post("/login")]
async fn login(user_login: Json<UserLogin>, app_state: Data<AppState>) -> impl Responder { async fn login(
// Verify that the password is correct app_state: Data<AppState>,
let argon2 = Argon2::default(); login_info: Json<LoginInfo>,
let user = query_as!( ) -> Result<impl Responder, Box<dyn Error>> {
User, if let Ok(Some(user)) =
"SELECT * FROM users WHERE username = $1", User::fetch_optional(&app_state.database, None, None, Some(&login_info.username)).await
user_login.username
)
.fetch_one(&app_state.database)
.await;
if user.is_err() {
return HttpResponse::BadRequest().finish();
}
let user = user.unwrap();
let hash = PasswordHash::new(&user.hash);
if hash.is_err() {
return HttpResponse::BadRequest().finish();
}
if argon2
.verify_password(user_login.password.as_bytes(), &hash.unwrap())
.is_err()
{ {
return HttpResponse::BadRequest().finish(); if validate_authentication(&user, &login_info.password)? {
let jwt_token = generate_jwt(&user, app_state.clone())?;
let refresh_token =
generate_refresh_token(&user, &jwt_token, app_state.clone()).await?;
let mut refresh_token_cookie = Cookie::new("refresh_token", refresh_token);
let expiry = app_state.refresh_token_expiration as i64;
refresh_token_cookie.set_max_age(Duration::new(expiry, 0));
let frontend_data = json!({
"uuid": user.uuid,
"username": user.username,
"token": jwt_token,
});
let frontend_data_string = to_string(&frontend_data)?;
Ok(HttpResponse::Ok()
.cookie(refresh_token_cookie)
.body(frontend_data_string))
} else {
Ok(HttpResponse::Unauthorized().finish())
} }
// Create the JWT } else {
let header = Header::new(Algorithm::ES256); Ok(HttpResponse::Unauthorized().finish())
// Put a random KeyId }
let mut rng = rand::rng(); }
let key_id: i64 = rng.random();
let claims = UserTokenClaims { #[derive(Deserialize, Debug)]
exp: (get_current_timestamp() + app_state.token_expiration) as usize, struct LogoutInfo {
kid: key_id, uuid: String,
uid: user.uuid.clone(), token: String,
};
let mut key = File::open("priv.pem").unwrap();
let mut buf = vec![];
key.read_to_end(&mut buf).unwrap();
let token = encode(&header, &claims, &EncodingKey::from_ec_pem(&buf).unwrap()).unwrap();
// Send the JWT as cookie
HttpResponse::Ok()
.cookie(Cookie::new("token", token))
.finish()
} }
#[post("/logout")] #[post("/logout")]
async fn logout(req: HttpRequest, app_state: Data<AppState>) -> impl Responder { async fn logout(
// Put the (KeyId, User) pair in the revoked table app_state: Data<AppState>,
// And remove data from client logout_info: Json<LogoutInfo>,
let token = req.cookie("token"); req: HttpRequest,
println!("token: {:?}", token); ) -> Result<impl Responder, Box<dyn Error>> {
if token.is_none() { let refresh_cookie = match req.cookie("refresh_token") {
return HttpResponse::BadRequest().finish(); Some(cookie) => cookie,
} None => return Ok(HttpResponse::Unauthorized().finish()),
let token = token.unwrap(); };
let token = token.value(); let refresh_token = refresh_cookie.value();
let mut key = File::open("pub.pem").unwrap(); match User::fetch_optional(&app_state.database, None, Some(&logout_info.uuid), None).await? {
let mut buf = vec![]; Some(user) => {
key.read_to_end(&mut buf).unwrap(); let now = get_current_timestamp() as i64;
let token = decode::<UserTokenClaims>( let result: SqliteQueryResult = query!(
token, "UPDATE refresh_tokens SET expiry = ?1 WHERE token = ?2 AND user = ?3 AND previous = ?4",
&DecodingKey::from_ec_pem(&buf).unwrap(), now,
&Validation::new(Algorithm::ES256), refresh_token,
); user.id,
match token { logout_info.token
Ok(token) => {
if query!(
"INSERT INTO revoked ( token_id, user_id ) VALUES ( $1, $2 )",
token.claims.kid,
token.claims.uid
) )
.execute(&app_state.database) .execute(&app_state.database)
.await .await?;
.is_err() if result.rows_affected() == 0 {
{ return Ok(HttpResponse::Unauthorized().finish());
return HttpResponse::InternalServerError().reason("Query fail").finish();
} }
let mut refresh_token_cookie = Cookie::new("refresh_token", "");
refresh_token_cookie.make_removal();
Ok(HttpResponse::Ok().cookie(refresh_token_cookie).finish())
} }
Err(e) => { None => Ok(HttpResponse::Unauthorized().finish()),
let message = format!("Error: {e}");
println!("{}", message);
return HttpResponse::InternalServerError().reason("Token caca").finish();
} }
} }
let mut cookie = Cookie::new("token", "");
cookie.make_removal(); #[derive(Deserialize, Debug)]
HttpResponse::Ok().cookie(cookie).finish() struct RegisterInfo {
username: Username,
password: Password,
email: String,
} }
#[post("/register")] #[post("/register")]
async fn register(user_register: Json<UserRegister>, app_state: Data<AppState>) -> impl Responder { async fn register(
let uuid = Uuid::new_v4().to_string(); app_state: Data<AppState>,
register_info: Json<RegisterInfo>,
) -> Result<impl Responder, Box<dyn Error>> {
if let Ok(Some(_)) = User::fetch_optional(&app_state.database, None, None, Some(&register_info.username)).await {
return Ok(HttpResponse::InternalServerError().finish());
}
let argon2 = Argon2::default(); let argon2 = Argon2::default();
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
let hash = argon2 let hash = argon2.hash_password(register_info.password.value.as_bytes(), &salt)?;
.hash_password(user_register.password.as_bytes(), &salt) User::create(&app_state.database, hash, &register_info.username, &register_info.email).await?;
.unwrap() Ok(HttpResponse::Ok().finish())
.to_string(); }
if query!(
"INSERT INTO users (uuid, username, hash, email) VALUES ($1, $2, $3, $4)", fn validate_authentication(user: &User, password: &Password) -> Result<bool, Box<dyn Error>> {
uuid, let argon2 = Argon2::default();
user_register.username, let hash = PasswordHash::new(&user.hash)?;
hash, if argon2
user_register.email .verify_password(password.value.as_bytes(), &hash)
)
.execute(&app_state.database)
.await
.is_err() .is_err()
{ {
return HttpResponse::InternalServerError().finish(); Ok(false)
}; } else {
HttpResponse::Ok().finish() Ok(true)
}
}
fn generate_jwt(user: &User, app_state: Data<AppState>) -> Result<String, Box<dyn Error>> {
let header = Header::new(Algorithm::ES256);
let mut rng = rand::rng();
let key_id: i64 = rng.random();
let claims = json!({
"exp": (get_current_timestamp() + app_state.jwt_token_expiration) as usize,
"kid": key_id,
"uid": user.uuid.clone(),
});
let mut key = File::open("priv.pem")?;
let mut buf = vec![];
key.read_to_end(&mut buf)?;
Ok(encode(&header, &claims, &EncodingKey::from_ec_pem(&buf)?)?)
}
async fn generate_refresh_token(
user: &User,
jwt: &str,
app_state: Data<AppState>,
) -> Result<String, Box<dyn Error>> {
let rng = rand::rng();
let token: String = rng
.sample_iter(&Alphanumeric)
.take(256)
.map(char::from)
.collect();
let expiry = (get_current_timestamp() + app_state.refresh_token_expiration) as i64;
query!(
"INSERT INTO refresh_tokens ('token', 'previous', 'user', 'expiry') VALUES (?1, ?2, ?3, ?4)",
token,
jwt,
user.id,
expiry
)
.execute(&app_state.database)
.await?;
Ok(token)
} }

21
src/utils.rs Normal file
View File

@@ -0,0 +1,21 @@
use crate::AppState;
use actix_web::web::Data;
use actix_web::{HttpResponse, Responder, options};
// This is needed for the web client.
// This returns the same options for every path of the api
#[options("/{_:.*}")]
async fn options(app_state: Data<AppState>) -> impl Responder {
HttpResponse::Ok()
.append_header((
"Access-Control-Allow-Origin",
app_state.allow_origins.join(","),
))
.append_header(("Access-Control-Allow-Methods", "GET, OPTIONS"))
.append_header((
"Access-Control-Allow-Headers",
"Content-Type, Authorization",
))
.append_header(("Access-Control-Allow-Credentials", "true"))
.finish()
}