2023-12-11 15:18:43 +00:00
|
|
|
use actix_multipart::form::tempfile::TempFile;
|
|
|
|
use anyhow::Error;
|
|
|
|
use chrono::Utc;
|
|
|
|
use glob::glob;
|
|
|
|
use image::io::Reader as ImageReader;
|
|
|
|
use std::{collections::HashSet, process::Command};
|
|
|
|
use tokio::{
|
|
|
|
fs::{remove_file, rename},
|
|
|
|
task::spawn_blocking,
|
|
|
|
};
|
|
|
|
|
|
|
|
use crate::{
|
|
|
|
cfg::Cfg,
|
|
|
|
ctx::Ctx,
|
|
|
|
db::models::{Board, File, Post},
|
|
|
|
error::NekrochanError,
|
|
|
|
};
|
|
|
|
|
|
|
|
impl File {
|
|
|
|
pub async fn new(
|
|
|
|
cfg: &Cfg,
|
|
|
|
temp_file: TempFile,
|
|
|
|
spoiler: bool,
|
|
|
|
thumb: bool,
|
|
|
|
) -> Result<Self, NekrochanError> {
|
|
|
|
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<String>,
|
|
|
|
) -> Result<(u32, u32), NekrochanError> {
|
|
|
|
let original_name_ = original_name.clone();
|
|
|
|
|
|
|
|
let img = spawn_blocking(move || {
|
|
|
|
ImageReader::open(format!("/tmp/{new_name}"))?
|
|
|
|
.decode()
|
|
|
|
.map_err(|_| {
|
|
|
|
NekrochanError::FileError(original_name_, "nepodařilo se dekódovat obrázek")
|
|
|
|
})
|
|
|
|
})
|
|
|
|
.await??;
|
|
|
|
|
|
|
|
let (width, height) = (img.width(), img.height());
|
|
|
|
|
|
|
|
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é",
|
|
|
|
));
|
|
|
|
}
|
|
|
|
|
2023-12-11 15:59:32 +00:00
|
|
|
let Some(thumb_name) = thumb_name else {
|
|
|
|
return Ok((width, height));
|
2023-12-11 15:18:43 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
let thumb_w = if width > cfg.files.thumb_size {
|
|
|
|
cfg.files.thumb_size
|
|
|
|
} else {
|
|
|
|
width
|
|
|
|
};
|
|
|
|
|
|
|
|
let thumb_h = if height > cfg.files.thumb_size {
|
|
|
|
cfg.files.thumb_size
|
|
|
|
} else {
|
|
|
|
height
|
|
|
|
};
|
|
|
|
|
|
|
|
spawn_blocking(move || {
|
|
|
|
let thumb = img.thumbnail(thumb_w, thumb_h);
|
|
|
|
|
|
|
|
thumb
|
|
|
|
.save(format!("./uploads/thumb/{thumb_name}"))
|
|
|
|
.map_err(|_| {
|
|
|
|
NekrochanError::FileError(original_name, "nepodařilo se vytvořit náhled obrázku")
|
|
|
|
})
|
|
|
|
})
|
|
|
|
.await??;
|
|
|
|
|
|
|
|
Ok((width, height))
|
|
|
|
}
|
|
|
|
|
|
|
|
async fn process_video(
|
|
|
|
cfg: &Cfg,
|
|
|
|
original_name: String,
|
|
|
|
new_name: String,
|
|
|
|
thumb_name: Option<String>,
|
|
|
|
) -> 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 = "ffprobe 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é",
|
|
|
|
));
|
|
|
|
}
|
|
|
|
|
2023-12-11 15:59:32 +00:00
|
|
|
let Some(thumb_name) = thumb_name else {
|
|
|
|
return Ok((width, height));
|
2023-12-11 15:18:43 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
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 boards = Board::read_all(ctx).await?;
|
|
|
|
|
2023-12-11 15:59:32 +00:00
|
|
|
for board in boards {
|
2023-12-11 15:18:43 +00:00
|
|
|
for file in board.banners.0 {
|
|
|
|
keep.insert(format!("{}.{}", file.timestamp, file.format));
|
|
|
|
}
|
|
|
|
|
|
|
|
let posts = Post::read_all(ctx, board.id.clone()).await?;
|
|
|
|
|
2023-12-11 15:59:32 +00:00
|
|
|
for post in posts {
|
2023-12-11 15:18:43 +00:00
|
|
|
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(())
|
|
|
|
}
|