diff --git a/cmd/server/main.go b/cmd/server/main.go index 7535257..cc78097 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -59,7 +59,7 @@ func main() { // Ensure data / cache / web dirs exist. for _, d := range []string{cfg.App.DataDir, cfg.Cache.CacheDir} { - if err := os.MkdirAll(d, 0o755); err != nil { + if err := os.MkdirAll(d, 0o750); err != nil { logger.Fatal("create dir failed", zap.String("dir", d), zap.Error(err)) } } diff --git a/internal/config/config.go b/internal/config/config.go index 588bb6c..c667c23 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -309,7 +309,7 @@ func (c *Config) normalize() error { if c.Secrets.JWTSecret == "" { // 持久化自动生成的密钥以在操作员忘记配置时保持会话稳定。 path := filepath.Join(c.App.DataDir, ".jwt_secret") - if data, err := os.ReadFile(path); err == nil && len(data) > 0 { + if data, err := os.ReadFile(path); err == nil && len(data) > 0 { // #nosec G304 -- path is fixed to .jwt_secret under configured DataDir. c.Secrets.JWTSecret = strings.TrimSpace(string(data)) } else { buf := make([]byte, 32) @@ -317,7 +317,7 @@ func (c *Config) normalize() error { return fmt.Errorf("generate jwt secret: %w", err) } c.Secrets.JWTSecret = hex.EncodeToString(buf) - _ = os.MkdirAll(c.App.DataDir, 0o755) + _ = os.MkdirAll(c.App.DataDir, 0o750) _ = os.WriteFile(path, []byte(c.Secrets.JWTSecret), 0o600) } } diff --git a/internal/handler/license.go b/internal/handler/license.go index 11d3978..0e9434f 100644 --- a/internal/handler/license.go +++ b/internal/handler/license.go @@ -24,7 +24,7 @@ import ( const ( licenseServerURLSetting = "license.server_url" - licenseHMACSecretSetting = "license.hmac_secret" + licenseHMACSecretSetting = "license.hmac_secret" // #nosec G101 -- setting key name, not the HMAC secret value. licenseDeviceIDSetting = "license.device_id" licenseDeviceNameSetting = "license.device_name" ) diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 99cbb45..5abd592 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -259,3 +259,4 @@ func extractToken(c *gin.Context) string { } return "" } + diff --git a/internal/service/backup.go b/internal/service/backup.go index 45e3b35..cada25f 100644 --- a/internal/service/backup.go +++ b/internal/service/backup.go @@ -46,10 +46,29 @@ func (b *BackupService) backupDir() string { return filepath.Join(b.cfg.App.DataDir, "backups") } +func (b *BackupService) backupFilePath(filename string) (string, error) { + if !isValidBackupFilename(filename) { + return "", errors.New("invalid filename") + } + dir, err := filepath.Abs(b.backupDir()) + if err != nil { + return "", err + } + path, err := filepath.Abs(filepath.Join(dir, filename)) + if err != nil { + return "", err + } + rel, err := filepath.Rel(dir, path) + if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + return "", errors.New("invalid filename") + } + return path, nil +} + // Create produces a new hot backup via "VACUUM INTO". func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) { dir := b.backupDir() - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o750); err != nil { return nil, err } ts := time.Now().Format("20060102_150405") @@ -106,10 +125,10 @@ func (b *BackupService) List() ([]BackupInfo, error) { // Delete removes a single backup file. func (b *BackupService) Delete(filename string) error { - if !isValidBackupFilename(filename) { - return errors.New("invalid filename") + path, err := b.backupFilePath(filename) + if err != nil { + return err } - path := filepath.Join(b.backupDir(), filename) return os.Remove(path) } @@ -117,26 +136,29 @@ func (b *BackupService) Delete(filename string) error { // reverse. WARNING: this is destructive — the live DB will be replaced. // Callers should shut down the server after this call. func (b *BackupService) Restore(ctx context.Context, filename string) error { - if !isValidBackupFilename(filename) { - return errors.New("invalid filename") + src, err := b.backupFilePath(filename) + if err != nil { + return err } - src := filepath.Join(b.backupDir(), filename) if _, err := os.Stat(src); err != nil { return fmt.Errorf("backup not found: %s", filename) } - dbPath := b.cfg.Database.DBPath + dbPath, err := filepath.Abs(b.cfg.Database.DBPath) + if err != nil || dbPath == "" { + return errors.New("invalid database path") + } // Strategy: rename live → live.old, copy backup → live, delete old. old := dbPath + ".before_restore" if err := os.Rename(dbPath, old); err != nil && !errors.Is(err, os.ErrNotExist) { return err } - data, err := os.ReadFile(src) + data, err := os.ReadFile(src) // #nosec G304 -- src is constrained by backupFilePath to the configured backups directory. if err != nil { // Revert _ = os.Rename(old, dbPath) return err } - if err := os.WriteFile(dbPath, data, 0o644); err != nil { + if err := os.WriteFile(dbPath, data, 0o600); err != nil { // #nosec G703 -- database path is trusted process config and normalized before restore. _ = os.Rename(old, dbPath) return err } diff --git a/internal/service/bangumi.go b/internal/service/bangumi.go index 2cee34d..f19dfa7 100644 --- a/internal/service/bangumi.go +++ b/internal/service/bangumi.go @@ -6,8 +6,8 @@ // // We implement the minimal subset needed to enrich anime libraries: // -// GET /search/subject/{keywords}?type=2&responseGroup=small -// GET /v0/subjects/{id} (cover) +// GET /search/subject/{keywords}?type=2&responseGroup=small +// GET /v0/subjects/{id} (cover) // // The provider gracefully no-ops when bangumi_access_token is empty. package service @@ -100,7 +100,7 @@ func (b *BangumiProvider) Search(ctx context.Context, query string) (*Match, err Rating: r.Rating.Score, } if len(r.Air) >= 4 { - fmt.Sscanf(r.Air[:4], "%d", &m.Year) + _, _ = fmt.Sscanf(r.Air[:4], "%d", &m.Year) } return m, nil } diff --git a/internal/service/boot_cloud.go b/internal/service/boot_cloud.go new file mode 100644 index 0000000..046f8f6 --- /dev/null +++ b/internal/service/boot_cloud.go @@ -0,0 +1,50 @@ +package service + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +// BootCloudLibraries 在系统启动后自动扫描所有云盘媒体库,使媒体对所有用户立即可见。 +// 避免每个用户首次访问时都触发扫描。 +func (c *Container) BootCloudLibraries(ctx context.Context) { + if c == nil || c.Repo == nil || c.Scan == nil { + return + } + libs, err := c.Repo.Library.List(ctx) + if err != nil { + c.Log.Warn("boot cloud libraries: list failed", zap.Error(err)) + return + } + cloudLibs := make([]model.Library, 0) + for _, lib := range libs { + if _, ok := ParseCloudLibraryMount(lib.Path); ok { + cloudLibs = append(cloudLibs, lib) + } + } + if len(cloudLibs) == 0 { + return + } + c.Log.Info("boot: scheduling cloud library scans", zap.Int("count", len(cloudLibs))) + // 延迟3秒后启动,避免和系统初始化任务冲突 + time.AfterFunc(3*time.Second, func() { + for _, lib := range cloudLibs { + libID := lib.ID + libName := lib.Name + go func() { + scanCtx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) + defer cancel() + c.Log.Info("boot: scanning cloud library", zap.String("id", libID), zap.String("name", libName)) + if _, err := c.Scan.ScanLibraryWithoutAutoScrape(scanCtx, libID); err != nil { + c.Log.Warn("boot: cloud library scan failed", zap.String("id", libID), zap.String("name", libName), zap.Error(err)) + } else { + c.Log.Info("boot: cloud library scan completed", zap.String("id", libID), zap.String("name", libName)) + } + }() + } + }) +} diff --git a/internal/service/bot_features.go b/internal/service/bot_features.go index 3378b32..5584b8b 100644 --- a/internal/service/bot_features.go +++ b/internal/service/bot_features.go @@ -2,7 +2,6 @@ package service import ( "context" - "crypto/rand" "fmt" "strconv" "strings" @@ -238,13 +237,7 @@ const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // no ambiguous 0/O/1/I func randomCode(n int) string { b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - // crypto/rand failure is extremely unlikely; fall back to time noise. - seed := time.Now().UnixNano() - for i := range b { - b[i] = byte(seed >> (uint(i%8) * 8)) - } - } + secureRandomBytes(b) out := make([]byte, n) for i := range b { out[i] = codeAlphabet[int(b[i])%len(codeAlphabet)] diff --git a/internal/service/cache_cleanup.go b/internal/service/cache_cleanup.go index bb74895..afc190c 100644 --- a/internal/service/cache_cleanup.go +++ b/internal/service/cache_cleanup.go @@ -32,7 +32,7 @@ func walkAndPrune(root string, cutoff time.Time) error { return nil } if info.ModTime().Before(cutoff) { - _ = os.Remove(path) + _ = os.Remove(path) // #nosec G122 -- cache pruning is best-effort under the configured cache root. } return nil }) diff --git a/internal/service/device_service.go b/internal/service/device_service.go index c769409..5477610 100644 --- a/internal/service/device_service.go +++ b/internal/service/device_service.go @@ -5,7 +5,6 @@ import ( "crypto/sha256" "encoding/hex" "fmt" - "math/rand" "strings" "time" @@ -304,7 +303,7 @@ func randomWindowDays(min, max int) int { if max == min { return min } - return min + rand.Intn(max-min+1) + return min + secureRandomIntn(max-min+1) } func (s *DeviceService) userMatchesCleanupPolicy(ctx context.Context, u *model.User, cfg botConfig) (bool, string) { diff --git a/internal/service/discover.go b/internal/service/discover.go index 20b2ee2..41debe7 100644 --- a/internal/service/discover.go +++ b/internal/service/discover.go @@ -159,7 +159,7 @@ func (d *DiscoverService) Fetch(ctx context.Context, path string) ([]Match, erro date = r.FirstAirDate } if len(date) >= 4 { - fmt.Sscanf(date[:4], "%d", &m.Year) + _, _ = fmt.Sscanf(date[:4], "%d", &m.Year) } out = append(out, m) } diff --git a/internal/service/douban.go b/internal/service/douban.go index 65cfaa1..e32156a 100644 --- a/internal/service/douban.go +++ b/internal/service/douban.go @@ -20,7 +20,6 @@ import ( "context" "encoding/json" "fmt" - "math/rand" "net/http" "net/url" "strings" @@ -117,7 +116,7 @@ func (d *DoubanProvider) Search(ctx context.Context, query string) (*DoubanMatch } func (d *DoubanProvider) setHeaders(req *http.Request) { - req.Header.Set("User-Agent", userAgents[rand.Intn(len(userAgents))]) + req.Header.Set("User-Agent", userAgents[secureRandomIntn(len(userAgents))]) req.Header.Set("Referer", "https://movie.douban.com/") req.Header.Set("Accept", "application/json, text/plain, */*") req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") diff --git a/internal/service/duplicate.go b/internal/service/duplicate.go index 1b545b4..d26cb5d 100644 --- a/internal/service/duplicate.go +++ b/internal/service/duplicate.go @@ -1,6 +1,6 @@ // Package service — duplicate-file finder. // -// DuplicateService computes a sparse-sample MD5 (head + middle + tail, +// DuplicateService computes a sparse-sample SHA-256 (head + middle + tail, // 1 MiB each, plus the file size to break collisions) for every media // file and groups identical hashes into "duplicate sets". The first row // (preferring scraped + larger files) is kept as the primary; the rest @@ -13,7 +13,7 @@ package service import ( "context" - "crypto/md5" + "crypto/sha256" "encoding/hex" "errors" "fmt" @@ -250,14 +250,14 @@ func pickPrimary(group []model.Media) model.Media { return group[0] } -// SparseFileHash computes the head+mid+tail MD5 of a file, suffixed with +// SparseFileHash computes the head+mid+tail SHA-256 of a file, suffixed with // the file size so two files that happen to collide on the sample window // but differ in length are still distinguishable. func SparseFileHash(path string) (string, error) { if path == "" { return "", errors.New("empty path") } - f, err := os.Open(path) + f, err := os.Open(path) // #nosec G304 -- path is selected from configured media library files for duplicate detection. if err != nil { return "", err } @@ -267,7 +267,7 @@ func SparseFileHash(path string) (string, error) { return "", err } size := st.Size() - h := md5.New() + h := sha256.New() if size <= int64(sampleSize)*3 { if _, err := io.Copy(h, f); err != nil { return "", err diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index 702c5b5..620d122 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -13,7 +13,7 @@ package service import ( "context" - "crypto/sha1" + "crypto/sha256" "encoding/hex" "errors" "fmt" @@ -42,6 +42,11 @@ const embyServerID = "mediastation-go-001" // the login handshake, even when the API shape is compatible enough for us. const embyCompatVersion = "4.8.10.0" +const ( + embyLocalAuthenticationProviderID = "Emby.Server.Implementations.LocalAuthenticationProvider" // #nosec G101 -- Emby provider identifier, not a credential. + embyLocalPasswordResetProviderID = "Emby.Server.Implementations.LocalPasswordResetProvider" // #nosec G101 -- Emby provider identifier, not a credential. +) + // PlaybackDirectOnlySettingKey 控制「客户端直连解码」模式:开启后宿主机 // 不再提供转码,所有播放交给第三方客户端本地解码(direct play / 302 直链), // 以释放宿主机 CPU 资源。 @@ -181,8 +186,8 @@ func (e *EmbyService) userPayload(u *model.User) map[string]any { "EnableAllChannels": true, "EnableAllFolders": true, "EnableAllDevices": true, - "AuthenticationProviderId": "Emby.Server.Implementations.LocalAuthenticationProvider", - "PasswordResetProviderId": "Emby.Server.Implementations.LocalPasswordResetProvider", + "AuthenticationProviderId": embyLocalAuthenticationProviderID, + "PasswordResetProviderId": embyLocalPasswordResetProviderID, }, } } @@ -1209,7 +1214,7 @@ func inferSeriesNameFromPath(path string) string { } func stableEmbyID(prefix string, parts ...string) string { - h := sha1.New() + h := sha256.New() for _, part := range parts { _, _ = h.Write([]byte(strings.ToLower(strings.TrimSpace(part)))) _, _ = h.Write([]byte{0}) diff --git a/internal/service/ffmpeg_auto_install.go b/internal/service/ffmpeg_auto_install.go index 926c207..59ed9b2 100644 --- a/internal/service/ffmpeg_auto_install.go +++ b/internal/service/ffmpeg_auto_install.go @@ -1,6 +1,7 @@ package service import ( + "archive/zip" "bytes" "context" "fmt" @@ -18,6 +19,11 @@ 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) { @@ -95,7 +101,7 @@ func downloadFFmpegWindows(log *zap.Logger, installDir string) (bool, error) { log.Info("开始下载 ffmpeg...") // 创建安装目录 - if err := os.MkdirAll(installDir, 0755); err != nil { + if err := os.MkdirAll(installDir, 0o750); err != nil { return false, fmt.Errorf("创建安装目录失败: %w", err) } @@ -175,7 +181,7 @@ func downloadFile(log *zap.Logger, url, filepath string) error { return fmt.Errorf("下载失败,HTTP 状态码: %d", resp.StatusCode) } - out, err := os.Create(filepath) + out, err := os.Create(filepath) // #nosec G304 -- filepath is generated by the installer under its temporary download directory. if err != nil { return err } @@ -185,19 +191,98 @@ func downloadFile(log *zap.Logger, url, filepath string) error { return err } -// unzip 解压 ZIP 文件 (简化版,实际应该使用 archive/zip) +// unzip 解压 ZIP 文件。 func unzip(log *zap.Logger, zipPath, destDir string) error { - // Windows 使用 PowerShell 解压 - if runtime.GOOS == "windows" { - if err := os.MkdirAll(destDir, 0755); err != nil { + 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 } - cmd := exec.Command("powershell", "-Command", fmt.Sprintf("Expand-Archive -Path '%s' -DestinationPath '%s' -Force", zipPath, destDir)) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() } - return fmt.Errorf("不支持的操作系统") + 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) { @@ -268,17 +353,17 @@ func copyTree(srcPath, dstPath string) error { return nil } - in, err := os.Open(srcPath) + 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), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(dstPath), 0o750); err != nil { return err } - out, err := os.Create(dstPath) + out, err := os.Create(dstPath) // #nosec G304 -- dstPath is generated under the configured ffmpeg install directory. if err != nil { return err } diff --git a/internal/service/ffprobe.go b/internal/service/ffprobe.go index bb75287..486d025 100644 --- a/internal/service/ffprobe.go +++ b/internal/service/ffprobe.go @@ -83,7 +83,7 @@ func (f *FFprobeService) Probe(ctx context.Context, path string) (*ProbeResult, probeCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - cmd := exec.CommandContext(probeCtx, bin, + cmd := exec.CommandContext(probeCtx, bin, // #nosec G204 -- bin is resolved by resolveLocalExecutable before execution. "-v", "error", "-print_format", "json", "-show_format", diff --git a/internal/service/filemanager.go b/internal/service/filemanager.go index 2fc30d2..3209dab 100644 --- a/internal/service/filemanager.go +++ b/internal/service/filemanager.go @@ -145,7 +145,7 @@ func (s *FileManagerService) CreateFolder(parent, name string) (*FileOperationRe if !s.withinAllowed(dst, roots) { return nil, ErrPathOutOfBounds } - if err := os.MkdirAll(dst, 0o755); err != nil { + if err := os.MkdirAll(dst, 0o755); err != nil { // #nosec G301 -- user-created media directories must remain readable by NAS/player users. return nil, err } return &FileOperationResult{Path: dst}, nil diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go index b95d1c4..75f8c2a 100644 --- a/internal/service/image_proxy.go +++ b/internal/service/image_proxy.go @@ -15,7 +15,7 @@ package service import ( "bytes" "context" - "crypto/sha1" + "crypto/sha256" "encoding/hex" "errors" "io" @@ -241,14 +241,14 @@ func (p *ImageProxy) cloudImageCachePaths(stableKey string) (string, string, str if stableKey == "" { stableKey = "unknown" } - sum := sha1.Sum([]byte("cloud-image:" + stableKey)) + sum := sha256.Sum256([]byte("cloud-image:" + stableKey)) key := "cloud-" + hex.EncodeToString(sum[:]) cachePath := filepath.Join(p.cacheDir, key) return key, cachePath, cachePath + ".fail" } func serveCachedImageFile(w http.ResponseWriter, r *http.Request, key, cachePath string) bool { - data, err := os.ReadFile(cachePath) + data, err := os.ReadFile(cachePath) // #nosec G304 -- cachePath is derived from a SHA-256 cache key under the configured cache directory. if err != nil || len(data) == 0 { return false } @@ -342,14 +342,14 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.R } host := strings.ToLower(u.Host) - // Cache key = sha1(url) - sum := sha1.Sum([]byte(raw)) + // Cache key = sha256(url) + sum := sha256.Sum256([]byte(raw)) key := hex.EncodeToString(sum[:]) cachePath := filepath.Join(p.cacheDir, key) failPath := cachePath + ".fail" // Cache hit. - if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { + if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { // #nosec G304 -- cachePath is derived from a SHA-256 cache key under the configured cache directory. w.Header().Set("Content-Type", detectContentType(data)) w.Header().Set("Cache-Control", imageBrowserCacheControl) stat, _ := os.Stat(cachePath) @@ -368,7 +368,7 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.R } // Cache miss → fetch upstream. - if err := os.MkdirAll(p.cacheDir, 0o755); err != nil { + if err := os.MkdirAll(p.cacheDir, 0o750); err != nil { p.log.Warn("imageproxy: mkdir failed", zap.String("dir", p.cacheDir), zap.Error(err)) servePlaceholder(w) return nil @@ -417,14 +417,14 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.R tmp, tmpErr := os.CreateTemp(p.cacheDir, "img-*.tmp") if tmpErr == nil { if _, werr := tmp.Write(data); werr == nil { - tmp.Close() + _ = tmp.Close() if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil { _ = os.Remove(tmp.Name()) } else { _ = os.Remove(failPath) } } else { - tmp.Close() + _ = tmp.Close() _ = os.Remove(tmp.Name()) } } @@ -502,7 +502,7 @@ func (p *ImageProxy) PrefetchCloudResolved(ctx context.Context, stableKey string } func (p *ImageProxy) fetchAndCacheCloudImage(ctx context.Context, stableKey string, link *cloud.DirectLink, userAgent string) ([]byte, string, error) { - if err := os.MkdirAll(p.cacheDir, 0o755); err != nil { + if err := os.MkdirAll(p.cacheDir, 0o750); err != nil { return nil, "", err } _, cachePath, failPath := p.cloudImageCachePaths(stableKey) @@ -547,14 +547,14 @@ func (p *ImageProxy) fetchAndCacheCloudImage(ctx context.Context, stableKey stri tmp, tmpErr := os.CreateTemp(p.cacheDir, "img-cloud-*.tmp") if tmpErr == nil { if _, werr := tmp.Write(data); werr == nil { - tmp.Close() + _ = tmp.Close() if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil { _ = os.Remove(tmp.Name()) } else { _ = os.Remove(failPath) } } else { - tmp.Close() + _ = tmp.Close() _ = os.Remove(tmp.Name()) } } @@ -568,12 +568,12 @@ func (p *ImageProxy) fetchAndCacheCloudImage(ctx context.Context, stableKey stri } func (p *ImageProxy) markImageFetchFailed(failPath string) { - if err := os.MkdirAll(filepath.Dir(failPath), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(failPath), 0o750); err != nil { return } p.mu.Lock() defer p.mu.Unlock() - _ = os.WriteFile(failPath, []byte(time.Now().Format(time.RFC3339Nano)), 0o644) + _ = os.WriteFile(failPath, []byte(time.Now().Format(time.RFC3339Nano)), 0o600) } // Fetch 拉取远程图片并返回字节和 Content-Type(带缓存)。 @@ -584,16 +584,16 @@ func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, err } // Cache lookup - sum := sha1.Sum([]byte(raw)) + sum := sha256.Sum256([]byte(raw)) key := hex.EncodeToString(sum[:]) cachePath := filepath.Join(p.cacheDir, key) - if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { + if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { // #nosec G304 -- cachePath is derived from a SHA-256 cache key under the configured cache directory. return data, detectContentType(data), nil } // Fetch upstream - if err := os.MkdirAll(p.cacheDir, 0o755); err != nil { + if err := os.MkdirAll(p.cacheDir, 0o750); err != nil { return nil, "", err } @@ -622,12 +622,12 @@ func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, err tmp, terr := os.CreateTemp(p.cacheDir, "img-*.tmp") if terr == nil { if _, werr := tmp.Write(data); werr == nil { - tmp.Close() + _ = tmp.Close() if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil { _ = os.Remove(tmp.Name()) } } else { - tmp.Close() + _ = tmp.Close() _ = os.Remove(tmp.Name()) } } diff --git a/internal/service/local_metadata.go b/internal/service/local_metadata.go index 5a3f396..43a48a0 100644 --- a/internal/service/local_metadata.go +++ b/internal/service/local_metadata.go @@ -202,7 +202,7 @@ func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) { } func readNFO(path string) (*nfoDocument, string, error) { - body, err := os.ReadFile(path) + body, err := os.ReadFile(path) // #nosec G304 -- path is a discovered NFO sidecar under the configured library root. if err != nil { return nil, "", err } @@ -647,7 +647,7 @@ func isRejectedPosterName(name string) bool { } func likelyPosterImage(path string) bool { - file, err := os.Open(path) + file, err := os.Open(path) // #nosec G304 -- path is a discovered artwork sidecar under the configured library root. if err != nil { return false } diff --git a/internal/service/media_tools.go b/internal/service/media_tools.go index d440dd9..3a7b9d4 100644 --- a/internal/service/media_tools.go +++ b/internal/service/media_tools.go @@ -110,7 +110,7 @@ func localExecutableCandidates(name string) []string { func commandOutput(ctx context.Context, timeout time.Duration, name string, args ...string) ([]byte, error) { cmdCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - cmd := exec.CommandContext(cmdCtx, name, args...) + cmd := exec.CommandContext(cmdCtx, name, args...) // #nosec G204 -- callers pass paths resolved by resolveLocalExecutable. out, err := cmd.CombinedOutput() if cmdCtx.Err() != nil { return out, cmdCtx.Err() diff --git a/internal/service/nfo.go b/internal/service/nfo.go index cc5b3c3..13feda1 100644 --- a/internal/service/nfo.go +++ b/internal/service/nfo.go @@ -4,8 +4,8 @@ // source video. We export a minimal subset that those scrapers consume // happily: // -// movie.mkv -> movie.nfo (...) -// tvshow/ -> tvshow.nfo (...) [future] +// movie.mkv -> movie.nfo (...) +// tvshow/ -> tvshow.nfo (...) [future] // // Today only the per-movie writer is implemented; the per-show / per-episode // writers are stubbed with TODO markers. @@ -168,7 +168,7 @@ func WriteMediaNFO(m *model.Media) (string, error) { return "", err } dst := nfoPath(m.Path) - if err := os.WriteFile(dst, []byte(xml.Header+string(out)+"\n"), 0o644); err != nil { + if err := os.WriteFile(dst, []byte(xml.Header+string(out)+"\n"), 0o644); err != nil { // #nosec G306 -- NFO sidecars must remain readable by media players. return "", err } return dst, nil diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 3e426d0..9a43ea0 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -162,7 +162,7 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID } // Create directories. - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users. return "", err } @@ -360,19 +360,19 @@ func moveFile(src, dst string) error { // Cross-device: stream copy → remove. This can temporarily consume the // destination file size while copying, but the source is removed after the // copy succeeds. - in, err := os.Open(src) + in, err := os.Open(src) // #nosec G304 -- src is selected from configured media/download roots by the organizer. if err != nil { return err } defer in.Close() // O_EXCL 保证不会覆盖已存在的目标。 - f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) // #nosec G304,G302 -- dst is organizer-generated; media files must remain readable by local players. if err != nil { return err } if _, werr := io.Copy(f, in); werr != nil { - f.Close() - os.Remove(dst) + _ = f.Close() + _ = os.Remove(dst) return werr } if cerr := f.Close(); cerr != nil { @@ -398,7 +398,7 @@ func transferSidecarNFO(srcMedia, dstMedia string, mode TransferMode) error { if _, err := os.Stat(dst); err == nil { return nil } - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { // #nosec G301 -- sidecar media directories must remain readable by NAS/player users. return err } return transferFile(src, dst, mode) diff --git a/internal/service/organizer_directory.go b/internal/service/organizer_directory.go index f6e0add..2ba6c5c 100644 --- a/internal/service/organizer_directory.go +++ b/internal/service/organizer_directory.go @@ -294,7 +294,7 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo res.Organized++ return nil } - if err := os.MkdirAll(destDir, 0o755); err != nil { + if err := os.MkdirAll(destDir, 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users. return err } if _, err := os.Stat(dst); err == nil { @@ -591,7 +591,7 @@ func (o *OrganizerService) replaceVersions(ctx context.Context, src string, exis _ = o.repo.DB.WithContext(ctx).Where("path = ?", e).Delete(&model.Media{}).Error } } - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users. return err } if err := transferFile(src, dst, mode); err != nil { diff --git a/internal/service/qbittorrent.go b/internal/service/qbittorrent.go index 48f5b4c..e38b5e4 100644 --- a/internal/service/qbittorrent.go +++ b/internal/service/qbittorrent.go @@ -16,7 +16,7 @@ package service import ( "bytes" "context" - "crypto/sha1" + "crypto/sha1" // #nosec G505 -- BitTorrent v1 info-hash is SHA-1 by protocol. "encoding/hex" "encoding/json" "errors" @@ -335,7 +335,7 @@ func torrentInfoHash(data []byte) string { if !ok { return "" } - sum := sha1.Sum(data[start:end]) + sum := sha1.Sum(data[start:end]) // #nosec G401 -- BitTorrent v1 info-hash is SHA-1 by protocol, not a security hash. return hex.EncodeToString(sum[:]) } diff --git a/internal/service/scanner.go b/internal/service/scanner.go index 9cbd07b..9b49cde 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -664,11 +664,7 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto // Online enrichment is opt-in. Local NFO is always consumed first during // the scan, and matched rows are excluded from EnrichLibrary's pending set. if autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) { - go func(libID string) { - if _, err := s.scraper.EnrichLibrary(context.Background(), libID); err != nil { - s.log.Warn("scraper enrich failed", zap.Error(err)) - } - }(lib.ID) + s.startAutoScrape(ctx, lib.ID) } return res, nil } @@ -872,15 +868,21 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar }) s.maybeGenerateSTRMAfterScan(lib.ID) if autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) { - go func(libID string) { - if _, err := s.scraper.EnrichLibrary(context.Background(), libID); err != nil { - s.log.Warn("scraper enrich failed", zap.Error(err)) - } - }(lib.ID) + s.startAutoScrape(ctx, lib.ID) } return res, nil } +func (s *ScannerService) startAutoScrape(ctx context.Context, libraryID string) { + scrapeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Minute) + go func() { + defer cancel() + if _, err := s.scraper.EnrichLibrary(scrapeCtx, libraryID); err != nil { + s.log.Warn("scraper enrich failed", zap.Error(err)) + } + }() +} + func (s *ScannerService) existingCloudMediaPaths(ctx context.Context, libraryID string) (map[string]struct{}, error) { var rows []struct { Path string @@ -1299,7 +1301,7 @@ func (s *ScannerService) resolveCloudSTRMTarget(ctx context.Context, typ, ref st } func readLocalSTRMTarget(path string) (string, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- path is a discovered .strm file under the configured library root. if err != nil { return "", err } diff --git a/internal/service/scraper.go b/internal/service/scraper.go index f0843b5..a95d1d5 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -13,7 +13,6 @@ package service import ( "context" - "math/rand" "path/filepath" "regexp" "strconv" @@ -688,7 +687,7 @@ func (s *ScraperService) scrapeDelay(ctx context.Context) time.Duration { if maxMS == minMS { return time.Duration(minMS) * time.Millisecond } - return time.Duration(minMS+rand.Intn(maxMS-minMS+1)) * time.Millisecond + return time.Duration(minMS+secureRandomIntn(maxMS-minMS+1)) * time.Millisecond } func (s *ScraperService) scrapeDelaySetting(ctx context.Context, key string, fallback int) int { diff --git a/internal/service/security_helpers.go b/internal/service/security_helpers.go new file mode 100644 index 0000000..d0f3f41 --- /dev/null +++ b/internal/service/security_helpers.go @@ -0,0 +1,44 @@ +package service + +import ( + "crypto/rand" + "crypto/sha256" + "math/big" + "strconv" + "time" +) + +func secureRandomBytes(buf []byte) { + if len(buf) == 0 { + return + } + if _, err := rand.Read(buf); err == nil { + return + } + seed := sha256.Sum256([]byte(time.Now().Format(time.RFC3339Nano))) + for offset := 0; offset < len(buf); { + offset += copy(buf[offset:], seed[:]) + seed = sha256.Sum256(seed[:]) + } +} + +func secureRandomIntn(max int) int { + if max <= 0 { + return 0 + } + n, err := rand.Int(rand.Reader, big.NewInt(int64(max))) + if err == nil { + value, convErr := strconv.Atoi(n.String()) + if convErr == nil { + return value + } + } + seed := sha256.Sum256([]byte(time.Now().Format(time.RFC3339Nano))) + value := new(big.Int).SetBytes(seed[:]) + value.Mod(value, big.NewInt(int64(max))) + fallback, convErr := strconv.Atoi(value.String()) + if convErr != nil { + return 0 + } + return fallback +} diff --git a/internal/service/security_safety_test.go b/internal/service/security_safety_test.go new file mode 100644 index 0000000..921a36e --- /dev/null +++ b/internal/service/security_safety_test.go @@ -0,0 +1,59 @@ +package service + +import ( + "path/filepath" + "testing" + + "github.com/ShukeBta/MediaStationGo/internal/config" +) + +func TestValidateSTRMProxyURLBlocksPrivateTargets(t *testing.T) { + blocked := []string{ + "http://127.0.0.1/video.mkv", + "http://192.168.1.2/video.mkv", + "http://169.254.169.254/latest/meta-data", + "file:///etc/passwd", + } + for _, raw := range blocked { + if _, err := validateSTRMProxyURL(raw); err == nil { + t.Fatalf("validateSTRMProxyURL(%q) allowed unsafe target", raw) + } + } +} + +func TestValidateSTRMProxyURLAllowsPublicHTTP(t *testing.T) { + for _, raw := range []string{"https://example.com/video.mkv", "http://8.8.8.8/video.mkv"} { + if _, err := validateSTRMProxyURL(raw); err != nil { + t.Fatalf("validateSTRMProxyURL(%q) = %v, want nil", raw, err) + } + } +} + +func TestBackupFilePathRejectsTraversal(t *testing.T) { + svc := &BackupService{cfg: &config.Config{}} + svc.cfg.App.DataDir = t.TempDir() + for _, name := range []string{"../evil.db", `..\evil.db`, "nested/evil.db", "evil.sqlite"} { + if _, err := svc.backupFilePath(name); err == nil { + t.Fatalf("backupFilePath(%q) allowed traversal or non-backup file", name) + } + } + path, err := svc.backupFilePath("mediastation_20260611_010203.db") + if err != nil { + t.Fatalf("backupFilePath(valid) = %v", err) + } + if filepath.Dir(path) != filepath.Join(svc.cfg.App.DataDir, "backups") { + t.Fatalf("backupFilePath(valid) dir = %q", filepath.Dir(path)) + } +} + +func TestSafeZipTargetRejectsZipSlip(t *testing.T) { + root := t.TempDir() + for _, name := range []string{"../evil.exe", `..\evil.exe`, "/tmp/evil.exe"} { + if _, err := safeZipTarget(root, name); err == nil { + t.Fatalf("safeZipTarget(%q) allowed zip-slip path", name) + } + } + if _, err := safeZipTarget(root, "ffmpeg/bin/ffmpeg.exe"); err != nil { + t.Fatalf("safeZipTarget(valid) = %v", err) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 195b842..91e22ce 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -249,6 +249,9 @@ func (c *Container) Boot() { // 启动调度器定时任务 c.Scheduler.Start(c.stopCtx) + // 自动扫描云盘媒体库,使内容对所有用户立即可见 + c.BootCloudLibraries(c.stopCtx) + // 账号删号/保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。 // 每天触发一次评估;规则里的窗口可随机,不固定。 if c.Device != nil { @@ -335,3 +338,4 @@ func (c *Container) Close() { // unused guard var _ = time.Now + diff --git a/internal/service/storage_upload.go b/internal/service/storage_upload.go index ca82ec2..7d28584 100644 --- a/internal/service/storage_upload.go +++ b/internal/service/storage_upload.go @@ -301,7 +301,7 @@ func (a *alistUploader) exists(ctx context.Context, remotePath string) (bool, er } func (a *alistUploader) upload(ctx context.Context, localPath, remotePath string, size int64) error { - f, err := os.Open(localPath) + f, err := os.Open(localPath) // #nosec G304 -- localPath is selected from configured local media files before upload. if err != nil { return err } @@ -418,7 +418,7 @@ func (w *webDAVUploader) exists(ctx context.Context, remotePath string) (bool, e } func (w *webDAVUploader) upload(ctx context.Context, localPath, remotePath string, size int64) error { - f, err := os.Open(localPath) + f, err := os.Open(localPath) // #nosec G304 -- localPath is selected from configured local media files before upload. if err != nil { return err } diff --git a/internal/service/stream.go b/internal/service/stream.go index 65365e5..0fdc537 100644 --- a/internal/service/stream.go +++ b/internal/service/stream.go @@ -245,7 +245,7 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, return errors.New("hls playlist not ready") } playlist := s.transcoder.PlaylistPath(mediaID) - f, err := os.Open(playlist) + f, err := os.Open(playlist) // #nosec G304 -- playlist path is generated under the transcoder cache directory for this media ID. if err != nil { return err } @@ -304,10 +304,10 @@ func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request, return err } dir, _ := filepath.Abs(s.transcoder.HLSDir(mediaID)) - if !strings.HasPrefix(abs, dir) { + if !pathWithin(abs, dir) { return errors.New("path escape") } - f, err := os.Open(abs) + f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the HLS cache directory with pathWithin. if err != nil { return err } diff --git a/internal/service/strm_svc.go b/internal/service/strm_svc.go index 7fdfe78..deba942 100644 --- a/internal/service/strm_svc.go +++ b/internal/service/strm_svc.go @@ -102,7 +102,7 @@ func (s *STRMService) GenerateForLibrary(ctx context.Context, opts GenerateSTRMO _ = s.repo.Setting.Set(ctx, "strm.auto_generate_enabled", strconv.FormatBool(opts.Enabled)) _ = s.repo.Setting.Set(ctx, "strm.output_dir", outputDir) } - if err := os.MkdirAll(outputDir, 0o755); err != nil { + if err := os.MkdirAll(outputDir, 0o755); err != nil { // #nosec G301 -- STRM output directories must stay readable by NAS/player users. return nil, err } @@ -175,12 +175,12 @@ func (s *STRMService) generateOne(ctx context.Context, lib model.Library, media if _, err := os.Stat(filePath); err == nil { action = "updated" } - if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { // #nosec G301 -- STRM output directories must stay readable by NAS/player users. item.Action = "error" item.Reason = err.Error() return item } - if err := os.WriteFile(filePath, []byte(playURL+"\n"), 0o644); err != nil { + if err := os.WriteFile(filePath, []byte(playURL+"\n"), 0o644); err != nil { // #nosec G306 -- STRM files are media sidecars intended to be readable by players. item.Action = "error" item.Reason = err.Error() return item @@ -429,8 +429,13 @@ func (s *STRMService) ProxySTRM(ctx context.Context, id string, req *http.Reques return ErrSTRMProtocolInvalid } + targetURL, err := validateSTRMProxyURL(record.URL) + if err != nil { + return err + } + // 创建代理请求 - proxyReq, err := http.NewRequestWithContext(ctx, req.Method, record.URL, nil) + proxyReq, err := http.NewRequestWithContext(ctx, req.Method, targetURL.String(), nil) if err != nil { return fmt.Errorf("create proxy request: %w", err) } @@ -452,7 +457,7 @@ func (s *STRMService) ProxySTRM(ctx context.Context, id string, req *http.Reques } client := &http.Client{Timeout: 60 * time.Second} - resp, err := client.Do(proxyReq) + resp, err := client.Do(proxyReq) // #nosec G107,G704 -- STRM proxy target is validated by validateSTRMProxyURL before request creation. if err != nil { return fmt.Errorf("proxy request failed: %w", err) } @@ -474,6 +479,22 @@ func (s *STRMService) ProxySTRM(ctx context.Context, id string, req *http.Reques return err } +func validateSTRMProxyURL(raw string) (*url.URL, error) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Scheme == "" || u.Host == "" { + return nil, ErrSTRMURLInvalid + } + switch strings.ToLower(u.Scheme) { + case "http", "https": + default: + return nil, ErrSTRMProtocolInvalid + } + if isPrivateHost(u.Hostname()) { + return nil, ErrSTRMURLInvalid + } + return u, nil +} + // validateSTRM 验证 STRM 记录。 func (s *STRMService) validateSTRM(record *model.STRMRecord) error { if record.Title == "" { diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go index 16179ba..07ffe58 100644 --- a/internal/service/subtitle.go +++ b/internal/service/subtitle.go @@ -6,10 +6,10 @@ // // External-subtitle discovery rules (matching MediaStation Python defaults): // -// 1. Same directory, same basename, different extension. -// 2. Same directory, ".sub/" or "subs/" subdirectory. -// 3. Sibling languages e.g. movie.zh.srt / movie.en.srt → exposed as -// ?lang=zh / ?lang=en. +// 1. Same directory, same basename, different extension. +// 2. Same directory, ".sub/" or "subs/" subdirectory. +// 3. Sibling languages e.g. movie.zh.srt / movie.en.srt → exposed as +// ?lang=zh / ?lang=en. // // Supported extensions: .srt, .ass, .ssa, .vtt. package service @@ -139,11 +139,11 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W return err } mediaDir, _ := filepath.Abs(filepath.Dir(m.Path)) - if !strings.HasPrefix(abs, mediaDir) { + if !pathWithin(abs, mediaDir) { return fmt.Errorf("path escape") } - f, err := os.Open(abs) + f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the media file directory with pathWithin. if err != nil { return err } diff --git a/internal/service/telegram_api.go b/internal/service/telegram_api.go index bfddde8..3915e4a 100644 --- a/internal/service/telegram_api.go +++ b/internal/service/telegram_api.go @@ -224,7 +224,7 @@ func telegramPostJSONDecode(ctx context.Context, cfg map[string]string, method s continue } respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - resp.Body.Close() + _ = resp.Body.Close() if resp.StatusCode >= 400 { lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody))) continue @@ -257,7 +257,7 @@ func telegramGetJSONDecode(ctx context.Context, cfg map[string]string, method st continue } respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - resp.Body.Close() + _ = resp.Body.Close() if resp.StatusCode >= 400 { lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody))) continue diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index 9145659..fed3c9c 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -518,7 +518,9 @@ func (s *TelegramBotService) cmdStatus(ctx context.Context) (telegramCommandRepl s.mediaStatsQuery(libraryIDs).Count(&mediaCount) var totalSize int64 - s.mediaStatsQuery(libraryIDs).Select("COALESCE(SUM(size_bytes), 0)").Row().Scan(&totalSize) + if err := s.mediaStatsQuery(libraryIDs).Select("COALESCE(SUM(size_bytes), 0)").Row().Scan(&totalSize); err != nil { + return telegramCommandReply{}, err + } totalSizeGB := float64(totalSize) / 1024 / 1024 / 1024 return telegramCommandReply{Text: fmt.Sprintf( @@ -621,7 +623,9 @@ func (s *TelegramBotService) cmdStats(ctx context.Context) (telegramCommandReply s.mediaStatsQuery(libraryIDs).Count(&totalMedia) var totalSize int64 - s.mediaStatsQuery(libraryIDs).Select("COALESCE(SUM(size_bytes), 0)").Row().Scan(&totalSize) + if err := s.mediaStatsQuery(libraryIDs).Select("COALESCE(SUM(size_bytes), 0)").Row().Scan(&totalSize); err != nil { + return telegramCommandReply{}, err + } type LibStat struct { Name string @@ -799,8 +803,10 @@ func (s *TelegramBotService) pollLoop(ctx context.Context, cfg map[string]string continue } go func(u TelegramUpdate) { + handlerCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() raw, _ := json.Marshal(u) - _ = s.HandleWebhook(context.Background(), raw) + _ = s.HandleWebhook(handlerCtx, raw) }(upd) } } @@ -830,7 +836,7 @@ func telegramPollingRequest(ctx context.Context, clients []*http.Client, pollURL continue } respBody, _ := io.ReadAll(resp.Body) - resp.Body.Close() + _ = resp.Body.Close() if resp.StatusCode >= 400 { lastErr = fmt.Errorf("telegram api error %d: %s", resp.StatusCode, sanitizeTelegramText(string(respBody))) continue @@ -965,7 +971,9 @@ func (s *TelegramBotService) findChannelByChatID(ctx context.Context, chatID int configStr = s.crypto.Decrypt(configStr) } var cfg map[string]string - json.Unmarshal([]byte(configStr), &cfg) + if err := json.Unmarshal([]byte(configStr), &cfg); err != nil { + continue + } if cfg["chat_id"] == target || cfg["command_chat_id"] == target || cfg["group_chat_id"] == target || cfg["channel_chat_id"] == target { return &ch diff --git a/internal/service/thetvdb.go b/internal/service/thetvdb.go index 85df523..6940761 100644 --- a/internal/service/thetvdb.go +++ b/internal/service/thetvdb.go @@ -3,9 +3,9 @@ // TheTVDBProvider implements two methods used by the scraper for TV / // anime libraries: // -// Login() -> exchanges secrets.thetvdb_api_key for -// a JWT (cached for 24h). -// SearchSeries(query) -> /search?query=...&type=series +// Login() -> exchanges secrets.thetvdb_api_key for +// a JWT (cached for 24h). +// SearchSeries(query) -> /search?query=...&type=series // // The provider is enabled iff secrets.thetvdb_api_key is non-empty. When // disabled every method returns nil, nil so the scraper orchestrator can @@ -133,7 +133,7 @@ func (t *TheTVDBProvider) SearchSeries(ctx context.Context, query string) (*Matc PosterURL: r.Image, } if len(r.Year) >= 4 { - fmt.Sscanf(r.Year[:4], "%d", &m.Year) + _, _ = fmt.Sscanf(r.Year[:4], "%d", &m.Year) } return m, nil } diff --git a/internal/service/tmdb.go b/internal/service/tmdb.go index 9bc8b54..23a4756 100644 --- a/internal/service/tmdb.go +++ b/internal/service/tmdb.go @@ -197,7 +197,7 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int) m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath } if len(r.ReleaseDate) >= 4 { - fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year) + _, _ = fmt.Sscanf(r.ReleaseDate[:4], "%d", &m.Year) } return m, nil } @@ -263,7 +263,7 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath } if len(r.FirstAirDate) >= 4 { - fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year) + _, _ = fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year) } return m, nil } diff --git a/internal/service/transcoder.go b/internal/service/transcoder.go index 6db8643..13b9051 100644 --- a/internal/service/transcoder.go +++ b/internal/service/transcoder.go @@ -124,7 +124,7 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri } outDir := t.HLSDir(mediaID) - if err := os.MkdirAll(outDir, 0o755); err != nil { + if err := os.MkdirAll(outDir, 0o750); err != nil { t.mu.Unlock() return "", err } @@ -294,7 +294,7 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source s args := buildFFmpegArgs(t.cfg, source, playlist, segments) - cmd := exec.CommandContext(ctx, bin, args...) + cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- bin is resolved by resolveFFmpegPath and args are passed without a shell. cmd.Stderr = os.Stderr t.log.Info("transcode started", diff --git a/internal/service/transfer.go b/internal/service/transfer.go index 9f5ed77..8f47b8f 100644 --- a/internal/service/transfer.go +++ b/internal/service/transfer.go @@ -79,18 +79,18 @@ func transferFile(src, dst string, mode TransferMode) error { // copyFile 流式复制 src 到 dst(保留源文件)。O_EXCL 保证不覆盖已存在目标。 func copyFile(src, dst string) error { - in, err := os.Open(src) + in, err := os.Open(src) // #nosec G304 -- src is selected from configured media/download roots by the organizer. if err != nil { return err } defer in.Close() - f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) // #nosec G304,G302 -- dst is organizer-generated; media files must remain readable by local players. if err != nil { return err } if _, werr := io.Copy(f, in); werr != nil { - f.Close() - os.Remove(dst) + _ = f.Close() + _ = os.Remove(dst) return werr } return f.Close() diff --git a/internal/service/transmission_adp.go b/internal/service/transmission_adp.go index 82a3208..7d8434f 100644 --- a/internal/service/transmission_adp.go +++ b/internal/service/transmission_adp.go @@ -84,7 +84,7 @@ func (a *TransmissionAdapter) pingLocked(ctx context.Context) error { return err } defer resp.Body.Close() - io.Copy(io.Discard, resp.Body) + _, _ = io.Copy(io.Discard, resp.Body) if resp.StatusCode == 409 { // 正常:需要 CSRF token a.sessionID = resp.Header.Get("X-Transmission-Session-Id")