mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-19 13:08:12 +02:00
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:
17 files changed
+690
-207
No files matched your search
+59
-19
@@ -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
@@ -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
@@ -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 *")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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é")
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user