diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 80bba651..674025ee 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -10,6 +10,7 @@ dependencies = [ "axum", "chrono", "env_logger", + "flate2", "font-kit", "image", "keyring", @@ -19,6 +20,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "tar", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", @@ -39,6 +41,7 @@ dependencies = [ "tokio", "url", "window-vibrancy 0.7.1", + "zip 8.6.0", ] [[package]] @@ -2107,6 +2110,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -7172,7 +7176,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -7991,6 +7995,12 @@ dependencies = [ "rand 0.9.4", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -9584,12 +9594,44 @@ dependencies = [ "memchr", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ee3caace..e157d265 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ name = "JumpServerClient" version = "4.1.2" description = "JumpServer client tool" -authors = [ "JumpServer" ] +authors = ["JumpServer"] license = "MIT" repository = "https://github.com/jumpserver/clients" edition = "2021" @@ -10,14 +10,14 @@ edition = "2021" [lib] name = "jumpserver_client_lib" crate-type = [ - "staticlib", - "cdylib", - "rlib" + "staticlib", + "cdylib", + "rlib" ] [build-dependencies.tauri-build] version = "2.5.0" -features = [ ] +features = [] [dependencies] url = "2.5.7" @@ -46,33 +46,40 @@ tauri-plugin-deep-link = "2.4.5" tauri-plugin-single-instance = "2" tauri-plugin-clipboard-manager = "2.3.0" tauri-plugin-prevent-default = "5.0.0" +flate2 = "1.1.9" +tar = "0.4.46" [dependencies.tokio] version = "1.46.1" features = ["full"] +[dependencies.zip] +version = "8.6.0" +default-features = false +features = ["deflate"] + [dependencies.tauri] version = "2.9.5" features = [ - "macos-private-api", - "tray-icon", - "unstable", + "macos-private-api", + "tray-icon", + "unstable", ] [dependencies.serde] version = "1" -features = [ "derive" ] +features = ["derive"] [dependencies.tauri-plugin-http] version = "2.5.2" -features = [ "unsafe-headers" ] +features = ["unsafe-headers"] [dependencies.reqwest] version = "0.12.25" default-features = false features = [ - "json", - "native-tls" + "json", + "native-tls" ] [target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e3bd5b66..33ca44c6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod api; mod commands; mod http; +mod offline; mod service; mod setup; mod utils; diff --git a/src-tauri/src/offline/mod.rs b/src-tauri/src/offline/mod.rs index e69de29b..d378670e 100644 --- a/src-tauri/src/offline/mod.rs +++ b/src-tauri/src/offline/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod package; +pub(crate) mod recording; +pub(crate) mod storage; +pub(crate) mod utils; diff --git a/src-tauri/src/offline/package.rs b/src-tauri/src/offline/package.rs index e69de29b..f2ac9f8c 100644 --- a/src-tauri/src/offline/package.rs +++ b/src-tauri/src/offline/package.rs @@ -0,0 +1,328 @@ +use crate::offline::recording::{ + RecordingEntry, RecordingKind, RecordingManifest, RecordingMetadata, +}; +use crate::offline::storage::{new_recording_id, OfflineStorage}; +use crate::offline::utils::{ + copy_reader_to_file, detect_archive_recording_kind, detect_gz_kind, ensure_entries_not_empty, + file_size, gunzip_reader_to_file, gunzip_to_file, is_metadata_file, now_string, playable_url, + read_metadata, safe_archive_file_name, strip_gz_suffix, strip_known_suffix, +}; +use anyhow::{bail, Context, Result}; +use flate2::read::GzDecoder; +use std::ffi::OsStr; +use std::fs; +use std::io::Read; +use std::path::Path; +use tar::Archive; +use zip::ZipArchive; + +// package.rs 的整体解析流程: +// 1. parse_file 是唯一入口: +// - 先确认用户传入的是一个存在的本地文件,再根据文件名后缀分流。 +// - 判断顺序很重要: .tar.gz 同时也是 .gz,所以必须先判断 .tar.gz / .tgz,再判断普通 .gz。 +// +// 2. 普通 .mp4 不需要解析包结构,只复制到 OfflineStorage 创建的缓存目录,并生成一个 RecordingEntry。 +// +// 3. 普通 .gz 先根据文件名判断类型: +// - .cast.gz 解压后给 asciinema 播放 +// - .replay.gz 解压后给 Guacamole 播放 +// - .part.gz 解压后按 Guacamole 分片播放 +// +// GzDecoder 的作用就是把 gzip 压缩流包装成一个可读取的 reader,后续 io::copy 会边读边解压写入目标文件。 +// +// 4. .tar / .tar.gz / .tgz 走 parse_tar_reader: +// - tar::Archive 会把一个 reader 解释成 tar 包,然后 entries() 逐个读包内文件。 +// - 代码不会直接解压整个目录,而是逐个 entry 判断:json 是元信息,录像文件才会落盘成 RecordingEntry。 +// +// 5. .zip 走 ZipArchive: +// - ZipArchive::new 会读取 zip 中央目录,archive.len() 是包内 entry 数量,by_index(index) 取出某个 entry。 +// - enclosed_name() 用来拿安全路径,避免 zip 里出现 ../ 这类路径穿越。 +// +// 6. 压缩包里的 json 文件会被 read_metadata 解析成 RecordingMetadata。 +// 当前实现是读到 json 后,后面的录像 entry 复用这份 metadata,所以如果包内有多个分片,它们会共享最近一次读到的元信息。 +// +// 7. 压缩包里的录像文件统一交给 create_entry_from_reader: +// 它负责生成 id、创建缓存目录、写入真实播放文件、计算文件大小、生成 playable_url,最后返回 RecordingEntry。 +// +// 8. RecordingEntry 是后端内部结构,里面包含真实 content_path;前端后续只应该拿 manifest.playable_url,不应该看到真实本地路径。 + +/// 离线录像包解析器 +/// +/// 只负责把用户选择的本地文件转换成后端可登记、可播放的 RecordingEntry。 +#[derive(Debug, Clone)] +pub struct OfflinePackageParser { + storage: OfflineStorage, +} + +impl OfflinePackageParser { + pub fn new(storage: OfflineStorage) -> Self { + Self { storage } + } + + /// 解析单个离线录像文件 + /// + /// AsRef 表示 source 不限定必须是 PathBuf 或 &Path,只要它可以被看作 Path 就行 + /// Path 表示一个路径,不一定是文件 + pub fn parse_file(&self, source: impl AsRef) -> Result> { + self.storage.ensure_ready()?; + + let source = source.as_ref(); + + if !source.exists() { + bail!("recording file does not exist: {:?}", source); + } + + if !source.is_file() { + bail!("recording path is not a file: {:?}", source); + } + + // .file_name() 的意思是:取路径最后一段,返回 Option<&OsStr> 这是因为:在 Unix / macOS / Linux 上,文件路径不一定是合法 UTF-8 + // OsStr 操作系统字符串,to_str() 尝试把 OsStr 转成 Rust 普通字符串 &str + let file_name = source + .file_name() + .and_then(OsStr::to_str) + .context("recording file name is invalid utf-8")?; + + if file_name.ends_with(".tar.gz") || file_name.ends_with(".tgz") { + return self.parse_tar_gz(source); + } + + if file_name.ends_with(".tar") { + return self.parse_tar(source); + } + + if file_name.ends_with(".zip") { + return self.parse_zip(source); + } + + if file_name.ends_with(".mp4") { + return Ok(vec![self.parse_mp4(source, file_name)?]); + } + + if file_name.ends_with(".gz") { + return Ok(vec![self.parse_gz(source, file_name)?]); + } + + bail!("unsupported offline recording file: {}", file_name); + } + + /// 解析 MP4 + fn parse_mp4(&self, source: &Path, file_name: &str) -> Result { + let id = new_recording_id(); + let playable_url = playable_url(&id); + // MP4 本身就是可播放文件,所以只需要复制进目录中 + let target_path = self.storage.copy_into_recording(source, &id, file_name)?; + let file_size = file_size(&target_path)?; + let working_dir = self.storage.recording_dir(&id)?; + + let manifest = RecordingManifest::new( + id, + strip_known_suffix(file_name).to_string(), + RecordingKind::Mp4, + RecordingMetadata::default(), + playable_url, + file_size, + now_string(), + ); + + Ok(RecordingEntry { + manifest, + content_path: target_path, + working_dir, + }) + } + + /// 解析 gz + fn parse_gz(&self, source: &Path, file_name: &str) -> Result { + let kind = detect_gz_kind(file_name)?; + let id = new_recording_id(); + let playable_url = playable_url(&id); + let target_file_name = strip_gz_suffix(file_name); + let target_path = self.storage.recording_file_path(&id, &target_file_name)?; + + // 创建目标目录 + self.storage.create_recording_dir(&id)?; + // 把 .gz 解压成真实可播放文件 + gunzip_to_file(source, &target_path)?; + + let file_size = file_size(&target_path)?; + let working_dir = self.storage.recording_dir(&id)?; + + let manifest = RecordingManifest::new( + id, + strip_known_suffix(&target_file_name).to_string(), + kind, + RecordingMetadata::default(), + playable_url, + file_size, + now_string(), + ); + + Ok(RecordingEntry { + manifest, + content_path: target_path, + working_dir, + }) + } + + /// 解析 tar 包 + fn parse_tar(&self, source: &Path) -> Result> { + let file = fs::File::open(source) + .with_context(|| format!("open tar file failed: {:?}", source))?; + + self.parse_tar_reader(file) + } + + /// 解析 tar.gz / tgz 包 + fn parse_tar_gz(&self, source: &Path) -> Result> { + let file = fs::File::open(source) + .with_context(|| format!("open tar.gz file failed: {:?}", source))?; + + let decoder = GzDecoder::new(file); + self.parse_tar_reader(decoder) + } + + /// 解析 zip 包 + fn parse_zip(&self, source: &Path) -> Result> { + let file = fs::File::open(source) + .with_context(|| format!("open zip file failed: {:?}", source))?; + + // ZipArchive::new(file) 返回 Result, ZipError> + // 也就是:尝试把一个已经打开的文件解析成 zip 包对象,它不是解压后的目录,也不是文件内容本身。它只是一个 zip 读取器 + let mut archive = ZipArchive::new(file) + .with_context(|| format!("read zip archive failed: {:?}", source))?; + + let mut metadata = RecordingMetadata::default(); + let mut entries = Vec::new(); + + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .with_context(|| format!("read zip entry failed: index={}", index))?; + + if file.is_dir() { + continue; + } + + // enclosed_name 可以确保文件路径可以安全地用作 Path + let Some(path) = file.enclosed_name() else { + continue; + }; + + let Some(file_name) = safe_archive_file_name(&path) else { + continue; + }; + + if is_metadata_file(&file_name) { + metadata = read_metadata(&mut file).unwrap_or(metadata); + continue; + } + + let Some(kind) = detect_archive_recording_kind(&file_name) else { + continue; + }; + + entries.push(self.create_entry_from_reader( + &file_name, + kind, + metadata.clone(), + &mut file, + )?); + } + + ensure_entries_not_empty(entries) + } + + /// 从 tar reader 中解析录像条目 + fn parse_tar_reader(&self, reader: R) -> Result> + where + R: Read, + { + let mut archive = Archive::new(reader); + let mut metadata = RecordingMetadata::default(); + let mut entries = Vec::new(); + + for item in archive.entries().context("read tar entries failed")? { + let mut item = item.context("read tar entry failed")?; + + if item.header().entry_type().is_dir() { + continue; + } + + let path = item + .path() + .context("read tar entry path failed")? + .to_path_buf(); + + let Some(file_name) = safe_archive_file_name(&path) else { + continue; + }; + + if is_metadata_file(&file_name) { + metadata = read_metadata(&mut item).unwrap_or(metadata); + continue; + } + + let Some(kind) = detect_archive_recording_kind(&file_name) else { + continue; + }; + + entries.push(self.create_entry_from_reader( + &file_name, + kind, + metadata.clone(), + &mut item, + )?); + } + + ensure_entries_not_empty(entries) + } + + /// 从 archive entry 创建录像登记 + fn create_entry_from_reader( + &self, + file_name: &str, + kind: RecordingKind, + metadata: RecordingMetadata, + reader: &mut R, + ) -> Result + where + R: Read, + { + let id = new_recording_id(); + let playable_url = playable_url(&id); + let target_file_name = if file_name.ends_with(".gz") { + strip_gz_suffix(file_name) + } else { + file_name.to_string() + }; + + self.storage.create_recording_dir(&id)?; + let target_path = self.storage.recording_file_path(&id, &target_file_name)?; + + if file_name.ends_with(".gz") { + gunzip_reader_to_file(reader, &target_path)?; + } else { + copy_reader_to_file(reader, &target_path)?; + } + + let file_size = file_size(&target_path)?; + let working_dir = self.storage.recording_dir(&id)?; + + let manifest = RecordingManifest::new( + id, + strip_known_suffix(&target_file_name).to_string(), + kind, + metadata, + playable_url, + file_size, + now_string(), + ); + + Ok(RecordingEntry { + manifest, + content_path: target_path, + working_dir, + }) + } +} diff --git a/src-tauri/src/offline/recording.rs b/src-tauri/src/offline/recording.rs index e69de29b..63fcc286 100644 --- a/src-tauri/src/offline/recording.rs +++ b/src-tauri/src/offline/recording.rs @@ -0,0 +1,89 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// 用于隐藏真实文件路径 +pub type RecordingId = String; + +/// 离线录像的播放类型 +/// +/// 这个类型决定前端后续应该使用哪一种播放器: +/// - mp4 走普通 video 播放器 +/// - cast 走 asciinema 播放器 +/// - gua / part 走 Guacamole 录像播放器 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RecordingKind { + Mp4, + Cast, + Gua, + Part, +} + +/// 离线录像的业务元信息 +/// +/// 这些字段来自录像包里的 json 文件,例如 replay.json。 +/// 不是所有录像格式都会带完整信息,所以全部使用 Option。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RecordingMetadata { + pub user: Option, + pub asset: Option, + pub protocol: Option, + pub date_start: Option, + pub date_end: Option, + pub duration: Option, + pub command_amount: Option, +} + +/// 返回给前端的离线录像清单 +/// +/// 前端不应该拿到本地真实路径,只通过 playable_url 请求 Rust 本地 HTTP 服务。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecordingManifest { + pub id: RecordingId, + pub name: String, + pub kind: RecordingKind, + pub metadata: RecordingMetadata, + pub playable_url: String, + pub raw_url: Option, + pub file_size: u64, + pub created_at: String, +} + +/// Rust 后端内部保存的录像登记信息 +/// +/// 这个结构可以包含真实本地路径,但不要直接返回给前端。 +#[derive(Debug, Clone)] +pub struct RecordingEntry { + pub manifest: RecordingManifest, + pub content_path: PathBuf, + pub working_dir: PathBuf, +} + +impl RecordingManifest { + pub fn new( + id: RecordingId, + name: String, + kind: RecordingKind, + metadata: RecordingMetadata, + playable_url: String, + file_size: u64, + created_at: String, + ) -> Self { + Self { + id, + name, + kind, + metadata, + playable_url, + raw_url: None, + file_size, + created_at, + } + } + + /// 设置原始文件访问地址 + pub fn with_raw_url(mut self, raw_url: String) -> Self { + self.raw_url = Some(raw_url); + self + } +} diff --git a/src-tauri/src/offline/storage.rs b/src-tauri/src/offline/storage.rs index e69de29b..0ab86bb2 100644 --- a/src-tauri/src/offline/storage.rs +++ b/src-tauri/src/offline/storage.rs @@ -0,0 +1,158 @@ +/* +负责: + - 离线录像缓存根目录 + - 为每个录像创建独立目录 + - 复制 / 删除缓存文件 + - 清空所有离线缓存 + - 校验 recording_id,避免路径穿越 + - 校验文件名,避免 ../xxx +*/ +use crate::offline::recording::RecordingId; +use anyhow::{bail, Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// 离线录像的本地缓存管理器 +/// +/// 这个结构只负责文件系统路径和缓存目录,不负责解析录像内容。 +#[derive(Debug, Clone)] +pub struct OfflineStorage { + root_dir: PathBuf, +} + +impl OfflineStorage { + pub fn new(app_data_dir: impl Into) -> Self { + Self { + root_dir: app_data_dir.into().join("offline").join("recordings"), + } + } + + /// 返回离线录像缓存根目录 + pub fn root_dir(&self) -> &Path { + &self.root_dir + } + + /// 确保缓存根目录存在 + pub fn ensure_ready(&self) -> Result<()> { + fs::create_dir_all(&self.root_dir) + .with_context(|| format!("create offline storage dir failed: {:?}", self.root_dir))?; + + Ok(()) + } + + /// 返回某个录像的缓存目录 + pub fn recording_dir(&self, id: &RecordingId) -> Result { + Self::validate_recording_id(id)?; + + Ok(self.root_dir.join(id)) + } + + /// 创建某个录像的缓存目录 + pub fn create_recording_dir(&self, id: &RecordingId) -> Result { + let dir = self.recording_dir(id)?; + + fs::create_dir_all(&dir) + .with_context(|| format!("create recording dir failed: {:?}", dir))?; + + Ok(dir) + } + + /// 返回某个录像目录下的安全文件路径 + pub fn recording_file_path(&self, id: &RecordingId, file_name: &str) -> Result { + Self::validate_file_name(file_name)?; + + Ok(self.recording_dir(id)?.join(file_name)) + } + + /// 将外部文件复制到某个录像缓存目录 + pub fn copy_into_recording( + &self, + source: impl AsRef, + id: &RecordingId, + file_name: &str, + ) -> Result { + self.create_recording_dir(id)?; + + let target = self.recording_file_path(id, file_name)?; + let source = source.as_ref(); + + fs::copy(source, &target) + .with_context(|| format!("copy recording file failed: {:?} -> {:?}", source, target))?; + + Ok(target) + } + + /// 删除某个录像的整个缓存目录 + pub fn remove_recording(&self, id: &RecordingId) -> Result<()> { + let dir = self.recording_dir(id)?; + + if dir.exists() { + fs::remove_dir_all(&dir) + .with_context(|| format!("remove recording dir failed: {:?}", dir))?; + } + + Ok(()) + } + + /// 清空所有离线录像缓存 + pub fn clear_all(&self) -> Result<()> { + if self.root_dir.exists() { + fs::remove_dir_all(&self.root_dir) + .with_context(|| format!("clear offline storage failed: {:?}", self.root_dir))?; + } + + self.ensure_ready() + } + + /// 校验录像 ID,避免路径穿越 + fn validate_recording_id(id: &str) -> Result<()> { + if id.is_empty() { + bail!("recording is empty"); + } + + if id.len() > 128 { + bail!("recording id too long"); + } + + // 检查 id 中每一个字符是否是 ASCII 字符或 _ 或 -。只要有一个字符不满足,valid 就是 false + let valid = id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'); + + if !valid { + bail!("recording id contains invalid characters"); + } + + Ok(()) + } + + /// 校验缓存文件名,避免 ../ 这类路径穿越 + fn validate_file_name(file_name: &str) -> Result<()> { + if file_name.is_empty() { + bail!("file name is empty"); + } + + if file_name == "." || file_name == ".." { + bail!("invalid file name"); + } + + if file_name.contains("/") || file_name.contains("\\") { + bail!("file name must not contain path separators"); + } + + Ok(()) + } +} + +/// 生成离线录像 ID +/// +/// 使用进程 ID + 当前时间纳秒,足够用于本地缓存目录 +pub fn new_recording_id() -> RecordingId { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + + format!("rec-{}-{}", std::process::id(), nanos) +} diff --git a/src-tauri/src/offline/utils.rs b/src-tauri/src/offline/utils.rs new file mode 100644 index 00000000..65d7941a --- /dev/null +++ b/src-tauri/src/offline/utils.rs @@ -0,0 +1,178 @@ +use crate::offline::recording::{RecordingEntry, RecordingKind, RecordingMetadata}; +use anyhow::{bail, Context, Result}; +use chrono::Utc; +use flate2::read::GzDecoder; +use std::ffi::OsStr; +use std::io::Read; +use std::path::Path; +use std::{fs, io}; + +const LOCAL_RECORDINGS_BASE_URL: &str = "http://127.0.0.1:14876/recordings"; + +pub fn file_size(path: &Path) -> anyhow::Result { + Ok(fs::metadata(path) + .with_context(|| format!("read file metadata failed: {:?}", path))? + .len()) +} + +/// 根据 gzip 文件名判断录像类型 +pub fn detect_gz_kind(file_name: &str) -> anyhow::Result { + if file_name.ends_with(".cast.gz") { + return Ok(RecordingKind::Cast); + } + + if file_name.ends_with(".replay.gz") { + return Ok(RecordingKind::Gua); + } + + if file_name.ends_with(".part.gz") { + return Ok(RecordingKind::Part); + } + + bail!("unsupported gzip recording file: {}", file_name); +} + +/// 移除 .gz 后缀 +pub fn strip_gz_suffix(file_name: &str) -> String { + file_name + .strip_suffix(".gz") + .unwrap_or(file_name) + .to_string() +} + +/// 移除常见录像文件后缀,用作显示名称 +pub fn strip_known_suffix(file_name: &str) -> &str { + file_name + .strip_suffix(".replay") + .or_else(|| file_name.strip_suffix(".part")) + .or_else(|| file_name.strip_suffix(".cast")) + .or_else(|| file_name.strip_suffix(".mp4")) + .unwrap_or(file_name) +} + +/// 生成前端播放地址 +pub fn playable_url(id: &str) -> String { + format!("{}/{}/content", LOCAL_RECORDINGS_BASE_URL, id) +} + +/// 当前时间字符串 +pub fn now_string() -> String { + Utc::now().to_rfc3339() +} + +/// 解压 gzip 文件到目标路径 +pub fn gunzip_to_file(source: &Path, target: &Path) -> anyhow::Result<()> { + let source_file = + fs::File::open(source).with_context(|| format!("open gzip file failed: {:?}", source))?; + + let mut decoder = GzDecoder::new(source_file); + let mut target_file = fs::File::create(target) + .with_context(|| format!("create gunzip target failed: {:?}", target))?; + + io::copy(&mut decoder, &mut target_file) + .with_context(|| format!("gunzip recording failed: {:?} -> {:?}", source, target))?; + + Ok(()) +} + +/// 只取 archive entry 的最后一段文件名,避免路径穿越 +pub fn safe_archive_file_name(path: &Path) -> Option { + path.file_name().and_then(OsStr::to_str).map(str::to_string) +} + +/// 判断是否是录像元信息文件 +pub fn is_metadata_file(file_name: &str) -> bool { + file_name.ends_with(".json") +} + +/// 根据 archive 内部文件名判断录像类型 +pub fn detect_archive_recording_kind(file_name: &str) -> Option { + if file_name.ends_with(".mp4") { + return Some(RecordingKind::Mp4); + } + + if file_name.ends_with(".cast") || file_name.ends_with(".cast.gz") { + return Some(RecordingKind::Cast); + } + + if file_name.ends_with(".replay") || file_name.ends_with(".replay.gz") { + return Some(RecordingKind::Gua); + } + + if file_name.ends_with(".part") || file_name.ends_with(".part.gz") { + return Some(RecordingKind::Part); + } + + None +} + +/// 读取录像元信息 JSON +pub fn read_metadata(reader: &mut R) -> Result +where + R: Read, +{ + let mut text = String::new(); + reader + .read_to_string(&mut text) + .context("read recording metadata failed")?; + + let value: serde_json::Value = + serde_json::from_str(&text).context("parse recording metadata failed")?; + + Ok(RecordingMetadata { + user: string_field(&value, "user"), + asset: string_field(&value, "asset"), + protocol: string_field(&value, "protocol"), + date_start: string_field(&value, "date_start"), + date_end: string_field(&value, "date_end"), + duration: string_field(&value, "duration"), + command_amount: value.get("command_amount").and_then(|value| value.as_u64()), + }) +} + +/// 从 JSON 中读取字符串字段 +pub fn string_field(value: &serde_json::Value, key: &str) -> Option { + value + .get(key) + .and_then(|value| value.as_str()) + .map(str::to_string) +} + +/// 把 reader 内容直接写入文件 +pub fn copy_reader_to_file(reader: &mut R, target: &Path) -> Result<()> +where + R: Read, +{ + let mut target_file = fs::File::create(target) + .with_context(|| format!("create target file failed: {:?}", target))?; + + io::copy(reader, &mut target_file) + .with_context(|| format!("copy archive entry failed: {:?}", target))?; + + Ok(()) +} + +/// 把 reader 里的 gzip 内容解压到文件 +pub fn gunzip_reader_to_file(reader: &mut R, target: &Path) -> Result<()> +where + R: Read, +{ + let mut decoder = GzDecoder::new(reader); + + let mut target_file = fs::File::create(target) + .with_context(|| format!("create gunzip target failed: {:?}", target))?; + + io::copy(&mut decoder, &mut target_file) + .with_context(|| format!("gunzip archive entry failed: {:?}", target))?; + + Ok(()) +} + +/// 确保压缩包里至少解析出一个可播放录像 +pub fn ensure_entries_not_empty(entries: Vec) -> Result> { + if entries.is_empty() { + bail!("archive does not contain supported recording files"); + } + + Ok(entries) +}