mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
fix: auto-scan cloud libraries on boot + allow CORS for media playback
- Add BootCloudLibraries() to auto-scan all cloud libraries on startup - Delay 3s to avoid conflict with system init, scan without auto-scrape - Enable CORS for /api/cloud/play/* and /api/img to support 3rd-party players - Fixes issue where each user triggers separate cloud library scans - Fixes issue where Infuse/Emby apps cannot play cloud resources
This commit is contained in:
+1
-1
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -259,3 +259,4 @@ func extractToken(c *gin.Context) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
+32
-10
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// source video. We export a minimal subset that those scrapers consume
|
||||
// happily:
|
||||
//
|
||||
// movie.mkv -> movie.nfo (<movie>...</movie>)
|
||||
// tvshow/ -> tvshow.nfo (<tvshow>...</tvshow>) [future]
|
||||
// movie.mkv -> movie.nfo (<movie>...</movie>)
|
||||
// tvshow/ -> tvshow.nfo (<tvshow>...</tvshow>) [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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
|
||||
|
||||
+13
-11
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user