Compare commits
37 Commits
748ff2b66a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cef0b320d8 | |||
| c9dce27582 | |||
| d4ef24c99f | |||
| e95bb7d291 | |||
| 972a280229 | |||
| 8d494c6756 | |||
| 190543cd6a | |||
| 2ffe9c0c2b | |||
| 9347db69fd | |||
| 7db7c1d755 | |||
| c2a0a671a5 | |||
| c5b2a60d14 | |||
| 5bc0e08f59 | |||
| 5f357d405b | |||
| d62a8f7e06 | |||
| 8ef4a38c08 | |||
| b9f02e9f6e | |||
| 1280c745c7 | |||
| c33f4e6243 | |||
| b3bd9b6ed2 | |||
| e5fcc4f59c | |||
| f66df87168 | |||
| c7c3c33686 | |||
| 9b19475e88 | |||
| 82176b83b2 | |||
| 2c7787f7da | |||
| 0c5b339c66 | |||
| cf92b4ede4 | |||
| dbde062e01 | |||
| f53c086fa6 | |||
| e6c1d58454 | |||
| 9483d067a2 | |||
| 411f5fbd7e | |||
| b90eea57f0 | |||
| 6e2e3c05ad | |||
| 920b48776e | |||
| 2d5e568b62 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,3 +6,5 @@ database.db-shm
|
|||||||
database.db-wal
|
database.db-wal
|
||||||
priv.pem
|
priv.pem
|
||||||
pub.pem
|
pub.pem
|
||||||
|
images/*
|
||||||
|
database.db
|
||||||
50
.sqlx/query-1e9045f52c002a19b815351fee1c7ee7520478ff4f5886b4523fb0dc4df0e204.json
generated
Normal file
50
.sqlx/query-1e9045f52c002a19b815351fee1c7ee7520478ff4f5886b4523fb0dc4df0e204.json
generated
Normal 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"
|
||||||
|
}
|
||||||
44
.sqlx/query-21875450b713d126c5fe3074b2f6ed2766bc92b6c047eb014106b0c82b905dd6.json
generated
Normal file
44
.sqlx/query-21875450b713d126c5fe3074b2f6ed2766bc92b6c047eb014106b0c82b905dd6.json
generated
Normal 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"
|
||||||
|
}
|
||||||
44
.sqlx/query-26ed513b4f9720a7cec47a9bcd3023cedb6888111199fd3c7d9a2bdcc6dd3398.json
generated
Normal file
44
.sqlx/query-26ed513b4f9720a7cec47a9bcd3023cedb6888111199fd3c7d9a2bdcc6dd3398.json
generated
Normal 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"
|
||||||
|
}
|
||||||
26
.sqlx/query-3367d7df853d161f82b517e6601de33b7655c98bdade65ab8b7d985ee77b0779.json
generated
Normal file
26
.sqlx/query-3367d7df853d161f82b517e6601de33b7655c98bdade65ab8b7d985ee77b0779.json
generated
Normal 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"
|
||||||
|
}
|
||||||
44
.sqlx/query-3d549f4fa2e86c6eab7dfb482cf0ef6a80cfcff18c1504a81757c26920c03f55.json
generated
Normal file
44
.sqlx/query-3d549f4fa2e86c6eab7dfb482cf0ef6a80cfcff18c1504a81757c26920c03f55.json
generated
Normal 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"
|
||||||
|
}
|
||||||
50
.sqlx/query-467dcd7fd379750cbf30a190d06dc1b3e61b1bd7ef288fbf6fbedec52c276434.json
generated
Normal file
50
.sqlx/query-467dcd7fd379750cbf30a190d06dc1b3e61b1bd7ef288fbf6fbedec52c276434.json
generated
Normal 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"
|
||||||
|
}
|
||||||
26
.sqlx/query-4bc709613cface1482452d8bcebf70e822fd3854e4e441e01426fb79c3dfb489.json
generated
Normal file
26
.sqlx/query-4bc709613cface1482452d8bcebf70e822fd3854e4e441e01426fb79c3dfb489.json
generated
Normal 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"
|
||||||
|
}
|
||||||
12
.sqlx/query-597ffc913008b9c86d1370a1d6ce3019b17c20e67400211000c395da08b21708.json
generated
Normal file
12
.sqlx/query-597ffc913008b9c86d1370a1d6ce3019b17c20e67400211000c395da08b21708.json
generated
Normal 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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
50
.sqlx/query-9c9232680b6d9701e9646f546673e54a2ea3a9b6f53a32c79661e6f93c57dcd6.json
generated
Normal file
50
.sqlx/query-9c9232680b6d9701e9646f546673e54a2ea3a9b6f53a32c79661e6f93c57dcd6.json
generated
Normal 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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
64
README.md
64
README.md
@@ -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
|
||||||
|
}
|
||||||
|
```
|
||||||
BIN
database.db
BIN
database.db
Binary file not shown.
25915
items.json
Normal file
25915
items.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||||
);
|
);
|
||||||
@@ -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
58
scripts/downloader.py
Normal 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
50
scripts/import_data.py
Normal 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
72
src/cases.rs
Normal 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
74
src/items.rs
Normal 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())
|
||||||
|
}
|
||||||
40
src/main.rs
40
src/main.rs
@@ -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,16 +28,33 @@ 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))?
|
||||||
.run()
|
.run()
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/types.rs
Normal file
58
src/types.rs
Normal 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,
|
||||||
|
}
|
||||||
425
src/users.rs
425
src/users.rs
@@ -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())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(HttpResponse::Unauthorized().finish())
|
||||||
}
|
}
|
||||||
// Create the JWT
|
}
|
||||||
let header = Header::new(Algorithm::ES256);
|
|
||||||
// Put a random KeyId
|
#[derive(Deserialize, Debug)]
|
||||||
let mut rng = rand::rng();
|
struct LogoutInfo {
|
||||||
let key_id: i64 = rng.random();
|
uuid: String,
|
||||||
let claims = UserTokenClaims {
|
token: String,
|
||||||
exp: (get_current_timestamp() + app_state.token_expiration) as usize,
|
|
||||||
kid: key_id,
|
|
||||||
uid: user.uuid.clone(),
|
|
||||||
};
|
|
||||||
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();
|
|
||||||
HttpResponse::Ok().cookie(cookie).finish()
|
#[derive(Deserialize, Debug)]
|
||||||
|
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(®ister_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, ®ister_info.username, ®ister_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
21
src/utils.rs
Normal 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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user