use actix_multipart::form::tempfile::TempFile; use anyhow::Error; use chrono::Utc; use glob::glob; use std::{collections::HashSet, process::Command}; use tokio::{ fs::{remove_file, rename}, task::spawn_blocking, }; use crate::{ cfg::Cfg, ctx::Ctx, db::models::{Banner, Board, File, Post}, error::NekrochanError, }; impl File { pub async fn new( cfg: &Cfg, temp_file: TempFile, spoiler: bool, thumb: bool, ) -> Result { let original_name = temp_file.file_name.unwrap_or_else(|| "unknown".into()); let mime = temp_file .content_type .ok_or(NekrochanError::FileError( original_name.clone(), "žádný mime typ", ))? .to_string(); let (video, format) = match mime.as_str() { "image/jpeg" => (false, "jpg"), "image/pjpeg" => (false, "jpg"), "image/png" => (false, "png"), "image/bmp" => (false, "bmp"), "image/gif" => (false, "gif"), "image/webp" => (false, "webp"), "image/apng" => (false, "apng"), "video/mpeg" => (true, "mpeg"), "video/quicktime" => (true, "mov"), "video/mp4" => (true, "mp4"), "video/webm" => (true, "webm"), "video/x-matroska" => (true, "mkv"), "video/ogg" => (true, "ogg"), _ => { return Err(NekrochanError::FileError( original_name, "nepodporovaný formát", )) } }; if video && !cfg.files.videos { return Err(NekrochanError::FileError( original_name, "videa nejsou podporovaná", )); } let size = temp_file.size; if size / 1_000_000 > cfg.files.max_size_mb { return Err(NekrochanError::FileError( original_name, "soubor je příliš velký", )); } let timestamp = Utc::now().timestamp_micros(); let format = format.to_owned(); let new_name = format!("{timestamp}.{format}"); let (thumb_format, thumb_name) = if thumb { let format = if video { "png".into() } else { format.clone() }; (Some(format.clone()), Some(format!("{timestamp}.{format}"))) } else { (None, None) }; rename(temp_file.file.path(), format!("/tmp/{new_name}")).await?; let (width, height) = if video { process_video(cfg, original_name.clone(), new_name.clone(), thumb_name).await? } else { process_image(cfg, original_name.clone(), new_name.clone(), thumb_name).await? }; rename(format!("/tmp/{new_name}"), format!("./uploads/{new_name}")).await?; let file = File { original_name, format, thumb_format, spoiler, width, height, timestamp, size, }; Ok(file) } pub async fn delete(&self) { remove_file(format!("./uploads/{}.{}", self.timestamp, self.format)) .await .ok(); if let Some(thumb_format) = &self.thumb_format { remove_file(format!( "./uploads/thumb/{}.{}", self.timestamp, thumb_format )) .await .ok(); } } pub fn file_url(&self) -> String { format!("/uploads/{}.{}", self.timestamp, self.format) } pub fn thumb_url(&self) -> String { if self.spoiler { "/static/spoiler.png".into() } else if let Some(thumb_format) = &self.thumb_format { format!("/uploads/thumb/{}.{}", self.timestamp, thumb_format) } else { self.file_url() } } } async fn process_image( cfg: &Cfg, original_name: String, new_name: String, thumb_name: Option, ) -> Result<(u32, u32), NekrochanError> { let new_name_ = new_name.clone(); let identify_out = spawn_blocking(move || { Command::new("identify") .args(["-format", "%wx%h", &format!("/tmp/{new_name_}[0]")]) .output() }) .await??; let invalid_dimensions = "imagemagick vrátil neplatné rozměry"; let out_string = String::from_utf8_lossy(&identify_out.stdout); let (width, height) = out_string .trim() .split_once('x') .ok_or(NekrochanError::FileError( original_name.clone(), invalid_dimensions, ))?; let (width, height) = ( width .parse() .map_err(|_| NekrochanError::FileError(original_name.clone(), invalid_dimensions))?, height .parse() .map_err(|_| NekrochanError::FileError(original_name.clone(), invalid_dimensions))?, ); if width > cfg.files.max_width || height > cfg.files.max_height { return Err(NekrochanError::FileError( original_name, "rozměry obrázku jsou příliš velké", )); } let Some(thumb_name) = thumb_name else { return Ok((width, height)); }; let thumb_size = cfg.files.thumb_size; let output = spawn_blocking(move || { Command::new("convert") .arg(&format!("/tmp/{new_name}")) .arg("-coalesce") .arg("-thumbnail") .arg(&format!("{thumb_size}x{thumb_size}>")) .arg(&format!("./uploads/thumb/{thumb_name}")) .output() }) .await??; if !output.status.success() { println!("{}", String::from_utf8_lossy(&output.stderr)); return Err(NekrochanError::FileError( original_name, "nepodařilo se vytvořit náhled obrázku", )); } Ok((width, height)) } async fn process_video( cfg: &Cfg, original_name: String, new_name: String, thumb_name: Option, ) -> Result<(u32, u32), NekrochanError> { let new_name_ = new_name.clone(); let ffprobe_out = spawn_blocking(move || { Command::new("ffprobe") .args([ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=s=x:p=0", &format!("/tmp/{new_name_}"), ]) .output() }) .await??; if !ffprobe_out.status.success() { return Err(NekrochanError::FileError( original_name, "nepodařilo se získat rozměry videa", )); } let invalid_dimensions = "ffmpeg vrátil neplatné rozměry"; let out_string = String::from_utf8_lossy(&ffprobe_out.stdout); let (width, height) = out_string .trim() .split_once('x') .ok_or(NekrochanError::FileError( original_name.clone(), invalid_dimensions, ))?; let (width, height) = ( width .parse() .map_err(|_| NekrochanError::FileError(original_name.clone(), invalid_dimensions))?, height .parse() .map_err(|_| NekrochanError::FileError(original_name.clone(), invalid_dimensions))?, ); if width > cfg.files.max_width || height > cfg.files.max_height { return Err(NekrochanError::FileError( original_name, "rozměry videa jsou příliš velké", )); } let Some(thumb_name) = thumb_name else { return Ok((width, height)); }; let thumb_size = cfg.files.thumb_size; let output = spawn_blocking(move || { Command::new("ffmpeg") .args([ "-i", &format!("/tmp/{new_name}"), "-ss", "00:00:00.50", "-vframes", "1", "-vf", &format!( "scale={}", if width > height { format!("{thumb_size}:-2") } else { format!("-2:{thumb_size}") } ), &format!("./uploads/thumb/{thumb_name}"), ]) .output() }) .await??; if !output.status.success() { return Err(NekrochanError::FileError( original_name, "nepodařilo se vytvořit náhled videa", )); } Ok((width, height)) } pub async fn cleanup_files(ctx: &Ctx) -> Result<(), Error> { let mut keep = HashSet::new(); let mut keep_thumbs = HashSet::new(); let banners = Banner::read_all(ctx).await?; for banner in banners { keep.insert(format!( "{}.{}", banner.banner.timestamp, banner.banner.format )); } let boards = Board::read_all(ctx).await?; for board in boards { let posts = Post::read_all(ctx, board.id.clone()).await?; for post in posts { for file in post.files.0 { keep.insert(format!("{}.{}", file.timestamp, file.format)); if let Some(thumb_format) = file.thumb_format { keep_thumbs.insert(format!("{}.{}", file.timestamp, thumb_format)); } } } } for file in glob("./uploads/*.*")? { let file = file?; let file_name = file.file_name(); if let Some(file_name) = file_name { let check = file_name.to_string_lossy().to_string(); if !keep.contains(&check) { remove_file(file).await?; } } } for file in glob("./uploads/thumb/*.*")? { let file = file?; let file_name = file.file_name(); if let Some(file_name) = file_name { let check = file_name.to_string_lossy().to_string(); if !keep_thumbs.contains(&check) { remove_file(file).await?; } } } Ok(()) }