Utilisation d'axum pour la backend du serveur + Ajout d'actions #3
19
.gitea/workflows/rust.yml
Normal file
19
.gitea/workflows/rust.yml
Normal 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
1017
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
21
Cargo.toml
21
Cargo.toml
|
@ -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"
|
||||
|
|
|
@ -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"]
|
10
exemple.py
10
exemple.py
|
@ -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
18
rustfmt.toml
Normal 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
|
234
src/main.rs
234
src/main.rs
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue