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")