Utilisation d'axum pour la backend du serveur + Ajout d'actions (#3)
Rust Checks / checks (push) Successful in 18s Details

Reviewed-on: #3
Co-authored-by: Tipragot <contact@tipragot.fr>
Co-committed-by: Tipragot <contact@tipragot.fr>
This commit is contained in:
Tipragot 2024-01-10 13:27:01 +00:00 committed by Corentin
parent 3ec6d37dfa
commit e10427d0ac
8 changed files with 428 additions and 896 deletions

19
.gitea/workflows/rust.yml Normal file
View File

@ -0,0 +1,19 @@
on: [push, pull_request]
name: Rust Checks
jobs:
checks:
runs-on: main
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Use cache
run: mkdir -p /cache/${{ gitea.repository }} && ln -s /cache/${{ gitea.repository }} target
- name: Cargo fmt
run: cargo fmt --check
- name: Cargo build
run: cargo build
- name: Cargo test
run: cargo test
- name: Cargo clippy
run: cargo clippy -- -D warnings

1017
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,21 @@
[package]
name = "ponguito-serv"
version = "0.1.0"
name = "ponguito-server"
version = "0.2.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lints.rust]
missing_docs = "warn"
[lints.clippy]
missing_docs_in_private_items = "warn"
unwrap_in_result = "warn"
unwrap_used = "warn"
nursery = "warn"
[dependencies]
chrono = "0.4.31"
rocket = { version = "0.5.0", features = ["json"] }
serde = "1.0.195"
chrono = { version = "0.4.31", features = ["serde"] }
serde = { version = "1.0.195", features = ["derive"] }
tokio = { version = "1.35.1", features = ["full"] }
axum-client-ip = "0.5.0"
serde_json = "1.0.111"
axum = "0.7.3"

View File

@ -6,5 +6,5 @@ RUN cargo build --release
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/target/release/ponguito-serv .
CMD ["./ponguito-serv"]
COPY --from=builder /app/target/release/ponguito-server .
CMD ["./ponguito-server"]

View File

@ -1 +0,0 @@
[]

View File

@ -1,10 +0,0 @@
import requests as rq
IP = "127.0.0.1"
post = {"name": "coco_sol", "score": 10}
rq.post(f"http://{IP}/new_score", post)
rq.post(f"http://{IP}/new_score", post)
print(rq.get(f"http://{IP}/data").json())

18
rustfmt.toml Normal file
View File

@ -0,0 +1,18 @@
use_try_shorthand = true
use_field_init_shorthand = true
version = "Two"
error_on_line_overflow = true
error_on_unformatted = true
format_code_in_doc_comments = true
format_macro_bodies = true
format_macro_matchers = true
format_strings = true
imports_granularity = "Module"
group_imports = "StdExternalCrate"
normalize_doc_attributes = true
normalize_comments = true
wrap_comments = true

View File

@ -1,80 +1,202 @@
use chrono::Utc;
use rocket::form::Form;
use rocket::serde::json::Json;
use rocket::{get, launch, Config, FromForm};
use rocket::{post, routes};
use serde::Deserialize;
use serde::Serialize;
use std::fs::File;
use std::io::{Read, Write};
//! Un simple serveur http pour enregistrer des scores du jeu Ponguito.
use std::net::IpAddr;
#[derive(Deserialize, Serialize, FromForm, Clone, Debug)]
struct NewScore {
use axum::extract::{Path, Query};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Json, Router};
use axum_client_ip::LeftmostXForwardedFor;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::fs::{File, OpenOptions};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
/// Structure du score utilisé pour le stockage.
#[derive(Serialize, Deserialize)]
struct Score {
/// L'adresse IP du joueur.
ip: IpAddr,
/// La date et l'heure de l'enregistrement du score.
time: DateTime<Utc>,
/// Le nom du joueur.
name: String,
/// Le score du joueur.
score: u32,
}
#[post("/new_score", data = "<score>")]
fn new_score(score: Form<NewScore>) -> Option<()> {
let name = score.name.clone();
let score = score.score.clone();
/// Structure du score envoyé aux clients.
#[derive(Serialize, Clone)]
struct ScoreResponse {
/// La date et l'heure de l'enregistrement du score.
time: DateTime<Utc>,
save_data(name, score, Utc::now().to_string());
/// Le nom du joueur.
name: String,
Some(())
/// Le score du joueur.
score: u32,
}
#[get("/all-data")]
fn all_data() -> Json<Vec<(u32, String, String)>> {
let best_scores: Vec<_> = load_data().into_iter().map(|(k, v, d)| (v, k, d)).collect();
/// Façons de trie des scores.
#[derive(Deserialize, Default)]
enum SortedBy {
/// Tri par date et heure.
///
/// Le score le plus récent sera le premier.
#[serde(rename = "time")]
Time,
Json(best_scores)
/// Tri par score.
///
/// Le score le plus haut sera le premier.
#[serde(rename = "score")]
#[default]
Score,
}
#[get("/data")]
fn data() -> Json<Vec<(u32, String, String)>> {
let mut best_scores: Vec<_> = load_data().into_iter().map(|(k, v, d)| (v, k, d)).collect();
/// Paramètres de la requête de récupération des scores.
#[derive(Deserialize)]
struct ScoresParams {
/// Nom du joueur (optionnel).
///
/// Si `None` récupère les scores de tous les joueurs.
#[serde(default)]
player: Option<String>,
let nb = 5.min(best_scores.len());
/// Comment sont trieés les scores.
#[serde(default)]
sorted_by: SortedBy,
best_scores.sort_by(|a, b| b.0.cmp(&a.0)); // sort by score();
best_scores.truncate(nb);
Json(best_scores)
/// Le nombre de scores à renvoyer.
#[serde(default)]
count: Option<u8>,
}
fn save_data(name: String, score: u32, date: String) {
let mut data = load_data();
data.push((name, score, date));
let json = serde_json::to_string(&data).unwrap();
let mut file = File::create("data.json").unwrap();
file.write_all(json.as_bytes()).unwrap();
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/scores", get(get_scores))
.route("/register/:name/:score", get(register_score));
let listener = tokio::net::TcpListener::bind("0.0.0.0:80")
.await
.expect("failed to bind to 0.0.0.0:80");
axum::serve(listener, app)
.await
.expect("failed to start server");
}
fn load_data() -> Vec<(String, u32, String)> {
let mut file = File::open("data.json").unwrap();
let mut contents = String::new();
file.read_to_string(&mut contents).unwrap();
serde_json::from_str(&contents).unwrap()
/// Récupération des meilleurs scores pour chaque joueur.
async fn get_scores(Query(params): Query<ScoresParams>) -> impl IntoResponse {
// Lecture de chaque ligne du fichier de logs des scores
let mut lines = BufReader::new(
File::open("scores.log")
.await
.expect("failed to load scores file"),
)
.lines();
// Récupération des paramètres
let count = params.count.unwrap_or(5);
let sorted_by = params.sorted_by;
let player = params.player;
// Création de la liste des scores contenant des scores vides par defaut
let mut scores = vec![
ScoreResponse {
time: Utc::now(),
name: String::new(),
score: 0
};
count as usize
];
// Remplissage de la liste des scores
while let Ok(Some(line)) = lines.next_line().await {
let score: Score = serde_json::from_str(&line).expect("failed to deserialize score");
// Filtrage des scores si un nom de joueur est spécifié
if let Some(name) = &player {
if score.name != *name {
continue;
}
}
// Création du score envoyé aux clients
let response = ScoreResponse {
time: score.time,
name: score.name,
score: score.score,
};
// Ajout du score à la liste en fonction du tri
match sorted_by {
SortedBy::Time => {
for i in 0..count as usize {
if score.time > scores[i].time {
*scores.get_mut(i).expect("failed to get score (impossible)") = response;
break;
}
}
}
SortedBy::Score => {
for i in 0..count as usize {
if score.score > scores[i].score {
*scores.get_mut(i).expect("failed to get score (impossible)") = response;
break;
}
}
}
}
}
// Renvoi des scores
Json(scores)
}
// The main function of the website.
#[launch]
async fn rocket() -> _ {
// generate a secret key and figment the rocket server
let figment = Config::figment()
.merge(("port", 80))
.merge(("worker_count", 4))
.merge(("log_level", rocket::config::LogLevel::Critical))
.merge(("address", IpAddr::from([0, 0, 0, 0])));
/// Enregistrement d'un nouveau score.
async fn register_score(
LeftmostXForwardedFor(ip): LeftmostXForwardedFor,
Path((name, score)): Path<(String, u32)>,
) -> impl IntoResponse {
// Vérification des informations
let name = name.trim().to_lowercase();
if name.is_empty() || score == 0 {
return StatusCode::BAD_REQUEST;
}
// create a config
let config = Config::from(figment);
// Vérification du nom du joueur
let authorized_characters = "abcdefghijklmnopqrstuvwxyz0123456789_-";
if name.chars().any(|c| !authorized_characters.contains(c)) {
return StatusCode::BAD_REQUEST;
}
// launch the server with different routes
rocket::build()
// apply the config
.configure(config)
.mount("/", routes![new_score, data, all_data])
// Création du fichier de logs des scores si il n'existe pas
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open("scores.log")
.await
.expect("failed to load scores file");
// Enregistrement du score
let score = Score {
ip,
time: Utc::now(),
name,
score,
};
let mut data = serde_json::to_vec(&score).expect("failed to serialize score");
data.push(b'\n');
file.write_all(&data)
.await
.expect("failed to write to scores file");
// Renvoi du statut
StatusCode::CREATED
}