diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 8aaa9c0..a3412ff 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -170,6 +170,14 @@ func main() { return err } app.scheduleCrawlerUploadMigration(ctx, driveID) + // 本地存储开启 .strm 越root后,之前因 strm 指向目录外而失败的封面/ + // 预览/指纹应自动重试,省得用户再手动点三个"重试失败"按钮。 + if d.Kind == localstorage.Kind && + parseBoolDefault(strings.TrimSpace(d.Credentials["strm_allow_outside_root"]), false) { + go app.regenFailedThumbnails(ctx, driveID) + go app.regenFailedPreviews(ctx, driveID) + go app.regenFailedFingerprints(ctx, driveID) + } return nil }, OnDriveDeleteCleanup: func(cleanupCtx context.Context, driveID string) (int, error) { @@ -987,8 +995,9 @@ func (a *App) attachDriveUnlocked(ctx context.Context, d *catalog.Drive) error { }) case localstorage.Kind: drv = localstorage.New(localstorage.Config{ - ID: d.ID, - RootPath: d.Credentials["path"], + ID: d.ID, + RootPath: d.Credentials["path"], + STRMAllowOutsideRoot: parseBoolDefault(strings.TrimSpace(d.Credentials["strm_allow_outside_root"]), false), }) case scriptcrawler.Kind: drv = scriptcrawler.New(scriptcrawler.Config{ diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index ba56b16..b4efe7d 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -470,6 +470,8 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { Spider91Proxy string `json:"spider91Proxy,omitempty"` LastCrawlAt int64 `json:"lastCrawlAt,omitempty"` GoogleDriveUseOnlineAPI *bool `json:"googleDriveUseOnlineAPI,omitempty"` + // STRMAllowOutsideRoot 是 localstorage 的 .strm 越root开关;其它 kind 省略。 + STRMAllowOutsideRoot *bool `json:"strmAllowOutsideRoot,omitempty"` ScanGenerationStatus GenerationStatus `json:"scanGenerationStatus"` ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"` PreviewGenerationStatus GenerationStatus `json:"previewGenerationStatus"` @@ -546,6 +548,7 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { Spider91Proxy: spider91ProxyForDrive(d), LastCrawlAt: lastCrawlAt, GoogleDriveUseOnlineAPI: googleDriveUseOnlineAPIForDrive(d), + STRMAllowOutsideRoot: strmAllowOutsideRootForDrive(d), ScanGenerationStatus: generation.Scan, ThumbnailGenerationStatus: generation.Thumbnail, PreviewGenerationStatus: generation.Preview, @@ -613,8 +616,10 @@ func (a *AdminServer) handleUpsertDrive(w http.ResponseWriter, r *http.Request) return } body.Credentials = credentials - } else if body.Kind == "googledrive" { - body.Credentials = mergeGoogleDriveCredentials(existing, body.Credentials) + } else if body.Kind == "googledrive" || body.Kind == "localstorage" { + // 按键合并、空值沿用旧值:localstorage 编辑表单里 path 留空表示不改, + // 但 strm_allow_outside_root 开关每次都会带值,必须逐键合并而不是整体替换。 + body.Credentials = mergeNonEmptyCredentials(existing, body.Credentials) } else if len(body.Credentials) == 0 && existing != nil && len(existing.Credentials) > 0 { body.Credentials = existing.Credentials } @@ -1350,6 +1355,21 @@ func spider91ProxyForDrive(d *catalog.Drive) string { return strings.TrimSpace(d.Credentials["proxy"]) } +// strmAllowOutsideRootForDrive 返回 localstorage 的 .strm 越root开关; +// 其它 kind 返回 nil(JSON 省略)。未配置时默认 false。 +func strmAllowOutsideRootForDrive(d *catalog.Drive) *bool { + if d == nil || d.Kind != "localstorage" { + return nil + } + result := false + if d.Credentials != nil { + if v, err := strconv.ParseBool(strings.TrimSpace(d.Credentials["strm_allow_outside_root"])); err == nil { + result = v + } + } + return &result +} + func googleDriveUseOnlineAPIForDrive(d *catalog.Drive) *bool { if d == nil || d.Kind != "googledrive" { return nil @@ -1370,7 +1390,10 @@ func googleDriveUseOnlineAPIForDrive(d *catalog.Drive) *bool { return &result } -func mergeGoogleDriveCredentials(existing *catalog.Drive, incoming map[string]string) map[string]string { +// mergeNonEmptyCredentials 逐键合并凭证:incoming 里非空的键覆盖旧值, +// 空值/缺失的键沿用旧值。googledrive 和 localstorage 的编辑表单都依赖 +// 这个语义(留空 = 不修改)。 +func mergeNonEmptyCredentials(existing *catalog.Drive, incoming map[string]string) map[string]string { merged := map[string]string{} if existing != nil { for k, v := range existing.Credentials { diff --git a/backend/internal/drives/localstorage/driver.go b/backend/internal/drives/localstorage/driver.go index a008ee4..fb87172 100644 --- a/backend/internal/drives/localstorage/driver.go +++ b/backend/internal/drives/localstorage/driver.go @@ -23,17 +23,24 @@ const maxSTRMBytes = 64 * 1024 type Config struct { ID string RootPath string + // STRMAllowOutsideRoot 允许 .strm 指向存储根目录之外的本地路径。 + // 默认关闭:strm 等于可以让 /p/stream 读到服务器上的任意文件,只有 + // 管理员明确知道自己在做什么(例如 strm 库与 rclone 挂载目录分离) + // 时才应打开。 + STRMAllowOutsideRoot bool } type Driver struct { - id string - rootPath string + id string + rootPath string + strmAllowOutsideRoot bool } func New(c Config) *Driver { return &Driver{ - id: c.ID, - rootPath: c.RootPath, + id: c.ID, + rootPath: c.RootPath, + strmAllowOutsideRoot: c.STRMAllowOutsideRoot, } } @@ -230,8 +237,8 @@ func (d *Driver) localSTRMLink(strmPath, target string) (*drives.StreamLink, err if err != nil { return nil, err } - if !within { - return nil, errors.New("localstorage: strm target escapes root") + if !within && !d.strmAllowOutsideRoot { + return nil, errors.New("localstorage: strm target escapes root (enable strm_allow_outside_root to allow)") } if strings.EqualFold(filepath.Ext(p), ".strm") || strings.EqualFold(filepath.Ext(realPath), ".strm") { return nil, errors.New("localstorage: nested strm target is not supported") diff --git a/backend/internal/drives/localstorage/driver_test.go b/backend/internal/drives/localstorage/driver_test.go index 8cd6d37..85d91e5 100644 --- a/backend/internal/drives/localstorage/driver_test.go +++ b/backend/internal/drives/localstorage/driver_test.go @@ -195,6 +195,46 @@ func TestStreamURLRejectsSTRMTargetEscapingRootThroughSymlink(t *testing.T) { } } +func TestStreamURLAllowsSTRMTargetOutsideRootWhenEnabled(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + target := filepath.Join(outside, "movie.mp4") + writeLocalStorageTestFile(t, target, []byte("movie-data")) + writeLocalStorageTestFile(t, filepath.Join(root, "movie.strm"), []byte(target+"\n")) + + // 默认关闭:根目录外的目标仍被拒绝 + strict := New(Config{ID: "local", RootPath: root}) + if _, err := strict.StreamURL(context.Background(), encodeRel("movie.strm")); err == nil || !strings.Contains(err.Error(), "strm target escapes root") { + t.Fatalf("default error = %v, want strm target escapes root", err) + } + + // 开启 strm_allow_outside_root 后放行 + relaxed := New(Config{ID: "local", RootPath: root, STRMAllowOutsideRoot: true}) + link, err := relaxed.StreamURL(context.Background(), encodeRel("movie.strm")) + if err != nil { + t.Fatalf("StreamURL with allow-outside-root: %v", err) + } + resolved, err := filepath.EvalSymlinks(target) + if err != nil { + t.Fatalf("eval target: %v", err) + } + if link.URL != resolved { + t.Fatalf("link url = %q, want %q", link.URL, resolved) + } +} + +func TestStreamURLAllowOutsideRootStillRejectsNestedSTRM(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + writeLocalStorageTestFile(t, filepath.Join(outside, "inner.strm"), []byte("http://example.com/v.mp4\n")) + writeLocalStorageTestFile(t, filepath.Join(root, "movie.strm"), []byte(filepath.Join(outside, "inner.strm")+"\n")) + + drv := New(Config{ID: "local", RootPath: root, STRMAllowOutsideRoot: true}) + if _, err := drv.StreamURL(context.Background(), encodeRel("movie.strm")); err == nil || !strings.Contains(err.Error(), "nested strm") { + t.Fatalf("error = %v, want nested strm rejection", err) + } +} + func TestStreamURLRejectsSymlinkFileIDEscapingRoot(t *testing.T) { root := t.TempDir() outside := t.TempDir() diff --git a/src/admin/DrivesPage.tsx b/src/admin/DrivesPage.tsx index 04481e6..2613cb0 100644 --- a/src/admin/DrivesPage.tsx +++ b/src/admin/DrivesPage.tsx @@ -217,6 +217,8 @@ export function DrivesPage() { ? { proxy: d.spider91Proxy ?? "" } : d.kind === "googledrive" ? { use_online_api: (d.googleDriveUseOnlineAPI ?? true) ? "true" : "false" } + : d.kind === "localstorage" + ? { strm_allow_outside_root: (d.strmAllowOutsideRoot ?? false) ? "true" : "false" } : {}, spider91UploadDriveId: settings?.spider91UploadDriveId ?? "", }; diff --git a/src/admin/api.ts b/src/admin/api.ts index 4e7a22f..3556a61 100644 --- a/src/admin/api.ts +++ b/src/admin/api.ts @@ -98,6 +98,8 @@ export type AdminDrive = { spider91Proxy?: string; // Google Drive 是否使用 OpenList 在线续期 API;未配置时后端按 true 返回。 googleDriveUseOnlineAPI?: boolean; + // localstorage 的 .strm 是否允许指向存储根目录之外;未配置时后端按 false 返回。 + strmAllowOutsideRoot?: boolean; scanGenerationStatus?: DriveGenerationStatus; thumbnailGenerationStatus?: DriveGenerationStatus; previewGenerationStatus?: DriveGenerationStatus; diff --git a/src/admin/drive/constants.ts b/src/admin/drive/constants.ts index 72c6393..572f34a 100644 --- a/src/admin/drive/constants.ts +++ b/src/admin/drive/constants.ts @@ -162,7 +162,7 @@ export function credentialHelp(kind: Kind, isEdit: boolean): string { ? "请参考OpenList文档中关于谷歌云盘的配置方法;如不修改凭证,留空即可,保存时会沿用旧值" : "请参考OpenList文档中关于谷歌云盘的配置方法"; case "localstorage": - return `填写服务器可访问的本地目录绝对路径,例如 /mnt/videos。系统会扫描该目录及子目录中的视频文件和 .strm 文件;.strm 可指向 HTTP/HTTPS 直链,或指向本地存储根目录内的真实视频路径。Docker 部署时请填写容器内路径。${note}`; + return `填写服务器可访问的本地目录绝对路径,例如 /mnt/videos。系统会扫描该目录及子目录中的视频文件和 .strm 文件;.strm 可指向 HTTP/HTTPS 直链或本地视频路径(指向目录外需开启下方开关)。Docker 部署时请填写容器内路径。${note}`; case "spider91": return "91Spider 不再支持通过网盘添加或编辑。请到后台爬虫管理页面添加爬虫脚本。"; default: @@ -330,6 +330,18 @@ export function credentialFields(kind: Kind, creds: Record = {}) required: true, help: "路径必须是后端服务器上的已有目录;保存后可手动重扫,系统会递归扫描支持的视频格式。", }, + { + key: "strm_allow_outside_root", + label: ".strm 允许指向目录外", + placeholder: "", + type: "select", + defaultValue: "false", + options: [ + { value: "false", label: "关闭(默认,仅允许目录内路径)" }, + { value: "true", label: "开启(允许任意本地路径)" }, + ], + help: "开启后 .strm 可指向本目录之外的本地文件(如 rclone 挂载点)。注意:等于允许通过 .strm 读取服务器上任意文件,请只在自己完全掌控媒体目录时开启。Docker 部署时路径必须是容器内路径。", + }, ]; case "spider91": return [