From 2d7cb55c028d31226329a6ab7c8db09e2ff2795a Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:15:06 +0800 Subject: [PATCH] split ffmpeg install helpers --- internal/service/ffmpeg_auto_install.go | 243 --------------------- internal/service/ffmpeg_install_archive.go | 202 +++++++++++++++++ internal/service/ffmpeg_status.go | 57 +++++ 3 files changed, 259 insertions(+), 243 deletions(-) create mode 100644 internal/service/ffmpeg_install_archive.go create mode 100644 internal/service/ffmpeg_status.go diff --git a/internal/service/ffmpeg_auto_install.go b/internal/service/ffmpeg_auto_install.go index 4f9ba45..362882c 100644 --- a/internal/service/ffmpeg_auto_install.go +++ b/internal/service/ffmpeg_auto_install.go @@ -1,17 +1,13 @@ package service import ( - "archive/zip" - "bytes" "context" "fmt" "io" "net/http" "os" - "os/exec" "path/filepath" "runtime" - "strings" "time" "go.uber.org/zap" @@ -19,11 +15,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/config" ) -const ( - maxFFmpegZipEntryBytes = int64(2 << 30) - maxFFmpegZipTotalBytes = int64(4 << 30) -) - // AutoInstallFFmpeg is only called by the admin tool-install endpoint. The // server must not auto-download or keep ffmpeg/ffprobe running during startup. func AutoInstallFFmpeg(log *zap.Logger, cfg *config.Config) (ffprobePath, ffmpegPath string) { @@ -191,191 +182,6 @@ func downloadFile(log *zap.Logger, url, filepath string) error { return err } -// unzip 解压 ZIP 文件。 -func unzip(log *zap.Logger, zipPath, destDir string) error { - if err := os.MkdirAll(destDir, 0o750); err != nil { - return err - } - destRoot, err := filepath.Abs(destDir) - if err != nil { - return err - } - reader, err := zip.OpenReader(zipPath) - if err != nil { - return err - } - defer reader.Close() - var totalWritten int64 - for _, file := range reader.File { - if file.UncompressedSize64 > uint64(maxFFmpegZipEntryBytes) { - return fmt.Errorf("zip entry too large: %s", file.Name) - } - target, err := safeZipTarget(destRoot, file.Name) - if err != nil { - return err - } - info := file.FileInfo() - if info.Mode()&os.ModeSymlink != 0 { - log.Warn("跳过 ZIP 符号链接", zap.String("name", file.Name)) - continue - } - if info.IsDir() { - if err := os.MkdirAll(target, 0o750); err != nil { - return err - } - continue - } - if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil { - return err - } - src, err := file.Open() - if err != nil { - return err - } - mode := info.Mode().Perm() - if mode == 0 { - mode = 0o644 - } - dst, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) // #nosec G304 -- target is constrained by safeZipTarget to the extraction directory. - if err != nil { - _ = src.Close() - return err - } - written, err := io.Copy(dst, io.LimitReader(src, maxFFmpegZipEntryBytes+1)) - totalWritten += written - if err != nil { - _ = dst.Close() - _ = src.Close() - return err - } - if written > maxFFmpegZipEntryBytes || totalWritten > maxFFmpegZipTotalBytes { - _ = dst.Close() - _ = src.Close() - return fmt.Errorf("zip content too large: %s", file.Name) - } - if err := dst.Close(); err != nil { - _ = src.Close() - return err - } - if err := src.Close(); err != nil { - return err - } - } - return nil -} - -func safeZipTarget(destRoot, name string) (string, error) { - trimmed := strings.TrimSpace(name) - if strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "\\") || filepath.IsAbs(trimmed) { - return "", fmt.Errorf("unsafe zip path: %s", name) - } - cleanName := filepath.Clean(strings.ReplaceAll(trimmed, "\\", "/")) - if cleanName == "." || strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) { - return "", fmt.Errorf("unsafe zip path: %s", name) - } - target := filepath.Join(destRoot, cleanName) - targetAbs, err := filepath.Abs(target) - if err != nil { - return "", err - } - rel, err := filepath.Rel(destRoot, targetAbs) - if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { - return "", fmt.Errorf("unsafe zip path: %s", name) - } - return targetAbs, nil -} - -func findFFmpegPackageRoot(root string) (string, error) { - var ffmpegPath string - var ffprobePath string - - err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - - switch strings.ToLower(d.Name()) { - case "ffmpeg.exe": - ffmpegPath = path - case "ffprobe.exe": - ffprobePath = path - } - return nil - }) - if err != nil { - return "", fmt.Errorf("扫描解压目录失败: %w", err) - } - if ffmpegPath == "" || ffprobePath == "" { - return "", fmt.Errorf("解压后未找到 ffmpeg/ffprobe 可执行文件") - } - - return filepath.Dir(filepath.Dir(ffmpegPath)), nil -} - -func copyDirContents(srcDir, dstDir string) error { - entries, err := os.ReadDir(srcDir) - if err != nil { - return err - } - - for _, entry := range entries { - srcPath := filepath.Join(srcDir, entry.Name()) - dstPath := filepath.Join(dstDir, entry.Name()) - if err := copyTree(srcPath, dstPath); err != nil { - return err - } - } - return nil -} - -func copyTree(srcPath, dstPath string) error { - info, err := os.Stat(srcPath) - if err != nil { - return err - } - - if info.IsDir() { - if err := os.MkdirAll(dstPath, info.Mode()); err != nil { - return err - } - entries, err := os.ReadDir(srcPath) - if err != nil { - return err - } - for _, entry := range entries { - if err := copyTree(filepath.Join(srcPath, entry.Name()), filepath.Join(dstPath, entry.Name())); err != nil { - return err - } - } - return nil - } - - in, err := os.Open(srcPath) // #nosec G304 -- srcPath is produced by walking the validated extracted ffmpeg package tree. - if err != nil { - return err - } - defer in.Close() - - if err := os.MkdirAll(filepath.Dir(dstPath), 0o750); err != nil { - return err - } - - out, err := os.Create(dstPath) // #nosec G304 -- dstPath is generated under the configured ffmpeg install directory. - if err != nil { - return err - } - defer out.Close() - - if _, err := io.Copy(out, in); err != nil { - return err - } - - return out.Close() -} - // updateConfigPaths 更新配置文件中的路径 func updateConfigPaths(cfg *config.Config, ffprobePath, ffmpegPath string) { cfg.App.FFprobePath = ffprobePath @@ -393,52 +199,3 @@ func updateConfigPaths(cfg *config.Config, ffprobePath, ffmpegPath string) { func is64Bit() bool { return true // 简化处理,假设为 64 位 } - -// CheckFFmpegStatus 检查 ffmpeg/ffprobe 状态 (供 API 使用) -func CheckFFmpegStatus(ffprobePath, ffmpegPath string) map[string]interface{} { - status := map[string]interface{}{ - "ffprobe_installed": false, - "ffmpeg_installed": false, - "auto_installable": runtime.GOOS == "windows", - } - - if ffprobePath != "" { - if _, err := os.Stat(ffprobePath); err == nil { - status["ffprobe_installed"] = true - status["ffprobe_path"] = ffprobePath - - // 获取版本 - cmd := exec.Command(ffprobePath, "-version") - out, err := cmd.Output() - if err == nil { - // 提取版本信息(第一行) - lines := bytes.Split(out, []byte("\n")) - if len(lines) > 0 { - version := string(bytes.TrimSpace(lines[0])) - status["ffprobe_version"] = version - status["ffprobe_security"] = EvaluateFFmpegSecurity(version) - } - } - } - } - - if ffmpegPath != "" { - if _, err := os.Stat(ffmpegPath); err == nil { - status["ffmpeg_installed"] = true - status["ffmpeg_path"] = ffmpegPath - - cmd := exec.Command(ffmpegPath, "-version") - out, err := cmd.Output() - if err == nil { - lines := bytes.Split(out, []byte("\n")) - if len(lines) > 0 { - version := string(bytes.TrimSpace(lines[0])) - status["ffmpeg_version"] = version - status["ffmpeg_security"] = EvaluateFFmpegSecurity(version) - } - } - } - } - - return status -} diff --git a/internal/service/ffmpeg_install_archive.go b/internal/service/ffmpeg_install_archive.go new file mode 100644 index 0000000..c8b7453 --- /dev/null +++ b/internal/service/ffmpeg_install_archive.go @@ -0,0 +1,202 @@ +package service + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "go.uber.org/zap" +) + +const ( + maxFFmpegZipEntryBytes = int64(2 << 30) + maxFFmpegZipTotalBytes = int64(4 << 30) +) + +// unzip 解压 ZIP 文件。 +func unzip(log *zap.Logger, zipPath, destDir string) error { + if err := os.MkdirAll(destDir, 0o750); err != nil { + return err + } + destRoot, err := filepath.Abs(destDir) + if err != nil { + return err + } + reader, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer reader.Close() + var totalWritten int64 + for _, file := range reader.File { + if file.UncompressedSize64 > uint64(maxFFmpegZipEntryBytes) { + return fmt.Errorf("zip entry too large: %s", file.Name) + } + target, err := safeZipTarget(destRoot, file.Name) + if err != nil { + return err + } + info := file.FileInfo() + if info.Mode()&os.ModeSymlink != 0 { + log.Warn("跳过 ZIP 符号链接", zap.String("name", file.Name)) + continue + } + if info.IsDir() { + if err := os.MkdirAll(target, 0o750); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil { + return err + } + src, err := file.Open() + if err != nil { + return err + } + mode := info.Mode().Perm() + if mode == 0 { + mode = 0o644 + } + dst, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) // #nosec G304 -- target is constrained by safeZipTarget to the extraction directory. + if err != nil { + _ = src.Close() + return err + } + written, err := io.Copy(dst, io.LimitReader(src, maxFFmpegZipEntryBytes+1)) + totalWritten += written + if err != nil { + _ = dst.Close() + _ = src.Close() + return err + } + if written > maxFFmpegZipEntryBytes || totalWritten > maxFFmpegZipTotalBytes { + _ = dst.Close() + _ = src.Close() + return fmt.Errorf("zip content too large: %s", file.Name) + } + if err := dst.Close(); err != nil { + _ = src.Close() + return err + } + if err := src.Close(); err != nil { + return err + } + } + return nil +} + +func safeZipTarget(destRoot, name string) (string, error) { + trimmed := strings.TrimSpace(name) + if strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "\\") || filepath.IsAbs(trimmed) { + return "", fmt.Errorf("unsafe zip path: %s", name) + } + cleanName := filepath.Clean(strings.ReplaceAll(trimmed, "\\", "/")) + if cleanName == "." || strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) { + return "", fmt.Errorf("unsafe zip path: %s", name) + } + target := filepath.Join(destRoot, cleanName) + targetAbs, err := filepath.Abs(target) + if err != nil { + return "", err + } + rel, err := filepath.Rel(destRoot, targetAbs) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + return "", fmt.Errorf("unsafe zip path: %s", name) + } + return targetAbs, nil +} + +func findFFmpegPackageRoot(root string) (string, error) { + var ffmpegPath string + var ffprobePath string + + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + switch strings.ToLower(d.Name()) { + case "ffmpeg.exe": + ffmpegPath = path + case "ffprobe.exe": + ffprobePath = path + } + return nil + }) + if err != nil { + return "", fmt.Errorf("扫描解压目录失败: %w", err) + } + if ffmpegPath == "" || ffprobePath == "" { + return "", fmt.Errorf("解压后未找到 ffmpeg/ffprobe 可执行文件") + } + + return filepath.Dir(filepath.Dir(ffmpegPath)), nil +} + +func copyDirContents(srcDir, dstDir string) error { + entries, err := os.ReadDir(srcDir) + if err != nil { + return err + } + + for _, entry := range entries { + srcPath := filepath.Join(srcDir, entry.Name()) + dstPath := filepath.Join(dstDir, entry.Name()) + if err := copyTree(srcPath, dstPath); err != nil { + return err + } + } + return nil +} + +func copyTree(srcPath, dstPath string) error { + info, err := os.Stat(srcPath) + if err != nil { + return err + } + + if info.IsDir() { + if err := os.MkdirAll(dstPath, info.Mode()); err != nil { + return err + } + entries, err := os.ReadDir(srcPath) + if err != nil { + return err + } + for _, entry := range entries { + if err := copyTree(filepath.Join(srcPath, entry.Name()), filepath.Join(dstPath, entry.Name())); err != nil { + return err + } + } + return nil + } + + in, err := os.Open(srcPath) // #nosec G304 -- srcPath is produced by walking the validated extracted ffmpeg package tree. + if err != nil { + return err + } + defer in.Close() + + if err := os.MkdirAll(filepath.Dir(dstPath), 0o750); err != nil { + return err + } + + out, err := os.Create(dstPath) // #nosec G304 -- dstPath is generated under the configured ffmpeg install directory. + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + + return out.Close() +} diff --git a/internal/service/ffmpeg_status.go b/internal/service/ffmpeg_status.go new file mode 100644 index 0000000..730f9fc --- /dev/null +++ b/internal/service/ffmpeg_status.go @@ -0,0 +1,57 @@ +package service + +import ( + "bytes" + "os" + "os/exec" + "runtime" +) + +// CheckFFmpegStatus 检查 ffmpeg/ffprobe 状态 (供 API 使用) +func CheckFFmpegStatus(ffprobePath, ffmpegPath string) map[string]interface{} { + status := map[string]interface{}{ + "ffprobe_installed": false, + "ffmpeg_installed": false, + "auto_installable": runtime.GOOS == "windows", + } + + if ffprobePath != "" { + if _, err := os.Stat(ffprobePath); err == nil { + status["ffprobe_installed"] = true + status["ffprobe_path"] = ffprobePath + + // 获取版本 + cmd := exec.Command(ffprobePath, "-version") + out, err := cmd.Output() + if err == nil { + // 提取版本信息(第一行) + lines := bytes.Split(out, []byte("\n")) + if len(lines) > 0 { + version := string(bytes.TrimSpace(lines[0])) + status["ffprobe_version"] = version + status["ffprobe_security"] = EvaluateFFmpegSecurity(version) + } + } + } + } + + if ffmpegPath != "" { + if _, err := os.Stat(ffmpegPath); err == nil { + status["ffmpeg_installed"] = true + status["ffmpeg_path"] = ffmpegPath + + cmd := exec.Command(ffmpegPath, "-version") + out, err := cmd.Output() + if err == nil { + lines := bytes.Split(out, []byte("\n")) + if len(lines) > 0 { + version := string(bytes.TrimSpace(lines[0])) + status["ffmpeg_version"] = version + status["ffmpeg_security"] = EvaluateFFmpegSecurity(version) + } + } + } + } + + return status +}