Files
Loki/rust/loki-rs/src/convfiles.rs
T
Claude db9b0b44f5 Clippy 1.99 : as_chunks au lieu de chunks_exact(4) pour la vignette
Le clippy de la CI (Rust 1.99) refuse chunks_exact à taille constante
(chunks_exact_to_as_chunks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 17:05:47 +00:00

1574 lines
52 KiB
Rust

//! Fichiers de la discussion — port de `web_files.go`, `web_files_zip.go`,
//! `web_upload.go` (dépôt, téléchargement, image), `web_thumb.go`,
//! `web_upload_orient.go` (orientation, réduction) et des chemins de
//! `chat_convfiles.go` (lot 11 de la migration), servis par la façade quand
//! `LOKI_FILES=rust` (expérimental ; le Go par défaut) :
//!
//! - `POST /api/chat/upload` : dépôt en un ou plusieurs morceaux base64 ;
//! - `GET /api/chat/files` : panneau Fichiers ;
//! - `GET /api/chat/files/zip` : toute la discussion en .zip (fiche, tranches
//! base64, ou brut) ;
//! - `POST /api/chat/file/delete` : suppression ;
//! - `GET /api/chat/file` : téléchargement (fiche, tranche base64, ou brut) ;
//! - `GET /api/chat/image` : affichage d'une image (vignette `thumb=<px>`).
//!
//! Le dossier de la discussion active reste décidé par le Go
//! (`/internal/v1/conv/workspace`), qui la connaît dans tous les cas.
//!
//! Écarts assumés : octets des vignettes JPEG (encodeur différent : mêmes
//! dimensions, même redressement, même réduction par moyennage) ; horodatage
//! des entrées .zip en UTC ; une seule plage d'octets par requête de
//! téléchargement (le Go en acceptait plusieurs, en multipart).
use std::collections::HashMap;
use std::io::{Read, Seek, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use base64::Engine;
use bytes::Bytes;
use http_body_util::{BodyExt, Full, Limited, StreamBody};
use hyper::body::{Frame, Incoming};
use hyper::header::{self, HeaderMap, HeaderValue};
use hyper::{Method, Request, Response, StatusCode};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::api::Ctx;
use crate::auth::json as send_json;
use crate::downloads::{disk_free, human_bytes};
use crate::paths::{go_clean, go_join};
use crate::proxy::Body;
const CONV_FILES_ROOT: &str = "discussions";
const UPLOADS_SUB: &str = "uploads";
const CAPTURE_DIR: &str = "captures";
const UPLOAD_MAX_BYTES: i64 = 1 << 30;
const UPLOAD_CHUNK_MAX: usize = 8 << 20;
const DOWNLOAD_CHUNK_MAX: i64 = 8 << 20;
const UPLOAD_SPACE_MARGIN: i64 = 512 << 20;
const UP_SESSION_TTL: Duration = Duration::from_secs(600);
const WS_WALK_LIMIT: usize = 20000;
const THUMB_MAX_SOURCE: u64 = 40 << 20;
const THUMB_CACHE_MAX: usize = 300;
/// Plus grand côté d'une image redressée pour l'affichage (vignette).
const E2E_HEADER: &str = "x-loki-e2e";
/// Routes servies ici.
pub fn handles(path: &str) -> bool {
matches!(
path,
"/api/chat/upload"
| "/api/chat/files"
| "/api/chat/files/zip"
| "/api/chat/file/delete"
| "/api/chat/file"
| "/api/chat/image"
)
}
// ---------------------------------------------------------------------------
// Fonctions pures
// ---------------------------------------------------------------------------
/// `safeUploadName` : un nom de fichier simple, sans chemin ni caractère
/// interdit, tronqué à 120 octets en gardant l'extension.
pub fn safe_upload_name(name: &str) -> String {
let name = name.replace('\\', "/");
let base = name.trim_end_matches('/').rsplit('/').next().unwrap_or("");
let base = if base.is_empty() { "." } else { base };
let mut b = String::new();
for r in base.chars() {
if r.is_control() || matches!(r, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') {
b.push('_');
} else {
b.push(r);
}
}
let mut name = b
.trim_matches(char::is_whitespace)
.trim_matches('.')
.to_string();
if name.is_empty() {
name = "fichier".into();
}
if name.len() > 120 {
let mut ext = go_ext(&name).to_string();
if ext.len() > 16 {
ext.clear();
}
let mut cut = 120 - ext.len();
while !name.is_char_boundary(cut) {
cut -= 1;
}
name = format!("{}{ext}", &name[..cut]);
}
name
}
/// `filepath.Ext`.
fn go_ext(name: &str) -> &str {
match name.rfind(['.', '/']) {
Some(i) if name.as_bytes()[i] == b'.' => &name[i..],
_ => "",
}
}
/// `slugify` : minuscules ASCII et tirets, 40 caractères au plus.
pub fn slugify(s: &str) -> String {
let mut b = String::new();
for r in s.trim_matches(char::is_whitespace).to_lowercase().chars() {
if r.is_ascii_lowercase() || r.is_ascii_digit() {
b.push(r);
} else if matches!(r, ' ' | '-' | '_' | '.') && !b.is_empty() && !b.ends_with('-') {
b.push('-');
}
if b.len() >= 40 {
break;
}
}
b.trim_matches('-').to_string()
}
/// `safeConvID`.
fn safe_conv_id(id: &str) -> String {
let id = id.trim();
if id.is_empty()
|| id.len() > 64
|| !id
.chars()
.all(|r| r == '-' || r == '_' || r.is_numeric() || r.is_ascii_alphabetic())
{
return String::new();
}
id.to_string()
}
/// `exifOrientation` : le tag Orientation (1..8) d'un JPEG, 0 sinon.
pub fn exif_orientation(b: &[u8]) -> i32 {
if b.len() < 4 || b[0] != 0xFF || b[1] != 0xD8 {
return 0;
}
let mut pos = 2;
while pos + 4 <= b.len() {
if b[pos] != 0xFF {
return 0;
}
let marker = b[pos + 1];
if marker == 0xDA || marker == 0xD9 {
return 0;
}
let seg_len = u16::from_be_bytes([b[pos + 2], b[pos + 3]]) as usize;
if seg_len < 2 || pos + 2 + seg_len > b.len() {
return 0;
}
let seg = &b[pos + 4..pos + 2 + seg_len];
if marker == 0xE1 && seg.len() >= 6 && &seg[..6] == b"Exif\0\0" {
return exif_from_tiff(&seg[6..]);
}
pos += 2 + seg_len;
}
0
}
fn exif_from_tiff(t: &[u8]) -> i32 {
if t.len() < 8 {
return 0;
}
let be = match &t[..2] {
b"II" => false,
b"MM" => true,
_ => return 0,
};
let u16_at = |i: usize| {
let x = [t[i], t[i + 1]];
if be {
u16::from_be_bytes(x)
} else {
u16::from_le_bytes(x)
}
};
let u32_at = |i: usize| {
let x = [t[i], t[i + 1], t[i + 2], t[i + 3]];
if be {
u32::from_be_bytes(x)
} else {
u32::from_le_bytes(x)
}
};
if u16_at(2) != 0x2A {
return 0;
}
let ifd = u32_at(4) as usize;
if ifd < 8 || ifd + 2 > t.len() {
return 0;
}
let n = u16_at(ifd) as usize;
let mut entry = ifd + 2;
for _ in 0..n {
if entry + 12 > t.len() {
return 0;
}
if u16_at(entry) == 0x0112 {
return u16_at(entry + 8) as i32;
}
entry += 12;
}
0
}
/// Une image RGBA non prémultipliée (`*image.NRGBA`).
#[derive(Debug, Clone, PartialEq)]
pub struct Rgba {
pub w: usize,
pub h: usize,
pub pix: Vec<u8>,
}
/// `applyOrientation` : rotations et miroirs du tag EXIF cuits dans les pixels.
pub fn apply_orientation(src: &Rgba, orient: i32) -> Rgba {
let (w, h) = (src.w, src.h);
let (dw, dh) = if orient >= 5 { (h, w) } else { (w, h) };
let mut dst = vec![0u8; dw * dh * 4];
for y in 0..h {
for x in 0..w {
let (nx, ny) = match orient {
2 => (w - 1 - x, y),
3 => (w - 1 - x, h - 1 - y),
4 => (x, h - 1 - y),
5 => (y, x),
6 => (h - 1 - y, x),
7 => (h - 1 - y, w - 1 - x),
8 => (y, w - 1 - x),
_ => (x, y),
};
let s = (y * w + x) * 4;
let d = (ny * dw + nx) * 4;
dst[d..d + 4].copy_from_slice(&src.pix[s..s + 4]);
}
}
Rgba {
w: dw,
h: dh,
pix: dst,
}
}
/// `scaleDownToMax` : réduction par moyennage d'aire, ratio conservé.
pub fn scale_down_to_max(src: &Rgba, max: usize) -> Rgba {
let (w, h) = (src.w, src.h);
if w <= max && h <= max {
return src.clone();
}
let scale = if h > w {
max as f64 / h as f64
} else {
max as f64 / w as f64
};
let nw = ((w as f64 * scale).round() as usize).max(1);
let nh = ((h as f64 * scale).round() as usize).max(1);
let mut dst = vec![0u8; nw * nh * 4];
for dy in 0..nh {
let sy0 = dy * h / nh;
let sy1 = ((dy + 1) * h / nh).max(sy0 + 1);
for dx in 0..nw {
let sx0 = dx * w / nw;
let sx1 = ((dx + 1) * w / nw).max(sx0 + 1);
let mut acc = [0u64; 4];
let mut cnt = 0u64;
for sy in sy0..sy1 {
for sx in sx0..sx1 {
let i = (sy * w + sx) * 4;
for (a, p) in acc.iter_mut().zip(&src.pix[i..i + 4]) {
*a += *p as u64;
}
cnt += 1;
}
}
let di = (dy * nw + dx) * 4;
for c in 0..4 {
dst[di + c] = (acc[c] / cnt) as u8;
}
}
}
Rgba {
w: nw,
h: nh,
pix: dst,
}
}
/// `http.DetectContentType`, réduit aux signatures d'images (le reste n'est
/// jamais servi comme image) ; "" si ce n'en est pas une.
pub fn sniff_image(head: &[u8]) -> &'static str {
let starts = |sig: &[u8]| head.starts_with(sig);
if starts(b"\x00\x00\x01\x00") || starts(b"\x00\x00\x02\x00") {
"image/x-icon"
} else if starts(b"BM") {
"image/bmp"
} else if starts(b"GIF87a") || starts(b"GIF89a") {
"image/gif"
} else if head.len() >= 14 && &head[..4] == b"RIFF" && &head[8..14] == b"WEBPVP" {
"image/webp"
} else if starts(b"\x89PNG\r\n\x1a\n") {
"image/png"
} else if starts(b"\xff\xd8\xff") {
"image/jpeg"
} else {
""
}
}
/// `relWithin` : `abs` est-il DANS `root` (liens résolus des deux côtés) ? Rend
/// le chemin relatif en `/`.
pub fn rel_within(root: &str, abs: &str) -> Option<String> {
if root.is_empty() {
return None;
}
let resolve = |p: &str| {
std::fs::canonicalize(p)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| go_clean(p))
};
let (root, abs) = (resolve(root), resolve(abs));
if abs == root {
return None;
}
let prefix = if root.ends_with('/') {
root.clone()
} else {
format!("{root}/")
};
abs.strip_prefix(&prefix).map(str::to_string)
}
/// `wsResolve` : un chemin relatif du client sous la racine du panneau.
pub fn ws_resolve(root: &str, rel: &str) -> Option<String> {
let rel = rel.trim().replace('\\', "/");
let rel = rel.trim_start_matches('/');
if rel.is_empty() || rel == "." {
return Some(root.to_string());
}
let abs = go_join(&[root, rel]);
rel_within(root, &abs).map(|_| abs)
}
/// `dirSize` : somme du contenu (liens non suivis), bornée.
pub fn dir_size(root: &Path) -> (i64, usize) {
fn walk(dir: &Path, total: &mut i64, count: &mut usize) {
if *count >= WS_WALK_LIMIT {
return;
}
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
let mut ents: Vec<_> = rd.filter_map(Result::ok).collect();
ents.sort_by_key(|e| e.file_name());
for e in ents {
if *count >= WS_WALK_LIMIT {
return;
}
let Ok(ft) = e.file_type() else { continue };
if ft.is_symlink() {
continue;
}
if ft.is_dir() {
walk(&e.path(), total, count);
continue;
}
if let Ok(m) = e.metadata() {
*total += m.len() as i64;
*count += 1;
}
}
}
let (mut total, mut count) = (0, 0);
walk(root, &mut total, &mut count);
(total, count)
}
fn mtime_secs(m: &std::fs::Metadata) -> i64 {
m.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map_or(0, |d| d.as_secs() as i64)
}
/// Les extensions présentées comme images dans le panneau.
fn is_image_ext(name: &str) -> bool {
matches!(
go_ext(name).to_lowercase().as_str(),
".png" | ".jpg" | ".jpeg" | ".gif" | ".webp" | ".bmp" | ".svg" | ".avif"
)
}
/// La discussion active vue du Go.
#[derive(Debug, Clone, Deserialize)]
pub struct Workspace {
pub dir: String,
pub root: String,
#[serde(default)]
pub id: String,
#[serde(default)]
pub title: String,
}
impl Workspace {
fn conv_dir_for(&self, id: &str) -> String {
let id = safe_conv_id(id);
if id.is_empty() {
self.root.clone()
} else {
go_join(&[&self.root, CONV_FILES_ROOT, &id])
}
}
/// `filesRoot` : la discussion, ou la racine pour `scope=legacy`.
fn files_root(&self, scope: &str) -> (String, bool) {
if scope.trim() == "legacy" {
(self.root.clone(), true)
} else {
(self.dir.clone(), false)
}
}
/// `workspaceFile` : discussion d'abord, emplacements d'avant ensuite.
pub fn workspace_file(&self, rel: &str) -> Option<String> {
let rel = rel.trim().replace('\\', "/");
let rel = rel.trim_start_matches('/');
if rel.is_empty() {
return None;
}
let p = go_join(&[&self.dir, rel]);
rel_within(&self.dir, &p)?;
if std::fs::metadata(&p).is_ok() {
return Some(p);
}
if let Some(l) = self.legacy_file(rel) {
return Some(l);
}
Some(p)
}
fn legacy_file(&self, rel: &str) -> Option<String> {
let file = |p: &str| std::fs::metadata(p).is_ok_and(|m| !m.is_dir());
let parts: Vec<&str> = rel.splitn(3, '/').collect();
if parts.len() == 3 && parts[0] == CAPTURE_DIR && !safe_conv_id(parts[1]).is_empty() {
let p = go_join(&[&self.conv_dir_for(parts[1]), CAPTURE_DIR, parts[2]]);
if file(&p) {
return Some(p);
}
}
let p = go_join(&[&self.root, rel]);
rel_within(&self.root, &p)?;
file(&p).then_some(p)
}
/// `legacyCount`.
fn legacy_count(&self) -> usize {
std::fs::read_dir(&self.root)
.map(|rd| {
rd.filter_map(Result::ok)
.filter(|e| {
let n = e.file_name().to_string_lossy().into_owned();
n != CONV_FILES_ROOT && !n.starts_with('.')
})
.count()
})
.unwrap_or(0)
}
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
struct UpSession {
f: std::fs::File,
path: PathBuf,
name: String,
written: i64,
}
struct UpEntry {
sess: tokio::sync::Mutex<Option<UpSession>>,
last: Mutex<Instant>,
busy: std::sync::atomic::AtomicBool,
}
/// Les fichiers de la discussion, tenus par la façade.
pub struct ConvFiles {
tmp_dir: PathBuf,
uploads: Mutex<HashMap<String, Arc<UpEntry>>>,
thumbs: Mutex<HashMap<String, (SystemTime, u64, Bytes)>>,
}
impl Default for ConvFiles {
fn default() -> Self {
Self::new(std::env::temp_dir())
}
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
impl ConvFiles {
/// `tmp_dir` : où se construisent les archives .zip.
pub fn new(tmp_dir: PathBuf) -> Self {
ConvFiles {
tmp_dir,
uploads: Mutex::new(HashMap::new()),
thumbs: Mutex::new(HashMap::new()),
}
}
/// `sweepUploadSessions` : les envois abandonnés sont fermés et effacés.
fn sweep(&self) {
let mut map = lock(&self.uploads);
let stale: Vec<String> = map
.iter()
.filter(|(_, e)| {
!e.busy.load(std::sync::atomic::Ordering::SeqCst)
&& lock(&e.last).elapsed() > UP_SESSION_TTL
})
.map(|(k, _)| k.clone())
.collect();
for k in stale {
if let Some(e) = map.remove(&k)
&& let Ok(mut s) = e.sess.try_lock()
&& let Some(s) = s.take()
{
let _ = std::fs::remove_file(&s.path);
}
}
}
/// `chatThumb` : vignette JPEG (grand côté borné 64..1600), en cache tant
/// que le fichier ne change pas.
fn thumb(&self, path: &str, meta: &std::fs::Metadata, px: i64) -> Result<Bytes, String> {
let px = px.clamp(64, 1600) as usize;
if meta.len() > THUMB_MAX_SOURCE {
return Err("image trop lourde pour une vignette".into());
}
let key = format!("{path}\0{px}");
let modt = meta.modified().unwrap_or(UNIX_EPOCH);
if let Some((m, s, d)) = lock(&self.thumbs).get(&key)
&& *m == modt
&& *s == meta.len()
{
return Ok(d.clone());
}
let raw = std::fs::read(path).map_err(|e| e.to_string())?;
let img = image::load_from_memory(&raw)
.map_err(|_| "format non pris en charge pour une vignette".to_string())?
.to_rgba8();
let mut src = Rgba {
w: img.width() as usize,
h: img.height() as usize,
pix: img.into_raw(),
};
let o = exif_orientation(&raw);
if (2..=8).contains(&o) {
src = apply_orientation(&src, o);
}
if src.w > px || src.h > px {
src = scale_down_to_max(&src, px);
}
// Fond blanc sous la transparence.
let mut rgb = Vec::with_capacity(src.w * src.h * 3);
for p in src.pix.as_chunks::<4>().0 {
let a = p[3] as u32;
for c in &p[..3] {
rgb.push(((*c as u32 * a + 255 * (255 - a) + 127) / 255) as u8);
}
}
let mut out = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 78)
.encode(
&rgb,
src.w as u32,
src.h as u32,
image::ExtendedColorType::Rgb8,
)
.map_err(|e| e.to_string())?;
let data = Bytes::from(out);
let mut cache = lock(&self.thumbs);
if cache.len() >= THUMB_CACHE_MAX {
let keys: Vec<String> = cache
.keys()
.take(THUMB_CACHE_MAX / 2 + 1)
.cloned()
.collect();
for k in keys {
cache.remove(&k);
}
}
cache.insert(key, (modt, meta.len(), data.clone()));
Ok(data)
}
}
/// `cleanStaleUploadParts` : au démarrage, les envois interrompus.
pub fn clean_stale_upload_parts(root: &str) {
let mut dirs = vec![go_join(&[root, UPLOADS_SUB])];
if let Ok(rd) = std::fs::read_dir(go_join(&[root, CONV_FILES_ROOT])) {
for e in rd.filter_map(Result::ok) {
if e.file_type().is_ok_and(|t| t.is_dir()) {
dirs.push(e.path().join(UPLOADS_SUB).to_string_lossy().into_owned());
}
}
}
for d in dirs {
let Ok(rd) = std::fs::read_dir(&d) else {
continue;
};
for e in rd.filter_map(Result::ok) {
let n = e.file_name().to_string_lossy().into_owned();
if !e.file_type().is_ok_and(|t| t.is_dir())
&& n.starts_with(".upload-")
&& n.ends_with(".part")
{
let _ = std::fs::remove_file(e.path());
}
}
}
}
/// `uniqueUploadPath` : rapport.pdf, rapport-2.pdf…
fn unique_upload_path(dir: &str, name: &str) -> String {
let p = go_join(&[dir, name]);
if std::fs::symlink_metadata(&p).is_err() {
return p;
}
let ext = go_ext(name);
let base = &name[..name.len() - ext.len()];
let mut p = p;
for i in 2..1000 {
p = go_join(&[dir, &format!("{base}-{i}{ext}")]);
if std::fs::symlink_metadata(&p).is_err() {
return p;
}
}
p
}
/// La date locale (`2006-01-02`).
fn local_date() -> String {
#[cfg(unix)]
{
let t = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as libc::time_t);
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
// SAFETY : pointeurs valides, structure de sortie initialisée.
if !unsafe { libc::localtime_r(&t, &mut tm) }.is_null() {
return format!(
"{:04}-{:02}-{:02}",
tm.tm_year + 1900,
tm.tm_mon + 1,
tm.tm_mday
);
}
}
let days = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs() / 86400) as i64;
let (y, m, d) = civil(days);
format!("{y:04}-{m:02}-{d:02}")
}
/// Jours depuis 1970 → (année, mois, jour), calendrier grégorien.
fn civil(days: i64) -> (i64, u32, u32) {
let z = days + 719468;
let era = z.div_euclid(146097);
let doe = z.rem_euclid(146097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(era * 400 + yoe + i64::from(m <= 2), m, d)
}
/// Date HTTP (`Last-Modified`).
fn http_date(secs: i64) -> String {
let (y, m, d) = civil(secs.div_euclid(86400));
let rem = secs.rem_euclid(86400);
let wd = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"]
[secs.div_euclid(86400).rem_euclid(7) as usize];
let mon = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
][m as usize - 1];
format!(
"{wd}, {d:02} {mon} {y:04} {:02}:{:02}:{:02} GMT",
rem / 3600,
(rem / 60) % 60,
rem % 60
)
}
/// `url.PathEscape`.
fn path_escape(s: &str) -> String {
crate::hf::go_path_escape(s)
}
// ---------------------------------------------------------------------------
// Archives
// ---------------------------------------------------------------------------
/// `buildZip` : liens ignorés, parcours borné, chemins relatifs en `/` ;
/// le dossier des discussions exclu de la vue « hors discussion ».
pub fn build_zip(root: &Path, dest: &Path, exclude_convs: bool) -> Result<usize, String> {
let out = std::fs::File::create(dest).map_err(|e| e.to_string())?;
let mut zw = zip::ZipWriter::new(out);
let mut files = 0usize;
fn walk<W: Write + Seek>(
zw: &mut zip::ZipWriter<W>,
dir: &Path,
rel: &str,
exclude: bool,
files: &mut usize,
) -> Result<(), String> {
let Ok(rd) = std::fs::read_dir(dir) else {
return Ok(());
};
let mut ents: Vec<_> = rd.filter_map(Result::ok).collect();
ents.sort_by_key(|e| e.file_name());
for e in ents {
if *files >= WS_WALK_LIMIT {
return Ok(());
}
let Ok(ft) = e.file_type() else { continue };
if ft.is_symlink() {
continue;
}
let name = e.file_name().to_string_lossy().into_owned();
let child = if rel.is_empty() {
name.clone()
} else {
format!("{rel}/{name}")
};
if ft.is_dir() {
if exclude && rel.is_empty() && name == CONV_FILES_ROOT {
continue;
}
walk(zw, &e.path(), &child, exclude, files)?;
continue;
}
let Ok(meta) = e.metadata() else { continue };
let Ok(mut f) = std::fs::File::open(e.path()) else {
continue;
};
let mut opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.large_file(meta.len() >= 0xFFFF_FFFF);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
opts = opts.unix_permissions(meta.permissions().mode() & 0o777);
}
let secs = mtime_secs(&meta);
let (y, mo, d) = civil(secs.div_euclid(86400));
let rem = secs.rem_euclid(86400);
if let Ok(dt) = zip::DateTime::from_date_and_time(
y.clamp(1980, 2107) as u16,
mo as u8,
d as u8,
(rem / 3600) as u8,
((rem / 60) % 60) as u8,
(rem % 60) as u8,
) {
opts = opts.last_modified_time(dt);
}
zw.start_file(child, opts).map_err(|e| e.to_string())?;
std::io::copy(&mut f, zw).map_err(|e| e.to_string())?;
*files += 1;
}
Ok(())
}
walk(&mut zw, root, "", exclude_convs, &mut files)?;
zw.finish().map_err(|e| e.to_string())?;
Ok(files)
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
fn fail(code: StatusCode, msg: impl Into<String>) -> Response<Body> {
send_json(code, &json!({ "ok": false, "error": msg.into() }))
}
fn query(uri: &hyper::Uri) -> HashMap<String, String> {
let mut out = HashMap::new();
for kv in uri
.query()
.unwrap_or("")
.split('&')
.filter(|s| !s.is_empty())
{
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
// r.URL.Query().Get : la première valeur.
out.entry(form_decode(k)).or_insert_with(|| form_decode(v));
}
out
}
fn form_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
match b[i] {
b'+' => out.push(b' '),
b'%' if i + 2 < b.len() => {
match std::str::from_utf8(&b[i + 1..i + 3])
.ok()
.and_then(|h| u8::from_str_radix(h, 16).ok())
{
Some(v) => {
out.push(v);
i += 3;
continue;
}
None => out.push(b'%'),
}
}
c => out.push(c),
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// `strconv.ParseInt` ou 0.
fn parse_i64(s: Option<&String>) -> i64 {
s.and_then(|v| v.parse::<i64>().ok()).unwrap_or(0)
}
async fn workspace(ctx: &Ctx) -> Result<Workspace, String> {
let v = ctx
.store
.internal_json(
Method::POST,
"/internal/v1/conv/workspace",
Some(&json!({})),
)
.await?;
serde_json::from_value(v).map_err(|e| e.to_string())
}
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
tokio::task::spawn_blocking(f)
.await
.expect("tâche bloquante interrompue")
}
/// Une tranche base64 d'un fichier (`b64=1&offset=&len=`).
fn b64_slice(path: &str, name: &str, size: i64, q: &HashMap<String, String>) -> Response<Body> {
let off = parse_i64(q.get("offset"));
let mut length = parse_i64(q.get("len"));
if length <= 0 || length > DOWNLOAD_CHUNK_MAX {
length = DOWNLOAD_CHUNK_MAX;
}
if off < 0 || off > size {
return fail(StatusCode::BAD_REQUEST, "position hors du fichier");
}
if off + length > size {
length = size - off;
}
let mut buf = vec![0u8; length as usize];
let n = match std::fs::File::open(path).and_then(|mut f| {
f.seek(std::io::SeekFrom::Start(off as u64))?;
let mut n = 0;
while n < buf.len() {
match f.read(&mut buf[n..])? {
0 => break,
k => n += k,
}
}
Ok(n)
}) {
Ok(n) => n,
Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
};
send_json(
StatusCode::OK,
&json!({
"ok": true, "name": name, "size": size, "offset": off,
"data": base64::engine::general_purpose::STANDARD.encode(&buf[..n]),
"eof": off + n as i64 >= size,
}),
)
}
/// Corps d'un fichier, en flux (une plage d'octets au plus).
fn serve_file(
path: &str,
meta: &std::fs::Metadata,
req_headers: &HeaderMap,
extra: &[(&'static str, String)],
) -> Response<Body> {
let size = meta.len();
let modified = mtime_secs(meta);
if let Some(ims) = req_headers
.get(header::IF_MODIFIED_SINCE)
.and_then(|v| v.to_str().ok())
&& ims == http_date(modified)
{
let mut r = Response::new(Full::new(Bytes::new()).map_err(|n| match n {}).boxed());
*r.status_mut() = StatusCode::NOT_MODIFIED;
return r;
}
let (mut start, mut end, mut status) = (0u64, size.saturating_sub(1), StatusCode::OK);
if let Some(range) = req_headers
.get(header::RANGE)
.and_then(|v| v.to_str().ok())
.and_then(|r| r.strip_prefix("bytes="))
&& !range.contains(',')
&& size > 0
{
let (a, b) = range.split_once('-').unwrap_or((range, ""));
let parsed = match (a.trim().parse::<u64>(), b.trim().parse::<u64>()) {
(Ok(a), Ok(b)) if a <= b && a < size => Some((a, b.min(size - 1))),
(Ok(a), Err(_)) if b.trim().is_empty() && a < size => Some((a, size - 1)),
(Err(_), Ok(n)) if a.trim().is_empty() && n > 0 => {
Some((size.saturating_sub(n), size - 1))
}
_ => None,
};
match parsed {
Some((a, b)) => {
(start, end, status) = (a, b, StatusCode::PARTIAL_CONTENT);
}
None => {
let mut r = fail(StatusCode::RANGE_NOT_SATISFIABLE, "plage invalide");
if let Ok(v) = HeaderValue::from_str(&format!("bytes */{size}")) {
r.headers_mut().insert(header::CONTENT_RANGE, v);
}
return r;
}
}
}
let len = if size == 0 { 0 } else { end - start + 1 };
let path = path.to_string();
let stream = futures_util::stream::unfold(
(None::<std::fs::File>, start, len),
move |(f, pos, left)| {
let path = path.clone();
async move {
if left == 0 {
return None;
}
let res = tokio::task::spawn_blocking(move || {
let mut f = match f {
Some(f) => f,
None => {
let mut f = std::fs::File::open(&path)?;
f.seek(std::io::SeekFrom::Start(pos))?;
f
}
};
let mut buf = vec![0u8; left.min(64 << 10) as usize];
let n = f.read(&mut buf)?;
buf.truncate(n);
Ok::<_, std::io::Error>((f, buf))
})
.await
.ok()?;
match res {
Ok((_, buf)) if buf.is_empty() => None,
Ok((f, buf)) => {
let n = buf.len() as u64;
Some((
Ok::<_, hyper::Error>(Frame::data(Bytes::from(buf))),
(Some(f), pos + n, left - n),
))
}
Err(_) => None,
}
}
},
);
let mut r = Response::new(BodyExt::boxed(StreamBody::new(stream)));
*r.status_mut() = status;
let h = r.headers_mut();
for (k, v) in extra {
if let Ok(v) = HeaderValue::from_str(v) {
h.insert(*k, v);
}
}
h.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
if let Ok(v) = HeaderValue::from_str(&len.to_string()) {
h.insert(header::CONTENT_LENGTH, v);
}
if let Ok(v) = HeaderValue::from_str(&http_date(modified)) {
h.insert(header::LAST_MODIFIED, v);
}
if status == StatusCode::PARTIAL_CONTENT
&& let Ok(v) = HeaderValue::from_str(&format!("bytes {start}-{end}/{size}"))
{
h.insert(header::CONTENT_RANGE, v);
}
r
}
#[derive(Deserialize, Default)]
struct UploadReq {
#[serde(default)]
name: String,
#[serde(default)]
data: String,
#[serde(default)]
id: String,
#[serde(default)]
more: bool,
#[serde(default)]
size: i64,
}
/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui.
pub async fn serve(
ctx: &Ctx,
cf: &Arc<ConvFiles>,
req: Request<Incoming>,
from_go: bool,
) -> Response<Body> {
let (parts, body) = req.into_parts();
if !from_go && let Err(refus) = ctx.guard(&parts.headers).await {
return *refus;
}
let ws = match workspace(ctx).await {
Ok(w) => w,
Err(e) => {
return fail(
StatusCode::SERVICE_UNAVAILABLE,
format!("fichiers indisponibles : {e}"),
);
}
};
let q = query(&parts.uri);
let e2e = parts.headers.contains_key(E2E_HEADER);
match parts.uri.path() {
"/api/chat/files" => {
let scope = q.get("scope").cloned().unwrap_or_default();
let dir = q.get("dir").cloned().unwrap_or_default();
blocking(move || list_files(&ws, &scope, &dir)).await
}
"/api/chat/file/delete" => {
let v = read_json(body, 1 << 20).await.unwrap_or(Value::Null);
let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let (path, scope) = (s("path"), s("scope"));
blocking(move || delete_file(&ws, &path, &scope)).await
}
"/api/chat/file" => {
let rel = q.get("path").cloned().unwrap_or_default();
if rel.trim().is_empty() {
return fail(StatusCode::BAD_REQUEST, "chemin manquant");
}
let Some(abs) = ws.workspace_file(&rel) else {
return fail(StatusCode::FORBIDDEN, "hors du dossier de travail");
};
let meta = match std::fs::metadata(&abs) {
Ok(m) if !m.is_dir() => m,
_ => return fail(StatusCode::NOT_FOUND, "fichier introuvable"),
};
let name = crate::paths::base_name(&abs);
if q.contains_key("meta") && !q["meta"].is_empty() {
return send_json(
StatusCode::OK,
&json!({ "ok": true, "name": name, "size": meta.len(), "e2e": e2e }),
);
}
if q.get("b64").is_some_and(|v| !v.is_empty()) {
let size = meta.len() as i64;
return blocking(move || b64_slice(&abs, &name, size, &q)).await;
}
serve_file(
&abs,
&meta,
&parts.headers,
&[
("content-type", "application/octet-stream".into()),
("x-content-type-options", "nosniff".into()),
(
"content-disposition",
format!("attachment; filename*=UTF-8''{}", path_escape(&name)),
),
],
)
}
"/api/chat/image" => {
let rel = q.get("path").cloned().unwrap_or_default();
if rel.trim().is_empty() {
return fail(StatusCode::BAD_REQUEST, "chemin manquant");
}
let Some(abs) = ws.workspace_file(&rel) else {
return fail(StatusCode::FORBIDDEN, "hors du dossier de travail");
};
let meta = match std::fs::metadata(&abs) {
Ok(m) if !m.is_dir() => m,
_ => return fail(StatusCode::NOT_FOUND, "fichier introuvable"),
};
if let Some(t) = q.get("thumb").filter(|t| !t.is_empty()) {
let px = t.parse::<i64>().unwrap_or(0);
let (cf2, abs2, meta2) = (cf.clone(), abs.clone(), meta.clone());
if let Ok(data) = blocking(move || cf2.thumb(&abs2, &meta2, px)).await {
let mut r = Response::new(Full::new(data).map_err(|n| match n {}).boxed());
let h = r.headers_mut();
h.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/jpeg"));
h.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
h.insert(
"content-security-policy",
HeaderValue::from_static("default-src 'none'; sandbox"),
);
h.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, max-age=3600"),
);
return r;
}
}
let mut head = [0u8; 512];
let n = match std::fs::File::open(&abs).and_then(|mut f| {
let mut n = 0;
while n < head.len() {
match f.read(&mut head[n..])? {
0 => break,
k => n += k,
}
}
Ok(n)
}) {
Ok(n) => n,
Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
};
let mime = sniff_image(&head[..n]);
if mime.is_empty() {
return fail(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"ce fichier n'est pas une image",
);
}
let name = crate::paths::base_name(&abs);
serve_file(
&abs,
&meta,
&parts.headers,
&[
("content-type", mime.into()),
("x-content-type-options", "nosniff".into()),
(
"content-security-policy",
"default-src 'none'; sandbox".into(),
),
(
"content-disposition",
format!("inline; filename*=UTF-8''{}", path_escape(&name)),
),
],
)
}
"/api/chat/files/zip" => {
let scope = q.get("scope").cloned().unwrap_or_default();
let legacy = scope.trim() == "legacy";
let (root, _) = ws.files_root(&scope);
let id = if legacy {
"legacy".to_string()
} else {
let id = safe_conv_id(&ws.id);
if id.is_empty() { "conv".into() } else { id }
};
let tmp = cf.tmp_dir.join(format!("loki-files-{id}.zip"));
let name = if legacy {
format!("loki-hors-discussion-{}.zip", local_date())
} else {
let slug = slugify(&ws.title);
let slug = if slug.is_empty() { ws.id.clone() } else { slug };
format!("loki-{slug}-{}.zip", local_date())
};
if q.get("meta").is_some_and(|v| !v.is_empty()) {
let tmp2 = tmp.clone();
let res = blocking(move || build_zip(Path::new(&root), &tmp2, legacy)).await;
let files = match res {
Ok(n) => n,
Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e),
};
if files == 0 {
let _ = std::fs::remove_file(&tmp);
return fail(StatusCode::NOT_FOUND, "aucun fichier à archiver");
}
let size = std::fs::metadata(&tmp).map(|m| m.len()).unwrap_or(0);
return send_json(
StatusCode::OK,
&json!({ "ok": true, "name": name, "size": size, "files": files, "e2e": e2e }),
);
}
let meta = match std::fs::metadata(&tmp) {
Ok(m) => m,
Err(_) => {
return fail(
StatusCode::NOT_FOUND,
"archive absente — redemande la fiche (meta=1) d'abord",
);
}
};
let tmp_s = tmp.to_string_lossy().into_owned();
if q.get("b64").is_some_and(|v| !v.is_empty()) {
let size = meta.len() as i64;
return blocking(move || b64_slice(&tmp_s, &name, size, &q)).await;
}
serve_file(
&tmp_s,
&meta,
&HeaderMap::new(),
&[
("content-type", "application/zip".into()),
(
"content-disposition",
format!(
"attachment; filename={}",
serde_json::to_string(&name).unwrap_or_default()
),
),
],
)
}
"/api/chat/upload" => upload(cf, &ws, body).await,
_ => send_json(
StatusCode::NOT_FOUND,
&json!({ "error": "404 page not found" }),
),
}
}
async fn read_json(body: Incoming, max: usize) -> Result<Value, String> {
let b = Limited::new(body, max)
.collect()
.await
.map_err(|e| e.to_string())?
.to_bytes();
serde_json::from_slice(&b).map_err(|e| e.to_string())
}
/// `handleChatFiles`.
fn list_files(ws: &Workspace, scope: &str, dir: &str) -> Response<Body> {
let (root, legacy) = ws.files_root(scope);
if !legacy {
let _ = std::fs::create_dir_all(&root);
}
let Some(abs) = ws_resolve(&root, dir) else {
return fail(StatusCode::FORBIDDEN, "hors du dossier de travail");
};
if !std::fs::metadata(&abs).is_ok_and(|m| m.is_dir()) {
return fail(StatusCode::NOT_FOUND, "dossier introuvable");
}
let rd = match std::fs::read_dir(&abs) {
Ok(rd) => rd,
Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
};
let mut ents: Vec<_> = rd.filter_map(Result::ok).collect();
ents.sort_by_key(|e| e.file_name());
let cur = rel_within(&root, &abs).unwrap_or_default();
let mut entries: Vec<(bool, i64, Value)> = Vec::new();
for e in ents {
let name = e.file_name().to_string_lossy().into_owned();
if legacy && cur.is_empty() && name == CONV_FILES_ROOT {
continue;
}
let Ok(meta) = e.metadata() else { continue };
let is_dir = e.file_type().is_ok_and(|t| t.is_dir());
let p = if cur.is_empty() {
name.clone()
} else {
format!("{cur}/{name}")
};
let m = mtime_secs(&meta);
let mut it = json!({ "name": name, "path": p, "dir": is_dir, "mod": m });
if is_dir {
let (size, items) = dir_size(&e.path());
it["size"] = json!(size);
it["image"] = json!(false);
if items > 0 {
it["items"] = json!(items);
}
} else {
it["size"] = json!(meta.len());
it["image"] = json!(is_image_ext(&name));
}
entries.push((is_dir, m, it));
}
// Dossiers d'abord, puis du plus récent au plus ancien (tri stable).
entries.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
let parent = match cur.rfind('/') {
Some(i) => cur[..i].to_string(),
None => String::new(),
};
let (total, count) = dir_size(Path::new(&root));
send_json(
StatusCode::OK,
&json!({
"ok": true, "dir": cur, "parent": parent, "at_root": cur.is_empty(),
"entries": entries.into_iter().map(|e| e.2).collect::<Vec<_>>(),
"total": total, "count": count, "root": root,
"scope": if legacy { "legacy" } else { "" }, "legacy": ws.legacy_count(),
}),
)
}
/// `handleChatFileDelete`.
fn delete_file(ws: &Workspace, path: &str, scope: &str) -> Response<Body> {
if path.trim().is_empty() {
return fail(StatusCode::BAD_REQUEST, "chemin manquant");
}
let (root, legacy) = ws.files_root(scope);
let Some(abs) = ws_resolve(&root, path) else {
return fail(StatusCode::FORBIDDEN, "hors du dossier de travail");
};
if abs == root {
return fail(
StatusCode::FORBIDDEN,
"le dossier de travail lui-même ne se supprime pas",
);
}
if legacy && abs == go_join(&[&ws.root, CONV_FILES_ROOT]) {
return fail(
StatusCode::FORBIDDEN,
"les dossiers des discussions se suppriment discussion par discussion",
);
}
let meta = match std::fs::symlink_metadata(&abs) {
Ok(m) => m,
Err(_) => return fail(StatusCode::NOT_FOUND, "introuvable"),
};
let (freed, res) = if meta.is_dir() {
(dir_size(Path::new(&abs)).0, std::fs::remove_dir_all(&abs))
} else {
(meta.len() as i64, std::fs::remove_file(&abs))
};
if let Err(e) = res {
return fail(
StatusCode::INTERNAL_SERVER_ERROR,
crate::paths::os_err("unlinkat", &abs, &e),
);
}
send_json(StatusCode::OK, &json!({ "ok": true, "freed": freed }))
}
/// `handleChatUpload` : un morceau ; le dernier donne au fichier son nom.
async fn upload(cf: &Arc<ConvFiles>, ws: &Workspace, body: Incoming) -> Response<Body> {
let req: UploadReq = match Limited::new(body, 2 * UPLOAD_CHUNK_MAX)
.collect()
.await
.ok()
.and_then(|b| serde_json::from_slice(&b.to_bytes()).ok())
{
Some(r) => r,
None => {
return fail(
StatusCode::BAD_REQUEST,
"morceau trop gros ou requête invalide",
);
}
};
let mut data = req.data.as_str();
if data.starts_with("data:")
&& let Some(i) = data.find(',')
{
data = &data[i + 1..];
}
// StdEncoding.DecodeString ignore les retours à la ligne.
let cleaned: String = data
.trim()
.chars()
.filter(|c| *c != '\r' && *c != '\n')
.collect();
let raw = match base64::engine::general_purpose::STANDARD.decode(cleaned) {
Ok(r) => r,
Err(_) => {
return fail(
StatusCode::BAD_REQUEST,
"contenu illisible (base64 attendu)",
);
}
};
// Le verrou de la table ne couvre que la recherche ou la création de la
// session : jamais d'attente sous lui.
let found: Result<(String, Arc<UpEntry>), Box<Response<Body>>> = (|| {
cf.sweep();
let mut map = lock(&cf.uploads);
if let Some(e) = map.get(&req.id) {
return Ok((req.id.clone(), e.clone()));
}
if !req.id.is_empty() {
return Err(Box::new(fail(
StatusCode::CONFLICT,
"envoi expiré — recommence le fichier",
)));
}
let dir = go_join(&[&ws.dir, UPLOADS_SUB]);
if let Err(e) = std::fs::create_dir_all(&dir) {
return Err(Box::new(fail(
StatusCode::INTERNAL_SERVER_ERROR,
e.to_string(),
)));
}
let free = disk_free(&dir);
if free > 0 && req.size > 0 && free < req.size + UPLOAD_SPACE_MARGIN {
return Err(Box::new(send_json(
StatusCode::INSUFFICIENT_STORAGE,
&json!({ "ok": false, "error": format!(
"espace insuffisant : {} libres, {} nécessaires",
human_bytes(free), human_bytes(req.size + UPLOAD_SPACE_MARGIN)) }),
)));
}
let (f, path) =
create_part(&dir).map_err(|e| Box::new(fail(StatusCode::INTERNAL_SERVER_ERROR, e)))?;
let id = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let e = Arc::new(UpEntry {
sess: tokio::sync::Mutex::new(Some(UpSession {
f,
path,
name: safe_upload_name(&req.name),
written: 0,
})),
last: Mutex::new(Instant::now()),
busy: std::sync::atomic::AtomicBool::new(false),
});
map.insert(id.clone(), e.clone());
Ok((id, e))
})();
let (id, entry) = match found {
Ok(x) => x,
Err(r) => return *r,
};
finish_chunk(cf, &id, entry, raw, req.more).await
}
/// `.upload-<nombre>.part`, créé exclusivement (`os.CreateTemp`).
fn create_part(dir: &str) -> Result<(std::fs::File, PathBuf), String> {
for _ in 0..10000 {
let mut b = [0u8; 4];
getrandom::fill(&mut b).map_err(|e| e.to_string())?;
let p = PathBuf::from(go_join(&[
dir,
&format!(".upload-{}.part", u32::from_le_bytes(b)),
]));
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&p)
{
Ok(f) => return Ok((f, p)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e.to_string()),
}
}
Err("aucun nom de fichier temporaire libre".into())
}
async fn finish_chunk(
cf: &Arc<ConvFiles>,
id: &str,
entry: Arc<UpEntry>,
raw: Vec<u8>,
more: bool,
) -> Response<Body> {
use std::sync::atomic::Ordering;
*lock(&entry.last) = Instant::now();
entry.busy.store(true, Ordering::SeqCst);
let mut guard = entry.sess.lock().await;
let res = (|| {
let Some(s) = guard.as_mut() else {
return Err((
StatusCode::CONFLICT,
"envoi expiré — recommence le fichier".to_string(),
));
};
let drop_sess = |s: &UpSession| {
lock(&cf.uploads).remove(id);
let _ = std::fs::remove_file(&s.path);
};
if s.written + raw.len() as i64 > UPLOAD_MAX_BYTES {
drop_sess(s);
return Err((
StatusCode::PAYLOAD_TOO_LARGE,
format!("fichier trop gros (max {})", human_bytes(UPLOAD_MAX_BYTES)),
));
}
if !raw.is_empty() {
if let Err(e) = s.f.write_all(&raw) {
drop_sess(s);
return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()));
}
s.written += raw.len() as i64;
}
if more {
return Ok(json!({ "ok": true, "id": id, "received": s.written }));
}
let sync = s.f.sync_all();
lock(&cf.uploads).remove(id);
let part = s.path.clone();
if let Err(e) = sync {
let _ = std::fs::remove_file(&part);
return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()));
}
if s.written == 0 {
let _ = std::fs::remove_file(&part);
return Err((StatusCode::BAD_REQUEST, "fichier vide".to_string()));
}
let dir = part
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let dest = unique_upload_path(&dir, &s.name);
if let Err(e) = std::fs::rename(&part, &dest) {
let _ = std::fs::remove_file(&part);
return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string()));
}
Ok(json!({
"ok": true,
"path": format!("uploads/{}", crate::paths::base_name(&dest)),
"abs": dest,
"size": s.written,
}))
})();
if !more || res.is_err() {
guard.take();
}
drop(guard);
entry.busy.store(false, Ordering::SeqCst);
match res {
Ok(v) => send_json(StatusCode::OK, &v),
Err((code, msg)) => fail(code, msg),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dates() {
assert_eq!(civil(0), (1970, 1, 1));
assert_eq!(civil(19723), (2024, 1, 1));
assert_eq!(http_date(1700000000), "Tue, 14 Nov 2023 22:13:20 GMT");
assert_eq!(local_date().len(), 10);
}
#[test]
fn extensions() {
assert_eq!(go_ext("a.tar.gz"), ".gz");
assert_eq!(go_ext("rep.d/fichier"), "");
assert_eq!(go_ext("sans"), "");
}
}