Serveur Rust : formatage et corrections clippy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
This commit is contained in:
Claude committed 2026-10-12 17:24:14 +00:00
1 parent 73e4a4c822
commit 9d14b2a5f0
17 files changed
+690 -207

No files matched your search

+59 -19
View File
@@ -57,7 +57,10 @@ impl Session {
}
pub fn get_str(&self, key: &str) -> Option<String> {
self.data.get(key).and_then(Value::as_str).map(str::to_string)
self.data
.get(key)
.and_then(Value::as_str)
.map(str::to_string)
}
pub fn set(&mut self, key: &str, value: Value) {
@@ -86,7 +89,8 @@ impl Session {
}
fn sign(secret: &str, value: &str) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepte toute clé");
let mut mac =
Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepte toute clé");
mac.update(value.as_bytes());
let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
format!("{value}.{}", sig.trim_end_matches('='))
@@ -142,9 +146,15 @@ fn cookie_meta(secure: bool, expires: Ts) -> Value {
}
/// En-tête `Set-Cookie` de la session.
pub fn session_cookie(state: &AppState, headers: &HeaderMap, sid: &str, expires: Ts) -> HeaderValue {
pub fn session_cookie(
state: &AppState,
headers: &HeaderMap,
sid: &str,
expires: Ts,
) -> HeaderValue {
let signed = sign(&state.config.session_secret, sid);
let encoded = percent_encoding::utf8_percent_encode(&format!("s:{signed}"), COOKIE_ENCODE).to_string();
let encoded =
percent_encoding::utf8_percent_encode(&format!("s:{signed}"), COOKIE_ENCODE).to_string();
let mut cookie = format!(
"{COOKIE_NAME}={encoded}; Path=/; Expires={}; HttpOnly; SameSite=Lax",
expires.utc().format("%a, %d %b %Y %H:%M:%S GMT")
@@ -157,19 +167,22 @@ pub fn session_cookie(state: &AppState, headers: &HeaderMap, sid: &str, expires:
/// En-tête `Set-Cookie` qui efface la session côté navigateur.
pub fn clear_session_cookie() -> HeaderValue {
HeaderValue::from_static("connect.sid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax")
HeaderValue::from_static(
"connect.sid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax",
)
}
/// Charge la session de la requête (cookie valide et session non expirée).
pub async fn load_session(state: &AppState, headers: &HeaderMap) -> Option<Session> {
let cookie = session_cookie_value(headers)?;
let sid = unsign(&state.config.session_secret, &cookie)?;
let row: Option<(Value,)> = sqlx::query_as("SELECT sess FROM session WHERE sid = $1 AND expire > $2")
.bind(&sid)
.bind(Ts::now())
.fetch_optional(&state.db.pool)
.await
.ok()?;
let row: Option<(Value,)> =
sqlx::query_as("SELECT sess FROM session WHERE sid = $1 AND expire > $2")
.bind(&sid)
.bind(Ts::now())
.fetch_optional(&state.db.pool)
.await
.ok()?;
row.map(|(data,)| Session { sid, data })
}
@@ -201,7 +214,10 @@ pub async fn start_session(
}
pub async fn destroy_session(state: &AppState, sid: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM session WHERE sid = $1").bind(sid).execute(&state.db.pool).await?;
sqlx::query("DELETE FROM session WHERE sid = $1")
.bind(sid)
.execute(&state.db.pool)
.await?;
Ok(())
}
@@ -252,7 +268,10 @@ impl Deref for AuthUser {
impl FromRequestParts<AppState> for AuthUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
match current_user(state, &parts.headers).await {
Some((user, session)) => Ok(AuthUser { user, session }),
None => Err(AppError::unauthorized("Non authentifié")),
@@ -277,10 +296,15 @@ impl Deref for AdminUser {
impl FromRequestParts<AppState> for AdminUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let AuthUser { user, session } = AuthUser::from_request_parts(parts, state).await?;
if user.role != UserRole::Admin {
return Err(AppError::forbidden("Accès refusé. Réservé aux administrateurs."));
return Err(AppError::forbidden(
"Accès refusé. Réservé aux administrateurs.",
));
}
Ok(AdminUser { user, session })
}
@@ -292,7 +316,10 @@ pub struct MaybeUser(pub Option<(User, Session)>);
impl FromRequestParts<AppState> for MaybeUser {
type Rejection = std::convert::Infallible;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
Ok(MaybeUser(current_user(state, &parts.headers).await))
}
}
@@ -333,7 +360,10 @@ pub async fn ensure_admin_user_exists(state: &AppState) -> anyhow::Result<()> {
}
tracing::info!("🔧 Création de l'utilisateur admin par défaut...");
let hash = hash_password("admin").await?;
state.db.create_user("admin", &hash, UserRole::Admin).await?;
state
.db
.create_user("admin", &hash, UserRole::Admin)
.await?;
tracing::info!("✅ Utilisateur admin créé avec succès (admin / admin)");
tracing::warn!("⚠️ IMPORTANT: Changez ce mot de passe immédiatement !");
Ok(())
@@ -346,8 +376,18 @@ mod tests {
#[test]
fn signature_matches_cookie_signature_package() {
// Valeur produite par `cookie-signature` (Node) : sign('abc', 'secret')
assert_eq!(sign("secret", "abc"), "abc.mUba1OAOkT/Ivo5dP34RCkqegy+D+wnDRShdeGONig4");
assert_eq!(unsign("secret", "s:abc.mUba1OAOkT/Ivo5dP34RCkqegy+D+wnDRShdeGONig4").as_deref(), Some("abc"));
assert_eq!(
sign("secret", "abc"),
"abc.mUba1OAOkT/Ivo5dP34RCkqegy+D+wnDRShdeGONig4"
);
assert_eq!(
unsign(
"secret",
"s:abc.mUba1OAOkT/Ivo5dP34RCkqegy+D+wnDRShdeGONig4"
)
.as_deref(),
Some("abc")
);
assert!(unsign("secret", "s:abc.invalid").is_none());
assert!(unsign("other", "s:abc.mUba1OAOkT/Ivo5dP34RCkqegy+D+wnDRShdeGONig4").is_none());
}
+20 -3
View File
@@ -88,11 +88,22 @@ pub fn encrypt(text: &str) -> String {
let mut iv = [0u8; 16];
rand::thread_rng().fill_bytes(&mut iv);
let sealed = cipher
.encrypt(Nonce::<U16>::from_slice(&iv), Payload { msg: text.as_bytes(), aad: &[] })
.encrypt(
Nonce::<U16>::from_slice(&iv),
Payload {
msg: text.as_bytes(),
aad: &[],
},
)
.expect("chiffrement AES-GCM");
// `sealed` = chiffré || tag (16 octets)
let (ciphertext, tag) = sealed.split_at(sealed.len() - 16);
format!("{}:{}:{}", hex::encode(iv), hex::encode(tag), hex::encode(ciphertext))
format!(
"{}:{}:{}",
hex::encode(iv),
hex::encode(tag),
hex::encode(ciphertext)
)
}
/// Déchiffre ; une valeur qui n'a pas le format attendu (jeton enregistré en
@@ -115,7 +126,13 @@ pub fn decrypt(value: &str) -> String {
data.extend_from_slice(&tag);
let cipher = Cipher::new_from_slice(key()).ok()?;
let plain = cipher
.decrypt(Nonce::<U16>::from_slice(&iv), Payload { msg: &data, aad: &[] })
.decrypt(
Nonce::<U16>::from_slice(&iv),
Payload {
msg: &data,
aad: &[],
},
)
.ok()?;
String::from_utf8(plain).ok()
};
+336 -111
View File
@@ -18,7 +18,8 @@ use crate::crypto::{decrypt, encrypt};
use crate::time::Ts;
/// Statuts de publication TikTok définitifs : inutile d'interroger l'API au-delà.
pub const TERMINAL_PUBLISH_STATUSES: [&str; 3] = ["PUBLISH_COMPLETE", "FAILED", "SEND_TO_USER_INBOX"];
pub const TERMINAL_PUBLISH_STATUSES: [&str; 3] =
["PUBLISH_COMPLETE", "FAILED", "SEND_TO_USER_INBOX"];
pub async fn connect(url: &str) -> Result<PgPool, sqlx::Error> {
let options: PgConnectOptions = url.parse()?;
@@ -193,13 +194,21 @@ impl Patch {
}
/// Exécute `UPDATE table SET … WHERE id = $id RETURNING *`.
async fn update_returning<T>(pool: &PgPool, table: &str, id: &str, patch: &Patch) -> Result<Option<T>, sqlx::Error>
async fn update_returning<T>(
pool: &PgPool,
table: &str,
id: &str,
patch: &Patch,
) -> Result<Option<T>, sqlx::Error>
where
T: for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> + Send + Unpin,
{
if patch.is_empty() {
let sql = format!("SELECT * FROM \"{table}\" WHERE id = $1");
return sqlx::query_as::<_, T>(&sql).bind(id).fetch_optional(pool).await;
return sqlx::query_as::<_, T>(&sql)
.bind(id)
.fetch_optional(pool)
.await;
}
let mut qb = QueryBuilder::<Postgres>::new(format!("UPDATE \"{table}\" SET "));
patch.push_into(&mut qb);
@@ -289,7 +298,12 @@ pub struct NewScheduledPost {
}
impl NewScheduledPost {
pub fn new(post_id: impl Into<String>, page_id: impl Into<String>, post_type: PostType, scheduled_at: Ts) -> Self {
pub fn new(
post_id: impl Into<String>,
page_id: impl Into<String>,
post_type: PostType,
scheduled_at: Ts,
) -> Self {
Self {
post_id: post_id.into(),
page_id: page_id.into(),
@@ -351,7 +365,10 @@ impl Db {
// ------------------------------------------------------------------
pub async fn get_user(&self, id: &str) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as("SELECT * FROM users WHERE id = $1").bind(id).fetch_optional(&self.pool).await
sqlx::query_as("SELECT * FROM users WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
}
pub async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, sqlx::Error> {
@@ -362,17 +379,26 @@ impl Db {
}
pub async fn get_all_users(&self) -> Result<Vec<User>, sqlx::Error> {
sqlx::query_as("SELECT * FROM users").fetch_all(&self.pool).await
sqlx::query_as("SELECT * FROM users")
.fetch_all(&self.pool)
.await
}
/// `password` doit déjà être haché.
pub async fn create_user(&self, username: &str, password_hash: &str, role: UserRole) -> Result<User, sqlx::Error> {
sqlx::query_as("INSERT INTO users (username, password, role) VALUES ($1, $2, $3) RETURNING *")
.bind(username)
.bind(password_hash)
.bind(role)
.fetch_one(&self.pool)
.await
pub async fn create_user(
&self,
username: &str,
password_hash: &str,
role: UserRole,
) -> Result<User, sqlx::Error> {
sqlx::query_as(
"INSERT INTO users (username, password, role) VALUES ($1, $2, $3) RETURNING *",
)
.bind(username)
.bind(password_hash)
.bind(role)
.fetch_one(&self.pool)
.await
}
pub async fn update_user(&self, id: &str, patch: Patch) -> Result<Option<User>, sqlx::Error> {
@@ -380,7 +406,10 @@ impl Db {
}
pub async fn delete_user(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM users WHERE id = $1").bind(id).execute(&self.pool).await?;
sqlx::query("DELETE FROM users WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
@@ -421,15 +450,18 @@ impl Db {
// ------------------------------------------------------------------
pub async fn get_social_pages(&self, user_id: &str) -> Result<Vec<SocialPage>, sqlx::Error> {
let pages: Vec<SocialPage> = sqlx::query_as("SELECT * FROM social_pages WHERE user_id = $1")
.bind(user_id)
.fetch_all(&self.pool)
.await?;
let pages: Vec<SocialPage> =
sqlx::query_as("SELECT * FROM social_pages WHERE user_id = $1")
.bind(user_id)
.fetch_all(&self.pool)
.await?;
Ok(pages.into_iter().map(decrypt_page).collect())
}
pub async fn get_all_social_pages(&self) -> Result<Vec<SocialPage>, sqlx::Error> {
let pages: Vec<SocialPage> = sqlx::query_as("SELECT * FROM social_pages").fetch_all(&self.pool).await?;
let pages: Vec<SocialPage> = sqlx::query_as("SELECT * FROM social_pages")
.fetch_all(&self.pool)
.await?;
Ok(pages.into_iter().map(decrypt_page).collect())
}
@@ -471,16 +503,36 @@ impl Db {
/// Mise à jour partielle ; `access_token` et `refresh_token` sont chiffrés
/// ici (une valeur vide n'est pas chiffrée, comme avant).
pub async fn update_social_page(&self, id: &str, patch: Patch) -> Result<Option<SocialPage>, sqlx::Error> {
pub async fn update_social_page(
&self,
id: &str,
patch: Patch,
) -> Result<Option<SocialPage>, sqlx::Error> {
let patch = patch
.map_text("access_token", |t| if t.is_empty() { t.to_string() } else { encrypt(t) })
.map_text("refresh_token", |t| if t.is_empty() { t.to_string() } else { encrypt(t) });
let updated: Option<SocialPage> = update_returning(&self.pool, "social_pages", id, &patch).await?;
.map_text("access_token", |t| {
if t.is_empty() {
t.to_string()
} else {
encrypt(t)
}
})
.map_text("refresh_token", |t| {
if t.is_empty() {
t.to_string()
} else {
encrypt(t)
}
});
let updated: Option<SocialPage> =
update_returning(&self.pool, "social_pages", id, &patch).await?;
Ok(updated.map(decrypt_page))
}
pub async fn delete_social_page(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM social_pages WHERE id = $1").bind(id).execute(&self.pool).await?;
sqlx::query("DELETE FROM social_pages WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
@@ -488,7 +540,12 @@ impl Db {
// Médias
// ------------------------------------------------------------------
pub async fn get_media(&self, user_id: &str, limit: Option<i64>, offset: Option<i64>) -> Result<Vec<Media>, sqlx::Error> {
pub async fn get_media(
&self,
user_id: &str,
limit: Option<i64>,
offset: Option<i64>,
) -> Result<Vec<Media>, sqlx::Error> {
sqlx::query_as(
"SELECT * FROM media WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
)
@@ -503,7 +560,10 @@ impl Db {
if ids.is_empty() {
return Ok(Vec::new());
}
sqlx::query_as("SELECT * FROM media WHERE id = ANY($1)").bind(ids).fetch_all(&self.pool).await
sqlx::query_as("SELECT * FROM media WHERE id = ANY($1)")
.bind(ids)
.fetch_all(&self.pool)
.await
}
pub async fn get_media_count(&self, user_id: &str) -> Result<i64, sqlx::Error> {
@@ -514,7 +574,10 @@ impl Db {
}
pub async fn get_media_by_id(&self, id: &str) -> Result<Option<Media>, sqlx::Error> {
sqlx::query_as("SELECT * FROM media WHERE id = $1").bind(id).fetch_optional(&self.pool).await
sqlx::query_as("SELECT * FROM media WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
}
pub async fn create_media(&self, media: NewMedia) -> Result<Media, sqlx::Error> {
@@ -545,7 +608,10 @@ impl Db {
}
pub async fn delete_media(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM media WHERE id = $1").bind(id).execute(&self.pool).await?;
sqlx::query("DELETE FROM media WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
@@ -568,19 +634,32 @@ impl Db {
}
pub async fn get_post(&self, id: &str) -> Result<Option<Post>, sqlx::Error> {
sqlx::query_as("SELECT * FROM posts WHERE id = $1").bind(id).fetch_optional(&self.pool).await
sqlx::query_as("SELECT * FROM posts WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
}
/// Post et médias, dans l'ordre d'affichage.
pub async fn get_post_with_media(&self, id: &str) -> Result<Option<(Post, Vec<Media>)>, sqlx::Error> {
pub async fn get_post_with_media(
&self,
id: &str,
) -> Result<Option<(Post, Vec<Media>)>, sqlx::Error> {
let Some(post) = self.get_post(id).await? else {
return Ok(None);
};
let links = self.get_post_media(id).await?;
let ids: Vec<String> = links.iter().map(|l| l.media_id.clone()).collect();
let by_id: HashMap<String, Media> =
self.get_media_by_ids(&ids).await?.into_iter().map(|m| (m.id.clone(), m)).collect();
let media = links.iter().filter_map(|l| by_id.get(&l.media_id).cloned()).collect();
let by_id: HashMap<String, Media> = self
.get_media_by_ids(&ids)
.await?
.into_iter()
.map(|m| (m.id.clone(), m))
.collect();
let media = links
.iter()
.filter_map(|l| by_id.get(&l.media_id).cloned())
.collect();
Ok(Some((post, media)))
}
@@ -617,7 +696,9 @@ impl Db {
progress: i32,
error: Option<&str>,
) -> Result<Option<Post>, sqlx::Error> {
let mut patch = Patch::new().set("generation_status", status).set("generation_progress", progress);
let mut patch = Patch::new()
.set("generation_status", status)
.set("generation_progress", progress);
if let Some(error) = error {
patch = patch.set("generation_error", error);
}
@@ -625,31 +706,51 @@ impl Db {
}
pub async fn delete_post(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM posts WHERE id = $1").bind(id).execute(&self.pool).await?;
sqlx::query("DELETE FROM posts WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Remplace les médias d'un post (ordre = position dans la liste).
pub async fn update_post_media(&self, post_id: &str, media_ids: &[String]) -> Result<(), sqlx::Error> {
let items: Vec<(String, i32)> =
media_ids.iter().enumerate().map(|(i, id)| (id.clone(), i as i32)).collect();
pub async fn update_post_media(
&self,
post_id: &str,
media_ids: &[String],
) -> Result<(), sqlx::Error> {
let items: Vec<(String, i32)> = media_ids
.iter()
.enumerate()
.map(|(i, id)| (id.clone(), i as i32))
.collect();
self.set_post_media(post_id, &items, true).await
}
/// Lie des médias à un post avec un ordre explicite ; `replace` efface d'abord les liens existants.
pub async fn set_post_media(&self, post_id: &str, items: &[(String, i32)], replace: bool) -> Result<(), sqlx::Error> {
pub async fn set_post_media(
&self,
post_id: &str,
items: &[(String, i32)],
replace: bool,
) -> Result<(), sqlx::Error> {
let mut tx = self.pool.begin().await?;
if replace {
sqlx::query("DELETE FROM post_media WHERE post_id = $1").bind(post_id).execute(&mut *tx).await?;
}
for (media_id, order) in items {
sqlx::query("INSERT INTO post_media (post_id, media_id, display_order) VALUES ($1, $2, $3)")
sqlx::query("DELETE FROM post_media WHERE post_id = $1")
.bind(post_id)
.bind(media_id)
.bind(order)
.execute(&mut *tx)
.await?;
}
for (media_id, order) in items {
sqlx::query(
"INSERT INTO post_media (post_id, media_id, display_order) VALUES ($1, $2, $3)",
)
.bind(post_id)
.bind(media_id)
.bind(order)
.execute(&mut *tx)
.await?;
}
tx.commit().await
}
@@ -663,7 +764,13 @@ impl Db {
start: Option<Ts>,
end: Option<Ts>,
) -> Result<Vec<ScheduledPostWithDetails>, sqlx::Error> {
self.query_scheduled_posts("p.user_id = $1", ScopeBind::Text(user_id.to_string()), start, end).await
self.query_scheduled_posts(
"p.user_id = $1",
ScopeBind::Text(user_id.to_string()),
start,
end,
)
.await
}
pub async fn get_scheduled_posts_by_pages(
@@ -675,7 +782,13 @@ impl Db {
if page_ids.is_empty() {
return Ok(Vec::new());
}
self.query_scheduled_posts("sp.page_id = ANY($1)", ScopeBind::List(page_ids.to_vec()), start, end).await
self.query_scheduled_posts(
"sp.page_id = ANY($1)",
ScopeBind::List(page_ids.to_vec()),
start,
end,
)
.await
}
async fn query_scheduled_posts(
@@ -733,23 +846,36 @@ impl Db {
}),
None => None,
};
out.push(ScheduledPostWithDetails { scheduled, post, page });
out.push(ScheduledPostWithDetails {
scheduled,
post,
page,
});
}
Ok(out)
}
pub async fn get_scheduled_post(&self, id: &str) -> Result<Option<ScheduledPost>, sqlx::Error> {
sqlx::query_as("SELECT * FROM scheduled_posts WHERE id = $1").bind(id).fetch_optional(&self.pool).await
sqlx::query_as("SELECT * FROM scheduled_posts WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
}
pub async fn get_scheduled_posts_by_post(&self, post_id: &str) -> Result<Vec<ScheduledPost>, sqlx::Error> {
pub async fn get_scheduled_posts_by_post(
&self,
post_id: &str,
) -> Result<Vec<ScheduledPost>, sqlx::Error> {
sqlx::query_as("SELECT * FROM scheduled_posts WHERE post_id = $1")
.bind(post_id)
.fetch_all(&self.pool)
.await
}
pub async fn create_scheduled_post(&self, sp: NewScheduledPost) -> Result<ScheduledPost, sqlx::Error> {
pub async fn create_scheduled_post(
&self,
sp: NewScheduledPost,
) -> Result<ScheduledPost, sqlx::Error> {
sqlx::query_as(
r#"INSERT INTO scheduled_posts (post_id, page_id, post_type, scheduled_at, publish_id, publish_status)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *"#,
@@ -764,25 +890,36 @@ impl Db {
.await
}
pub async fn update_scheduled_post(&self, id: &str, patch: Patch) -> Result<Option<ScheduledPost>, sqlx::Error> {
pub async fn update_scheduled_post(
&self,
id: &str,
patch: Patch,
) -> Result<Option<ScheduledPost>, sqlx::Error> {
update_returning(&self.pool, "scheduled_posts", id, &patch).await
}
pub async fn delete_scheduled_post(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM scheduled_posts WHERE id = $1").bind(id).execute(&self.pool).await?;
sqlx::query("DELETE FROM scheduled_posts WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Publications dues et pas encore traitées.
pub async fn get_pending_scheduled_posts(&self) -> Result<Vec<ScheduledPost>, sqlx::Error> {
sqlx::query_as("SELECT * FROM scheduled_posts WHERE published_at IS NULL AND scheduled_at <= $1")
.bind(Ts::now())
.fetch_all(&self.pool)
.await
sqlx::query_as(
"SELECT * FROM scheduled_posts WHERE published_at IS NULL AND scheduled_at <= $1",
)
.bind(Ts::now())
.fetch_all(&self.pool)
.await
}
/// Envois TikTok dont le statut final n'est pas encore connu.
pub async fn get_scheduled_posts_awaiting_publish_status(&self) -> Result<Vec<ScheduledPost>, sqlx::Error> {
pub async fn get_scheduled_posts_awaiting_publish_status(
&self,
) -> Result<Vec<ScheduledPost>, sqlx::Error> {
// `NOT IN` ignore les statuts NULL, comme `notInArray` de Drizzle
sqlx::query_as(
"SELECT * FROM scheduled_posts WHERE publish_id IS NOT NULL AND publish_status NOT IN ($1, $2, $3)",
@@ -817,15 +954,20 @@ impl Db {
.bind(&ids)
.fetch_all(&self.pool)
.await?;
let step_by_post: HashMap<String, Option<String>> =
steps.into_iter().filter_map(|(post_id, step)| post_id.map(|p| (p, step))).collect();
let step_by_post: HashMap<String, Option<String>> = steps
.into_iter()
.filter_map(|(post_id, step)| post_id.map(|p| (p, step)))
.collect();
Ok(posts
.into_iter()
.map(|post| {
let step = step_by_post.get(&post.id).cloned().flatten();
let mut value = serde_json::to_value(&post).unwrap_or(Value::Null);
if let Value::Object(map) = &mut value {
map.insert("generationStep".into(), step.map(Value::String).unwrap_or(Value::Null));
map.insert(
"generationStep".into(),
step.map(Value::String).unwrap_or(Value::Null),
);
}
value
})
@@ -847,7 +989,10 @@ impl Db {
// Générations IA
// ------------------------------------------------------------------
pub async fn get_ai_generations(&self, user_id: &str) -> Result<Vec<AiGeneration>, sqlx::Error> {
pub async fn get_ai_generations(
&self,
user_id: &str,
) -> Result<Vec<AiGeneration>, sqlx::Error> {
sqlx::query_as("SELECT * FROM ai_generations WHERE user_id = $1 ORDER BY created_at DESC")
.bind(user_id)
.fetch_all(&self.pool)
@@ -875,7 +1020,9 @@ impl Db {
// ------------------------------------------------------------------
pub async fn get_cloudinary_config(&self) -> Result<Option<CloudinaryConfig>, sqlx::Error> {
sqlx::query_as("SELECT * FROM cloudinary_config LIMIT 1").fetch_optional(&self.pool).await
sqlx::query_as("SELECT * FROM cloudinary_config LIMIT 1")
.fetch_optional(&self.pool)
.await
}
pub async fn create_cloudinary_config(
@@ -908,9 +1055,14 @@ impl Db {
.await?
.ok_or_else(|| anyhow::anyhow!("Cloudinary config not initialized"))?;
let patch = patch.set("updated_at", Ts::now());
let updated = update_returning::<CloudinaryConfig>(&self.pool, "cloudinary_config", &existing.id, &patch)
.await?
.ok_or_else(|| anyhow::anyhow!("Cloudinary config not initialized"))?;
let updated = update_returning::<CloudinaryConfig>(
&self.pool,
"cloudinary_config",
&existing.id,
&patch,
)
.await?
.ok_or_else(|| anyhow::anyhow!("Cloudinary config not initialized"))?;
Ok(updated)
}
@@ -918,7 +1070,10 @@ impl Db {
// OpenRouter
// ------------------------------------------------------------------
pub async fn get_openrouter_config(&self, user_id: &str) -> Result<Option<OpenrouterConfig>, sqlx::Error> {
pub async fn get_openrouter_config(
&self,
user_id: &str,
) -> Result<Option<OpenrouterConfig>, sqlx::Error> {
sqlx::query_as("SELECT * FROM openrouter_config WHERE user_id = $1")
.bind(user_id)
.fetch_optional(&self.pool)
@@ -926,9 +1081,11 @@ impl Db {
}
pub async fn get_any_openrouter_config(&self) -> Result<Option<OpenrouterConfig>, sqlx::Error> {
sqlx::query_as("SELECT * FROM openrouter_config ORDER BY updated_at DESC NULLS LAST LIMIT 1")
.fetch_optional(&self.pool)
.await
sqlx::query_as(
"SELECT * FROM openrouter_config ORDER BY updated_at DESC NULLS LAST LIMIT 1",
)
.fetch_optional(&self.pool)
.await
}
/// `model` / `system_prompt` absents : valeurs par défaut de la table.
@@ -954,7 +1111,11 @@ impl Db {
.await
}
pub async fn update_openrouter_config(&self, user_id: &str, patch: Patch) -> Result<Option<OpenrouterConfig>, sqlx::Error> {
pub async fn update_openrouter_config(
&self,
user_id: &str,
patch: Patch,
) -> Result<Option<OpenrouterConfig>, sqlx::Error> {
let patch = patch.set("updated_at", Ts::now());
let mut qb = QueryBuilder::<Postgres>::new("UPDATE openrouter_config SET ");
patch.push_into(&mut qb);
@@ -968,29 +1129,45 @@ impl Db {
// Permissions des utilisateurs sur les pages
// ------------------------------------------------------------------
pub async fn get_user_page_permissions(&self, user_id: &str) -> Result<Vec<UserPagePermission>, sqlx::Error> {
pub async fn get_user_page_permissions(
&self,
user_id: &str,
) -> Result<Vec<UserPagePermission>, sqlx::Error> {
sqlx::query_as("SELECT * FROM user_page_permissions WHERE user_id = $1")
.bind(user_id)
.fetch_all(&self.pool)
.await
}
pub async fn get_page_permissions(&self, page_id: &str) -> Result<Vec<UserPagePermission>, sqlx::Error> {
pub async fn get_page_permissions(
&self,
page_id: &str,
) -> Result<Vec<UserPagePermission>, sqlx::Error> {
sqlx::query_as("SELECT * FROM user_page_permissions WHERE page_id = $1")
.bind(page_id)
.fetch_all(&self.pool)
.await
}
pub async fn create_page_permission(&self, user_id: &str, page_id: &str) -> Result<UserPagePermission, sqlx::Error> {
sqlx::query_as("INSERT INTO user_page_permissions (user_id, page_id) VALUES ($1, $2) RETURNING *")
.bind(user_id)
.bind(page_id)
.fetch_one(&self.pool)
.await
pub async fn create_page_permission(
&self,
user_id: &str,
page_id: &str,
) -> Result<UserPagePermission, sqlx::Error> {
sqlx::query_as(
"INSERT INTO user_page_permissions (user_id, page_id) VALUES ($1, $2) RETURNING *",
)
.bind(user_id)
.bind(page_id)
.fetch_one(&self.pool)
.await
}
pub async fn delete_page_permission(&self, user_id: &str, page_id: &str) -> Result<(), sqlx::Error> {
pub async fn delete_page_permission(
&self,
user_id: &str,
page_id: &str,
) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM user_page_permissions WHERE user_id = $1 AND page_id = $2")
.bind(user_id)
.bind(page_id)
@@ -1008,7 +1185,10 @@ impl Db {
}
/// Pages attribuées à un utilisateur (jetons déchiffrés).
pub async fn get_user_accessible_pages(&self, user_id: &str) -> Result<Vec<SocialPage>, sqlx::Error> {
pub async fn get_user_accessible_pages(
&self,
user_id: &str,
) -> Result<Vec<SocialPage>, sqlx::Error> {
let pages: Vec<SocialPage> = sqlx::query_as(
r#"SELECT sp.* FROM user_page_permissions upp
INNER JOIN social_pages sp ON upp.page_id = sp.id
@@ -1033,14 +1213,20 @@ impl Db {
// Musique
// ------------------------------------------------------------------
pub async fn get_music_favorites(&self, user_id: &str) -> Result<Vec<MusicFavorite>, sqlx::Error> {
pub async fn get_music_favorites(
&self,
user_id: &str,
) -> Result<Vec<MusicFavorite>, sqlx::Error> {
sqlx::query_as("SELECT * FROM music_favorites WHERE user_id = $1 ORDER BY created_at DESC")
.bind(user_id)
.fetch_all(&self.pool)
.await
}
pub async fn add_music_favorite(&self, fav: NewMusicFavorite) -> Result<MusicFavorite, sqlx::Error> {
pub async fn add_music_favorite(
&self,
fav: NewMusicFavorite,
) -> Result<MusicFavorite, sqlx::Error> {
sqlx::query_as(
r#"INSERT INTO music_favorites (user_id, track_id, title, artist, album_name, duration,
preview_url, download_url, image_url, license)
@@ -1060,7 +1246,11 @@ impl Db {
.await
}
pub async fn remove_music_favorite(&self, user_id: &str, track_id: &str) -> Result<(), sqlx::Error> {
pub async fn remove_music_favorite(
&self,
user_id: &str,
track_id: &str,
) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM music_favorites WHERE user_id = $1 AND track_id = $2")
.bind(user_id)
.bind(track_id)
@@ -1069,13 +1259,18 @@ impl Db {
Ok(())
}
pub async fn is_music_favorite(&self, user_id: &str, track_id: &str) -> Result<bool, sqlx::Error> {
let found: Option<String> =
sqlx::query_scalar("SELECT id FROM music_favorites WHERE user_id = $1 AND track_id = $2 LIMIT 1")
.bind(user_id)
.bind(track_id)
.fetch_optional(&self.pool)
.await?;
pub async fn is_music_favorite(
&self,
user_id: &str,
track_id: &str,
) -> Result<bool, sqlx::Error> {
let found: Option<String> = sqlx::query_scalar(
"SELECT id FROM music_favorites WHERE user_id = $1 AND track_id = $2 LIMIT 1",
)
.bind(user_id)
.bind(track_id)
.fetch_optional(&self.pool)
.await?;
Ok(found.is_some())
}
@@ -1084,11 +1279,16 @@ impl Db {
// ------------------------------------------------------------------
pub async fn get_audio_tracks(&self) -> Result<Vec<AudioTrack>, sqlx::Error> {
sqlx::query_as("SELECT * FROM audio_tracks ORDER BY created_at DESC").fetch_all(&self.pool).await
sqlx::query_as("SELECT * FROM audio_tracks ORDER BY created_at DESC")
.fetch_all(&self.pool)
.await
}
pub async fn get_audio_track(&self, id: &str) -> Result<Option<AudioTrack>, sqlx::Error> {
sqlx::query_as("SELECT * FROM audio_tracks WHERE id = $1").bind(id).fetch_optional(&self.pool).await
sqlx::query_as("SELECT * FROM audio_tracks WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
}
pub async fn create_audio_track(
@@ -1112,7 +1312,10 @@ impl Db {
}
pub async fn delete_audio_track(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM audio_tracks WHERE id = $1").bind(id).execute(&self.pool).await?;
sqlx::query("DELETE FROM audio_tracks WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
@@ -1121,14 +1324,18 @@ impl Db {
// ------------------------------------------------------------------
pub async fn get_app_config(&self) -> Result<Option<AppConfig>, sqlx::Error> {
sqlx::query_as("SELECT * FROM app_config LIMIT 1").fetch_optional(&self.pool).await
sqlx::query_as("SELECT * FROM app_config LIMIT 1")
.fetch_optional(&self.pool)
.await
}
/// Colonnes possibles : external_api_key, gemini_api_key, qwen_tts_url, qwen_tts_api_key.
pub async fn upsert_app_config(&self, patch: Patch) -> Result<AppConfig, sqlx::Error> {
if let Some(existing) = self.get_app_config().await? {
let patch = patch.clone().set("updated_at", Ts::now());
if let Some(updated) = update_returning(&self.pool, "app_config", &existing.id, &patch).await? {
if let Some(updated) =
update_returning(&self.pool, "app_config", &existing.id, &patch).await?
{
return Ok(updated);
}
}
@@ -1147,7 +1354,9 @@ impl Db {
pub async fn get_facebook_config(&self) -> Result<Option<FacebookConfig>, sqlx::Error> {
let config: Option<FacebookConfig> =
sqlx::query_as("SELECT * FROM facebook_config LIMIT 1").fetch_optional(&self.pool).await?;
sqlx::query_as("SELECT * FROM facebook_config LIMIT 1")
.fetch_optional(&self.pool)
.await?;
Ok(config.map(|mut c| {
c.app_secret = decrypt(&c.app_secret);
c
@@ -1155,9 +1364,15 @@ impl Db {
}
/// Crée ou met à jour ; `app_secret` absent = secret inchangé.
pub async fn upsert_facebook_config(&self, app_id: Option<&str>, app_secret: Option<&str>) -> anyhow::Result<FacebookConfig> {
pub async fn upsert_facebook_config(
&self,
app_id: Option<&str>,
app_secret: Option<&str>,
) -> anyhow::Result<FacebookConfig> {
let existing: Option<FacebookConfig> =
sqlx::query_as("SELECT * FROM facebook_config LIMIT 1").fetch_optional(&self.pool).await?;
sqlx::query_as("SELECT * FROM facebook_config LIMIT 1")
.fetch_optional(&self.pool)
.await?;
let secret = app_secret.filter(|s| !s.is_empty()).map(encrypt);
let mut row: FacebookConfig = match existing {
Some(existing) => {
@@ -1174,14 +1389,17 @@ impl Db {
.ok_or_else(|| anyhow::anyhow!("configuration Facebook introuvable"))?
}
None => {
let (Some(app_id), Some(secret)) = (app_id.filter(|s| !s.is_empty()), secret) else {
let (Some(app_id), Some(secret)) = (app_id.filter(|s| !s.is_empty()), secret)
else {
anyhow::bail!("L'App ID et l'App Secret Facebook sont requis");
};
sqlx::query_as("INSERT INTO facebook_config (app_id, app_secret) VALUES ($1, $2) RETURNING *")
.bind(app_id)
.bind(secret)
.fetch_one(&self.pool)
.await?
sqlx::query_as(
"INSERT INTO facebook_config (app_id, app_secret) VALUES ($1, $2) RETURNING *",
)
.bind(app_id)
.bind(secret)
.fetch_one(&self.pool)
.await?
}
};
row.app_secret = decrypt(&row.app_secret);
@@ -1189,8 +1407,9 @@ impl Db {
}
pub async fn get_tiktok_config(&self) -> Result<Option<TiktokConfig>, sqlx::Error> {
let config: Option<TiktokConfig> =
sqlx::query_as("SELECT * FROM tiktok_config LIMIT 1").fetch_optional(&self.pool).await?;
let config: Option<TiktokConfig> = sqlx::query_as("SELECT * FROM tiktok_config LIMIT 1")
.fetch_optional(&self.pool)
.await?;
Ok(config.map(|mut c| {
c.client_secret = decrypt(&c.client_secret);
c
@@ -1198,9 +1417,14 @@ impl Db {
}
/// Crée ou met à jour ; `client_secret` absent = secret inchangé.
pub async fn upsert_tiktok_config(&self, client_key: Option<&str>, client_secret: Option<&str>) -> anyhow::Result<TiktokConfig> {
let existing: Option<TiktokConfig> =
sqlx::query_as("SELECT * FROM tiktok_config LIMIT 1").fetch_optional(&self.pool).await?;
pub async fn upsert_tiktok_config(
&self,
client_key: Option<&str>,
client_secret: Option<&str>,
) -> anyhow::Result<TiktokConfig> {
let existing: Option<TiktokConfig> = sqlx::query_as("SELECT * FROM tiktok_config LIMIT 1")
.fetch_optional(&self.pool)
.await?;
let secret = client_secret.filter(|s| !s.is_empty()).map(encrypt);
let mut row: TiktokConfig = match existing {
Some(existing) => {
@@ -1217,7 +1441,8 @@ impl Db {
.ok_or_else(|| anyhow::anyhow!("configuration TikTok introuvable"))?
}
None => {
let (Some(key), Some(secret)) = (client_key.filter(|s| !s.is_empty()), secret) else {
let (Some(key), Some(secret)) = (client_key.filter(|s| !s.is_empty()), secret)
else {
anyhow::bail!("Le client key et le client secret TikTok sont requis");
};
sqlx::query_as("INSERT INTO tiktok_config (client_key, client_secret) VALUES ($1, $2) RETURNING *")
+4 -1
View File
@@ -104,7 +104,10 @@ impl SocialPage {
let mut value = serde_json::to_value(self).unwrap_or(Value::Null);
let auto_renew = match self.platform {
Platform::Tiktok => self.refresh_token.as_deref().is_some_and(|t| !t.is_empty()),
_ => self.user_access_token.as_deref().is_some_and(|t| !t.is_empty()),
_ => self
.user_access_token
.as_deref()
.is_some_and(|t| !t.is_empty()),
};
if let Value::Object(map) = &mut value {
map.insert("autoRenew".into(), Value::Bool(auto_renew));
+4 -1
View File
@@ -16,7 +16,10 @@ pub type ApiResult<T = Response> = Result<T, AppError>;
impl AppError {
/// Réponse `{ error: message }` avec le statut donné.
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self { status, body: json!({ "error": message.into() }) }
Self {
status,
body: json!({ "error": message.into() }),
}
}
/// Corps JSON libre (ex. `{ success: false, error }`).
+36 -8
View File
@@ -30,7 +30,11 @@ pub struct RateDecision {
impl RateLimiter {
pub fn new(max: u32, window: Duration) -> Self {
Self { max, window, hits: Mutex::new(HashMap::new()) }
Self {
max,
window,
hits: Mutex::new(HashMap::new()),
}
}
pub fn check(&self, ip: IpAddr) -> RateDecision {
@@ -59,7 +63,7 @@ impl RateLimiter {
/// sinon l'adresse de la connexion.
pub fn client_ip(headers: &HeaderMap, peer: Option<SocketAddr>) -> IpAddr {
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
if let Some(ip) = xff.split(',').map(str::trim).filter(|s| !s.is_empty()).last() {
if let Some(ip) = xff.split(',').map(str::trim).rfind(|s| !s.is_empty()) {
if let Ok(ip) = ip.parse() {
return ip;
}
@@ -73,7 +77,10 @@ pub async fn rate_limit(State(state): State<AppState>, request: Request, next: N
if !path.starts_with("/api/") {
return next.run(request).await;
}
let peer = request.extensions().get::<ConnectInfo<SocketAddr>>().map(|c| c.0);
let peer = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.0);
let ip = client_ip(request.headers(), peer);
let decision = state.global_limiter.check(ip);
@@ -83,7 +90,10 @@ pub async fn rate_limit(State(state): State<AppState>, request: Request, next: N
if path == "/api/auth/login" {
let auth = state.auth_limiter.check(ip);
if !auth.allowed {
return too_many(&auth, "Trop de tentatives de connexion, réessayez dans 15 minutes");
return too_many(
&auth,
"Trop de tentatives de connexion, réessayez dans 15 minutes",
);
}
}
@@ -96,7 +106,11 @@ pub async fn rate_limit(State(state): State<AppState>, request: Request, next: N
}
fn too_many(decision: &RateDecision, message: &str) -> Response {
let mut response = (StatusCode::TOO_MANY_REQUESTS, axum::Json(json!({ "error": message }))).into_response();
let mut response = (
StatusCode::TOO_MANY_REQUESTS,
axum::Json(json!({ "error": message })),
)
.into_response();
let headers = response.headers_mut();
headers.insert("ratelimit-limit", HeaderValue::from(decision.limit));
headers.insert("ratelimit-remaining", HeaderValue::from(0u32));
@@ -132,10 +146,20 @@ pub async fn security_headers(request: Request, next: Next) -> Response {
set("content-security-policy", CSP);
set("cross-origin-opener-policy", "same-origin");
// Les fichiers téléversés sont lus par d'autres origines (rendu des Reels)
set("cross-origin-resource-policy", if is_upload { "cross-origin" } else { "same-origin" });
set(
"cross-origin-resource-policy",
if is_upload {
"cross-origin"
} else {
"same-origin"
},
);
set("origin-agent-cluster", "?1");
set("referrer-policy", "no-referrer");
set("strict-transport-security", "max-age=31536000; includeSubDomains");
set(
"strict-transport-security",
"max-age=31536000; includeSubDomains",
);
set("x-content-type-options", "nosniff");
set("x-dns-prefetch-control", "off");
set("x-download-options", "noopen");
@@ -157,7 +181,11 @@ pub async fn log_requests(request: Request, next: Next) -> Response {
let status = response.status();
// Les 401 de /api/auth/session sont la vérification normale de session
if !(path == "/api/auth/session" && status == StatusCode::UNAUTHORIZED) {
tracing::info!("{method} {path} {} in {}ms", status.as_u16(), started.elapsed().as_millis());
tracing::info!(
"{method} {path} {} in {}ms",
status.as_u16(),
started.elapsed().as_millis()
);
}
response
}
+73 -18
View File
@@ -255,23 +255,74 @@ const COLUMNS: &[&str] = &[
/// Index de performance (Postgres n'indexe pas les clés étrangères seul).
const INDEXES: &[(&str, &str)] = &[
("idx_posts_user_id", r#"CREATE INDEX IF NOT EXISTS "idx_posts_user_id" ON "posts" ("user_id")"#),
("idx_media_user_id", r#"CREATE INDEX IF NOT EXISTS "idx_media_user_id" ON "media" ("user_id")"#),
("idx_social_pages_user_id", r#"CREATE INDEX IF NOT EXISTS "idx_social_pages_user_id" ON "social_pages" ("user_id")"#),
("idx_ai_generations_user_id", r#"CREATE INDEX IF NOT EXISTS "idx_ai_generations_user_id" ON "ai_generations" ("user_id")"#),
("idx_scheduled_posts_post_id", r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_post_id" ON "scheduled_posts" ("post_id")"#),
("idx_post_media_post_id", r#"CREATE INDEX IF NOT EXISTS "idx_post_media_post_id" ON "post_media" ("post_id")"#),
("idx_post_media_media_id", r#"CREATE INDEX IF NOT EXISTS "idx_post_media_media_id" ON "post_media" ("media_id")"#),
("idx_post_analytics_post_id", r#"CREATE INDEX IF NOT EXISTS "idx_post_analytics_post_id" ON "post_analytics" ("post_id")"#),
("idx_page_analytics_history_page_date", r#"CREATE INDEX IF NOT EXISTS "idx_page_analytics_history_page_date" ON "page_analytics_history" ("page_id", "date")"#),
("idx_user_page_permissions_user_id", r#"CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_user_id" ON "user_page_permissions" ("user_id")"#),
("idx_user_page_permissions_page_id", r#"CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_page_id" ON "user_page_permissions" ("page_id")"#),
("idx_scheduled_posts_page_scheduled_at", r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_page_scheduled_at" ON "scheduled_posts" ("page_id", "scheduled_at")"#),
("idx_scheduled_posts_pending", r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_pending" ON "scheduled_posts" ("scheduled_at") WHERE "published_at" IS NULL"#),
("idx_scheduled_posts_publish_id", r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_publish_id" ON "scheduled_posts" ("publish_id") WHERE "publish_id" IS NOT NULL"#),
("idx_reel_jobs_queue", r#"CREATE INDEX IF NOT EXISTS "idx_reel_jobs_queue" ON "reel_jobs" ("created_at") WHERE "status" IN ('pending', 'processing')"#),
("idx_posts_generation_status", r#"CREATE INDEX IF NOT EXISTS "idx_posts_generation_status" ON "posts" ("generation_status") WHERE "generation_status" IS NOT NULL"#),
("IDX_session_expire", r#"CREATE INDEX IF NOT EXISTS "IDX_session_expire" ON "session" ("expire")"#),
(
"idx_posts_user_id",
r#"CREATE INDEX IF NOT EXISTS "idx_posts_user_id" ON "posts" ("user_id")"#,
),
(
"idx_media_user_id",
r#"CREATE INDEX IF NOT EXISTS "idx_media_user_id" ON "media" ("user_id")"#,
),
(
"idx_social_pages_user_id",
r#"CREATE INDEX IF NOT EXISTS "idx_social_pages_user_id" ON "social_pages" ("user_id")"#,
),
(
"idx_ai_generations_user_id",
r#"CREATE INDEX IF NOT EXISTS "idx_ai_generations_user_id" ON "ai_generations" ("user_id")"#,
),
(
"idx_scheduled_posts_post_id",
r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_post_id" ON "scheduled_posts" ("post_id")"#,
),
(
"idx_post_media_post_id",
r#"CREATE INDEX IF NOT EXISTS "idx_post_media_post_id" ON "post_media" ("post_id")"#,
),
(
"idx_post_media_media_id",
r#"CREATE INDEX IF NOT EXISTS "idx_post_media_media_id" ON "post_media" ("media_id")"#,
),
(
"idx_post_analytics_post_id",
r#"CREATE INDEX IF NOT EXISTS "idx_post_analytics_post_id" ON "post_analytics" ("post_id")"#,
),
(
"idx_page_analytics_history_page_date",
r#"CREATE INDEX IF NOT EXISTS "idx_page_analytics_history_page_date" ON "page_analytics_history" ("page_id", "date")"#,
),
(
"idx_user_page_permissions_user_id",
r#"CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_user_id" ON "user_page_permissions" ("user_id")"#,
),
(
"idx_user_page_permissions_page_id",
r#"CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_page_id" ON "user_page_permissions" ("page_id")"#,
),
(
"idx_scheduled_posts_page_scheduled_at",
r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_page_scheduled_at" ON "scheduled_posts" ("page_id", "scheduled_at")"#,
),
(
"idx_scheduled_posts_pending",
r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_pending" ON "scheduled_posts" ("scheduled_at") WHERE "published_at" IS NULL"#,
),
(
"idx_scheduled_posts_publish_id",
r#"CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_publish_id" ON "scheduled_posts" ("publish_id") WHERE "publish_id" IS NOT NULL"#,
),
(
"idx_reel_jobs_queue",
r#"CREATE INDEX IF NOT EXISTS "idx_reel_jobs_queue" ON "reel_jobs" ("created_at") WHERE "status" IN ('pending', 'processing')"#,
),
(
"idx_posts_generation_status",
r#"CREATE INDEX IF NOT EXISTS "idx_posts_generation_status" ON "posts" ("generation_status") WHERE "generation_status" IS NOT NULL"#,
),
(
"IDX_session_expire",
r#"CREATE INDEX IF NOT EXISTS "IDX_session_expire" ON "session" ("expire")"#,
),
];
pub async fn run(pool: &PgPool) {
@@ -281,7 +332,11 @@ pub async fn run(pool: &PgPool) {
for statement in ENUMS.iter().chain(TABLES).chain(COLUMNS) {
if let Err(error) = sqlx::raw_sql(statement).execute(pool).await {
failures += 1;
let head: String = statement.split_whitespace().take(6).collect::<Vec<_>>().join(" ");
let head: String = statement
.split_whitespace()
.take(6)
.collect::<Vec<_>>()
.join(" ");
tracing::warn!("[Migration] Échec de « {head} … » : {error}");
}
}
+4 -2
View File
@@ -25,9 +25,11 @@ pub fn resolve_public_base_url(state: &AppState, headers: Option<&HeaderMap>) ->
return url.clone();
}
if let Some(headers) = headers {
let host = first_header_value(headers, "x-forwarded-host").or_else(|| first_header_value(headers, "host"));
let host = first_header_value(headers, "x-forwarded-host")
.or_else(|| first_header_value(headers, "host"));
if let Some(host) = host {
let protocol = first_header_value(headers, "x-forwarded-proto").unwrap_or_else(|| "http".into());
let protocol =
first_header_value(headers, "x-forwarded-proto").unwrap_or_else(|| "http".into());
return format!("{protocol}://{host}");
}
}
+22 -6
View File
@@ -17,7 +17,10 @@ pub fn router() -> Router<AppState> {
.route("/api/auth/login", post(login))
.route("/api/auth/logout", post(logout))
.route("/api/auth/session", get(session))
.route("/api/auth/default-password-status", get(default_password_status))
.route(
"/api/auth/default-password-status",
get(default_password_status),
)
}
async fn login(
@@ -28,8 +31,14 @@ async fn login(
const BAD_CREDENTIALS: &str = "Nom d'utilisateur ou mot de passe incorrect";
// passport-local : champs absents ou vides → « Missing credentials »
let username = body.get("username").and_then(Value::as_str).filter(|s| !s.is_empty());
let password = body.get("password").and_then(Value::as_str).filter(|s| !s.is_empty());
let username = body
.get("username")
.and_then(Value::as_str)
.filter(|s| !s.is_empty());
let password = body
.get("password")
.and_then(Value::as_str)
.filter(|s| !s.is_empty());
let (Some(username), Some(password)) = (username, password) else {
return Err(AppError::unauthorized("Missing credentials"));
};
@@ -53,14 +62,19 @@ async fn login(
Ok(auth::with_cookie(auth::user_json(&user), cookie))
}
async fn logout(axum::extract::State(state): axum::extract::State<AppState>, headers: HeaderMap) -> ApiResult {
async fn logout(
axum::extract::State(state): axum::extract::State<AppState>,
headers: HeaderMap,
) -> ApiResult {
if let Some(session) = auth::load_session(&state, &headers).await {
auth::destroy_session(&state, &session.sid)
.await
.map_err(|_| AppError::internal("Erreur lors de la déconnexion"))?;
}
let mut response = Json(json!({ "message": "Déconnecté avec succès" })).into_response();
response.headers_mut().append(SET_COOKIE, auth::clear_session_cookie());
response
.headers_mut()
.append(SET_COOKIE, auth::clear_session_cookie());
Ok(response)
}
@@ -72,7 +86,9 @@ async fn session(MaybeUser(user): MaybeUser) -> Response {
}
/// Le mot de passe de `admin` est-il toujours « admin » ?
async fn default_password_status(axum::extract::State(state): axum::extract::State<AppState>) -> ApiResult<Json<Value>> {
async fn default_password_status(
axum::extract::State(state): axum::extract::State<AppState>,
) -> ApiResult<Json<Value>> {
let admin = state
.db
.get_user_by_username("admin")
+28 -7
View File
@@ -1,8 +1,8 @@
//! Assemblage des routes HTTP.
mod analytics;
mod auth;
mod core;
mod analytics;
mod external;
mod facebook;
mod legal;
@@ -35,7 +35,11 @@ pub fn router(state: AppState) -> Router {
.merge(legal::router());
let config = &state.config;
for dir in [&config.uploads_dir, &config.audio_upload_dir, &config.temp_upload_dir] {
for dir in [
&config.uploads_dir,
&config.audio_upload_dir,
&config.temp_upload_dir,
] {
let _ = std::fs::create_dir_all(dir);
}
for sub in ["media", "logos", "stories"] {
@@ -51,7 +55,10 @@ pub fn router(state: AppState) -> Router {
api.merge(uploads)
.fallback(fallback)
.layer(DefaultBodyLimit::max(MAX_JSON_BODY))
.layer(middleware::from_fn_with_state(state.clone(), http::rate_limit))
.layer(middleware::from_fn_with_state(
state.clone(),
http::rate_limit,
))
.layer(middleware::from_fn(http::log_requests))
.layer(middleware::from_fn(http::security_headers))
.with_state(state)
@@ -59,7 +66,10 @@ pub fn router(state: AppState) -> Router {
/// Sonde de santé (Docker) : le serveur répond et la base est joignable.
async fn healthz(State(state): State<AppState>) -> Response {
match sqlx::query_scalar::<_, i32>("SELECT 1").fetch_one(&state.db.pool).await {
match sqlx::query_scalar::<_, i32>("SELECT 1")
.fetch_one(&state.db.pool)
.await
{
Ok(_) => (StatusCode::OK, "ok").into_response(),
Err(_) => (StatusCode::SERVICE_UNAVAILABLE, "database unavailable").into_response(),
}
@@ -70,16 +80,27 @@ async fn fallback(State(state): State<AppState>, headers: HeaderMap, request: Re
let path = request.uri().path().to_string();
if path == "/api" || path.starts_with("/api/") {
return if crate::auth::current_user(&state, &headers).await.is_none() {
(StatusCode::UNAUTHORIZED, axum::Json(json!({ "error": "Non authentifié" }))).into_response()
(
StatusCode::UNAUTHORIZED,
axum::Json(json!({ "error": "Non authentifié" })),
)
.into_response()
} else {
(StatusCode::NOT_FOUND, axum::Json(json!({ "error": "Route introuvable" }))).into_response()
(
StatusCode::NOT_FOUND,
axum::Json(json!({ "error": "Route introuvable" })),
)
.into_response()
};
}
// Vérifications de domaine (TikTok, Google, Meta…), sans les fichiers cachés
let is_hidden = path.split('/').any(|part| part.starts_with('.'));
if !is_hidden && path != "/" {
let candidate = state.config.public_root_dir.join(path.trim_start_matches('/'));
let candidate = state
.config
.public_root_dir
.join(path.trim_start_matches('/'));
if candidate.is_file() {
return serve(ServeFile::new(candidate), request).await;
}
+8 -1
View File
@@ -57,7 +57,14 @@ pub async fn fix_audio_track_encoding(state: &AppState) -> usize {
/// Durée d'un fichier audio en secondes (arrondie), via ffprobe ; 0 si inconnue.
pub async fn probe_duration_secs(path: &Path) -> i32 {
let output = Command::new("ffprobe")
.args(["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1"])
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
])
.arg(path)
.output()
.await;
+4 -1
View File
@@ -39,6 +39,9 @@ pub async fn health_check(state: &AppState) -> bool {
}
/// Voix disponibles ; `qwen` remplace la cible Qwen configurée côté service.
pub async fn list_voices(state: &AppState, qwen: Option<QwenTarget>) -> anyhow::Result<VoiceCatalog> {
pub async fn list_voices(
state: &AppState,
qwen: Option<QwenTarget>,
) -> anyhow::Result<VoiceCatalog> {
anyhow::bail!("service FFmpeg non implémenté")
}
+44 -9
View File
@@ -17,7 +17,11 @@ pub fn resolve_public_url(state: &AppState, url: &str) -> String {
if url.is_empty() || !url.starts_with('/') {
return url.to_string();
}
let base = state.config.app_url.as_deref().unwrap_or("http://localhost:5555");
let base = state
.config
.app_url
.as_deref()
.unwrap_or("http://localhost:5555");
format!("{}{url}", base.trim_end_matches('/'))
}
@@ -40,8 +44,13 @@ pub fn resolve_internal_url(state: &AppState, url: &str) -> String {
pub fn local_path(state: &AppState, url: &str) -> Option<PathBuf> {
let rest = url.strip_prefix("/uploads/")?;
let rest = rest.split(['?', '#']).next().unwrap_or(rest);
let decoded = percent_encoding::percent_decode_str(rest).decode_utf8().ok()?;
if decoded.split('/').any(|part| part == ".." || part.is_empty()) {
let decoded = percent_encoding::percent_decode_str(rest)
.decode_utf8()
.ok()?;
if decoded
.split('/')
.any(|part| part == ".." || part.is_empty())
{
return None;
}
let (root, relative) = if let Some(r) = decoded.strip_prefix("audio/") {
@@ -78,7 +87,13 @@ fn safe_name(file_name: &str) -> String {
.and_then(|n| n.to_str())
.unwrap_or("file");
base.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '-' })
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'-'
}
})
.collect()
}
@@ -111,7 +126,11 @@ pub enum MediaSource<'a> {
Bytes(&'a [u8]),
}
pub async fn upload_media(state: &AppState, source: MediaSource<'_>, file_name: &str) -> anyhow::Result<StoredMedia> {
pub async fn upload_media(
state: &AppState,
source: MediaSource<'_>,
file_name: &str,
) -> anyhow::Result<StoredMedia> {
let object_key = format!("media/{}-{}", now_ms(), safe_name(file_name));
let target = state.config.uploads_dir.join(&object_key);
if let Some(parent) = target.parent() {
@@ -148,14 +167,26 @@ pub async fn delete_media(state: &AppState, public_id: &str) -> anyhow::Result<(
}
/// Image de story avec texte incrusté ; renvoie son URL.
pub async fn upload_story_image(state: &AppState, data: &[u8], original_file_name: &str) -> anyhow::Result<String> {
let object_key = format!("stories/story-{}-{}", now_ms(), safe_name(original_file_name));
pub async fn upload_story_image(
state: &AppState,
data: &[u8],
original_file_name: &str,
) -> anyhow::Result<String> {
let object_key = format!(
"stories/story-{}-{}",
now_ms(),
safe_name(original_file_name)
);
write(state, &object_key, data).await?;
Ok(build_url(&object_key))
}
/// Vignette vidéo (conservée après la suppression de la vidéo source).
pub async fn upload_thumbnail(state: &AppState, data: &[u8], file_name: &str) -> anyhow::Result<(String, String)> {
pub async fn upload_thumbnail(
state: &AppState,
data: &[u8],
file_name: &str,
) -> anyhow::Result<(String, String)> {
let object_key = format!("thumbnails/{}", safe_name(file_name));
write(state, &object_key, data).await?;
let url = build_url(&object_key);
@@ -163,7 +194,11 @@ pub async fn upload_thumbnail(state: &AppState, data: &[u8], file_name: &str) ->
}
/// Logo de l'entreprise ; renvoie (clé, URL).
pub async fn upload_logo(state: &AppState, data: &[u8], file_name: &str) -> anyhow::Result<(String, String)> {
pub async fn upload_logo(
state: &AppState,
data: &[u8],
file_name: &str,
) -> anyhow::Result<(String, String)> {
let ext = Path::new(file_name)
.extension()
.and_then(|e| e.to_str())
+14 -7
View File
@@ -39,7 +39,10 @@ pub async fn generate_video_thumbnail(video: &Path, output: &Path, seek_time: f6
/// Produit la vignette et la range dans le stockage public. Tolérante à
/// l'échec : renvoie `None` sans jamais faire échouer l'appelant.
pub async fn create_video_thumbnail(state: &AppState, source: ThumbnailSource<'_>) -> Option<String> {
pub async fn create_video_thumbnail(
state: &AppState,
source: ThumbnailSource<'_>,
) -> Option<String> {
let temp_dir = state.config.uploads_dir.join("temp");
if tokio::fs::create_dir_all(&temp_dir).await.is_err() {
return None;
@@ -72,13 +75,17 @@ pub async fn create_video_thumbnail(state: &AppState, source: ThumbnailSource<'_
let result = if ok {
match tokio::fs::read(&output).await {
Ok(data) => match media_store::upload_thumbnail(state, &data, &format!("thumb-{stamp}.jpg")).await {
Ok((_, url)) => Some(url),
Err(error) => {
tracing::warn!("⚠️ Vignette non enregistrée : {error}");
None
Ok(data) => {
match media_store::upload_thumbnail(state, &data, &format!("thumb-{stamp}.jpg"))
.await
{
Ok((_, url)) => Some(url),
Err(error) => {
tracing::warn!("⚠️ Vignette non enregistrée : {error}");
None
}
}
},
}
Err(_) => None,
}
} else {
+4 -1
View File
@@ -49,7 +49,10 @@ impl AppState {
.build()?;
let ffmpeg = FfmpegSettings {
api_url: config.ffmpeg_api_url.as_ref().map(|u| u.trim_end_matches('/').to_string()),
api_url: config
.ffmpeg_api_url
.as_ref()
.map(|u| u.trim_end_matches('/').to_string()),
api_key: config.ffmpeg_api_key.clone(),
};
if ffmpeg.api_url.is_none() || ffmpeg.api_key.is_none() {
+19 -10
View File
@@ -24,15 +24,15 @@ impl Ts {
Ts(dt.naive_utc())
}
pub fn utc(&self) -> DateTime<Utc> {
pub fn utc(self) -> DateTime<Utc> {
self.0.and_utc()
}
pub fn to_iso(&self) -> String {
pub fn to_iso(self) -> String {
self.0.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
}
pub fn timestamp_millis(&self) -> i64 {
pub fn timestamp_millis(self) -> i64 {
self.0.and_utc().timestamp_millis()
}
@@ -40,7 +40,7 @@ impl Ts {
DateTime::<Utc>::from_timestamp_millis(ms).map(Ts::from_utc)
}
pub fn add(&self, d: Duration) -> Self {
pub fn add(self, d: Duration) -> Self {
Ts(self.0 + d)
}
@@ -68,9 +68,8 @@ impl<'de> Deserialize<'de> for Ts {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
match &value {
serde_json::Value::String(s) => {
parse_js_date(s).ok_or_else(|| serde::de::Error::custom(format!("date invalide : {s}")))
}
serde_json::Value::String(s) => parse_js_date(s)
.ok_or_else(|| serde::de::Error::custom(format!("date invalide : {s}"))),
serde_json::Value::Number(n) => n
.as_i64()
.or_else(|| n.as_f64().map(|f| f as i64))
@@ -102,7 +101,11 @@ pub fn parse_js_date(value: &str) -> Option<Ts> {
return Some(Ts::from_utc(dt.with_timezone(&Utc)));
}
// Variantes ISO tolérées par Date.parse : décalage sans « : », espace au lieu de « T »
for fmt in ["%Y-%m-%dT%H:%M:%S%.f%z", "%Y-%m-%d %H:%M:%S%.f%z", "%Y-%m-%dT%H:%M%z"] {
for fmt in [
"%Y-%m-%dT%H:%M:%S%.f%z",
"%Y-%m-%d %H:%M:%S%.f%z",
"%Y-%m-%dT%H:%M%z",
] {
if let Ok(dt) = DateTime::parse_from_str(s, fmt) {
return Some(Ts::from_utc(dt.with_timezone(&Utc)));
}
@@ -149,8 +152,14 @@ mod tests {
#[test]
fn naive_and_date_only() {
assert_eq!(parse_js_date("2024-05-01T08:30").unwrap().to_iso(), "2024-05-01T08:30:00.000Z");
assert_eq!(parse_js_date("2024-05-01").unwrap().to_iso(), "2024-05-01T00:00:00.000Z");
assert_eq!(
parse_js_date("2024-05-01T08:30").unwrap().to_iso(),
"2024-05-01T08:30:00.000Z"
);
assert_eq!(
parse_js_date("2024-05-01").unwrap().to_iso(),
"2024-05-01T00:00:00.000Z"
);
assert!(parse_js_date("pas une date").is_none());
}
}
+11 -2
View File
@@ -35,7 +35,13 @@ pub struct UploadRules<'a> {
pub fn disk_name(original: &str) -> String {
let safe: String = original
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '.' { c } else { '_' })
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' {
c
} else {
'_'
}
})
.collect();
let millis = chrono::Utc::now().timestamp_millis();
let random: u32 = rand::thread_rng().gen_range(0..1_000_000_000);
@@ -103,7 +109,10 @@ pub async fn receive_files(
{
size += chunk.len() as u64;
if size > rules.max_file_size {
return Err(AppError::new(axum::http::StatusCode::PAYLOAD_TOO_LARGE, "File too large"));
return Err(AppError::new(
axum::http::StatusCode::PAYLOAD_TOO_LARGE,
"File too large",
));
}
out.write_all(&chunk)
.await