mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-17 04:06:44 +02:00
feat(rust) : serveur réécrit en Rust, Node retiré ; Calendrier en tête du menu (#571)
- Serveur Rust (server-rs/, axum + sqlx) : les 148 adresses de l'API, l'interface, les migrations de démarrage et les tâches de fond ; même comportement que Node (banc : 2027 cas, 0 écart hors écarts déclarés USERS-02, RAPPRO-21, R4, R5). - Node retiré : server/, dépendances serveur, image Docker sans Node (51 Mo), docker-compose à un seul service applicatif. - R4 : écritures d'un autre site refusées (Sec-Fetch-Site / Origin). - R5 : « aujourd'hui » et sauvegarde automatique à l'heure de Paris. - R6 : mainteneur dans .github/CODEOWNERS. - Menu latéral : Accueil puis Calendrier en tête. - Plan et état : docs/PLAN-MIGRATION-RUST.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ke2qyS8rgacP3ap5iezDbM
This commit is contained in:
1 parent
6760f43e87
commit
600627c632
146 files changed
+31521
-20331
No files matched your search
@@ -0,0 +1,264 @@
|
||||
// Magasins : /api/groups (server/routes.ts, « Groups routes »).
|
||||
//
|
||||
// Le mot de passe SMTP ne sort jamais : comme stripSmtpPassword
|
||||
// (server/sanitize.ts, appliqué par Node à toute réponse JSON de /api), il est
|
||||
// remplacé par smtpPasswordSet, à la même place. Il est chiffré à l'écriture
|
||||
// (crate::crypto, comme storage.createGroup / updateGroup).
|
||||
//
|
||||
// POST /api/groups/:id/test-smtp vérifie la configuration SMTP du magasin
|
||||
// (connexion et authentification, sans envoi) : services::mail.
|
||||
//
|
||||
// Comme Node aujourd'hui : un manager peut créer, modifier et supprimer
|
||||
// n'importe quel magasin, pas seulement le sien.
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Json, Router, routing::get, routing::post, routing::put};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::crypto;
|
||||
use crate::db::{self, Table};
|
||||
use crate::error::{ApiResult, AppError, OrInternal, empty_json};
|
||||
use crate::request::{JsonBody, int_id};
|
||||
use crate::schema::GroupsRow;
|
||||
use crate::zod::{Field, Object, Parsed};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/groups", get(list).post(create))
|
||||
.route("/api/groups/{id}", put(update).delete(remove))
|
||||
.route("/api/groups/{id}/test-smtp", post(test_smtp))
|
||||
}
|
||||
|
||||
/// insertGroupSchema (shared/schema.ts) : le formulaire envoie "" pour les
|
||||
/// champs vides, d'où les champs texte élargis
|
||||
fn insert_schema() -> Object {
|
||||
GroupsRow::insert_schema()
|
||||
.omit(&["id", "createdAt", "updatedAt"])
|
||||
.extend(vec![
|
||||
Field::string("address").optional().nullable(),
|
||||
Field::string("phone").optional().nullable(),
|
||||
Field::string("logo").optional().nullable(),
|
||||
Field::string("smtpHost").optional().nullable(),
|
||||
Field::coerce_number("smtpPort")
|
||||
.int()
|
||||
.min(1.0)
|
||||
.max(65535.0)
|
||||
.optional()
|
||||
.nullable(),
|
||||
Field::string("smtpUser").optional().nullable(),
|
||||
Field::string("smtpPassword").optional().nullable(),
|
||||
Field::union(
|
||||
"smtpSenderEmail",
|
||||
vec![Field::string("").email(), Field::literal("", json!(""))],
|
||||
)
|
||||
.optional()
|
||||
.nullable(),
|
||||
Field::string("smtpSenderName").optional().nullable(),
|
||||
])
|
||||
}
|
||||
|
||||
/// stripSmtpPassword : smtpPassword (ou smtp_password) remplacé par
|
||||
/// smtpPasswordSet, à la même place, dans tout l'objet (8 niveaux au plus)
|
||||
pub fn strip_smtp_password(value: Value) -> Value {
|
||||
fn strip(value: Value, depth: usize) -> Value {
|
||||
if depth > 8 {
|
||||
return value;
|
||||
}
|
||||
match value {
|
||||
Value::Array(items) => {
|
||||
Value::Array(items.into_iter().map(|item| strip(item, depth + 1)).collect())
|
||||
}
|
||||
Value::Object(object) => {
|
||||
let mut out = Map::with_capacity(object.len());
|
||||
for (key, entry) in object {
|
||||
if key == "smtpPassword" || key == "smtp_password" {
|
||||
out.insert("smtpPasswordSet".into(), Value::Bool(js_truthy(&entry)));
|
||||
} else {
|
||||
out.insert(key, strip(entry, depth + 1));
|
||||
}
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
strip(value, 0)
|
||||
}
|
||||
|
||||
/// Boolean(valeur) de JavaScript
|
||||
fn js_truthy(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Null => false,
|
||||
Value::Bool(flag) => *flag,
|
||||
Value::Number(number) => number.as_f64().is_some_and(|n| n != 0.0),
|
||||
Value::String(text) => !text.is_empty(),
|
||||
Value::Array(_) | Value::Object(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Réponse JSON d'un magasin complet, sans son mot de passe SMTP
|
||||
fn group_json<T: serde::Serialize>(group: T) -> Response {
|
||||
match serde_json::to_value(group) {
|
||||
Ok(value) => Json(strip_smtp_password(value)).into_response(),
|
||||
Err(_) => AppError::internal("Internal Server Error").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// encryptSecret sur le mot de passe SMTP fourni (vide ou déjà chiffré : inchangé)
|
||||
fn encrypt_smtp_password(data: &mut Parsed, secret: &str) {
|
||||
if let Some(Value::String(password)) = data.get("smtpPassword") {
|
||||
let sealed = crypto::encrypt(password, secret);
|
||||
data.insert("smtpPassword".into(), Value::String(sealed));
|
||||
}
|
||||
}
|
||||
|
||||
fn can_write(user: &CurrentUser) -> Result<(), AppError> {
|
||||
if ["admin", "manager"].contains(&user.role()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::forbidden("Insufficient permissions"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin : tous les magasins (fiches complètes, triées par nom) ; les autres :
|
||||
/// leurs magasins, tels que chargés avec l'utilisateur
|
||||
async fn list(State(state): State<AppState>, user: CurrentUser) -> ApiResult {
|
||||
if user.is_admin() {
|
||||
let groups: Vec<GroupsRow> = sqlx::query_as(&GroupsRow::select("ORDER BY name"))
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.or_500("Failed to fetch groups")?;
|
||||
return Ok(group_json(groups));
|
||||
}
|
||||
let groups: Vec<_> = user
|
||||
.user_groups
|
||||
.iter()
|
||||
.map(|membership| &membership.group)
|
||||
.collect();
|
||||
Ok(Json(groups).into_response())
|
||||
}
|
||||
|
||||
async fn create(State(state): State<AppState>, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult {
|
||||
can_write(&user)?;
|
||||
let mut data = insert_schema()
|
||||
.parse(&body)
|
||||
.map_err(|issues| AppError::validation("Validation failed", issues))?;
|
||||
encrypt_smtp_password(&mut data, &state.config.encryption_key);
|
||||
let group: GroupsRow = db::insert(&state.db, &data)
|
||||
.await
|
||||
.or_500("Failed to create group")?;
|
||||
Ok(group_json(group))
|
||||
}
|
||||
|
||||
async fn update(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(id): Path<String>,
|
||||
JsonBody(body): JsonBody,
|
||||
) -> ApiResult {
|
||||
const FAILED: &str = "Failed to update group";
|
||||
can_write(&user)?;
|
||||
let id = int_id(&id, FAILED)?;
|
||||
let mut data = insert_schema()
|
||||
.partial()
|
||||
.parse(&body)
|
||||
.map_err(|_| AppError::internal(FAILED))?;
|
||||
// Le mot de passe n'est jamais renvoyé au client : vide, il reste inchangé
|
||||
if !data.get("smtpPassword").is_some_and(js_truthy) {
|
||||
data.remove("smtpPassword");
|
||||
}
|
||||
encrypt_smtp_password(&mut data, &state.config.encryption_key);
|
||||
let group = db::update_by_id::<GroupsRow>(&state.db, id, &data, true)
|
||||
.await
|
||||
.or_500(FAILED)?;
|
||||
Ok(match group {
|
||||
Some(group) => group_json(group),
|
||||
None => empty_json(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn remove(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
|
||||
const FAILED: &str = "Failed to delete group";
|
||||
can_write(&user)?;
|
||||
let id = int_id(&id, FAILED)?;
|
||||
// Un magasin encore utilisé (commandes, livraisons…) fait échouer la suppression
|
||||
db::delete_by_id::<GroupsRow>(&state.db, id)
|
||||
.await
|
||||
.or_500(FAILED)?;
|
||||
Ok(Json(json!({ "message": "Group deleted successfully" })).into_response())
|
||||
}
|
||||
|
||||
/// Test de la configuration SMTP d'un magasin, sans envoi : { success, message },
|
||||
/// toute erreur (base comprise) en 400 avec son message
|
||||
async fn test_smtp(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
|
||||
can_write(&user)?;
|
||||
let failure = |message: &str| {
|
||||
let message = if message.is_empty() {
|
||||
"Impossible de joindre le serveur SMTP"
|
||||
} else {
|
||||
message
|
||||
};
|
||||
AppError::with_body(
|
||||
StatusCode::BAD_REQUEST,
|
||||
json!({ "success": false, "message": message }),
|
||||
)
|
||||
};
|
||||
// parseInt(id) envoyé en texte : un identifiant illisible fait échouer la requête
|
||||
let id = crate::js::parse_int(&id).map_or_else(|| "NaN".to_string(), crate::js::number_to_string);
|
||||
let group = crate::services::nocodb::group_by_text_id(&state.db, Some(&id))
|
||||
.await
|
||||
.map_err(|message| failure(&message))?
|
||||
.ok_or_else(|| AppError::not_found("Magasin introuvable"))?;
|
||||
let missing = crate::services::mail::missing_smtp_fields(&group);
|
||||
if !missing.is_empty() {
|
||||
return Err(failure(&format!(
|
||||
"Configuration incomplète : {}",
|
||||
missing.join(", ")
|
||||
)));
|
||||
}
|
||||
crate::services::mail::verify_smtp_config(&group, &state.config.encryption_key)
|
||||
.await
|
||||
.map_err(|message| failure(&message))?;
|
||||
Ok(Json(json!({ "success": true, "message": "Connexion au serveur SMTP réussie" })).into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mot_de_passe_smtp_remplace_a_sa_place() {
|
||||
let value = json!([{ "id": 1, "smtpUser": "u", "smtpPassword": "enc:v1:x", "smtpSenderName": "n" },
|
||||
{ "group": { "smtp_password": "" } }]);
|
||||
assert_eq!(
|
||||
strip_smtp_password(value).to_string(),
|
||||
r#"[{"id":1,"smtpUser":"u","smtpPasswordSet":true,"smtpSenderName":"n"},{"group":{"smtpPasswordSet":false}}]"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn courriel_d_expediteur_vide_ou_valide() {
|
||||
let schema = insert_schema();
|
||||
assert!(
|
||||
schema
|
||||
.parse(&json!({ "name": "X", "smtpSenderEmail": "" }))
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
schema
|
||||
.parse(&json!({ "name": "X", "smtpSenderEmail": null, "smtpPort": "587" }))
|
||||
.is_ok_and(|data| data["smtpPort"] == json!(587))
|
||||
);
|
||||
let issues = schema
|
||||
.parse(&json!({ "name": "X", "smtpSenderEmail": "abc", "smtpPort": "0" }))
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
serde_json::to_string(&issues).unwrap(),
|
||||
r#"[{"code":"too_small","minimum":1,"type":"number","inclusive":true,"exact":false,"message":"Number must be greater than or equal to 1","path":["smtpPort"]},{"validation":"email","code":"invalid_string","message":"Invalid email","path":["smtpSenderEmail"]}]"#
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user