mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
feat: add opt-in toggle for local STRM targets outside the storage root
Local .strm files that pointed to a path outside the configured storage root previously failed cover/preview/fingerprint generation and playback with "strm target escapes root", breaking the common layout where the strm library and the real media files (e.g. an rclone mount) live in separate directories (issue #22 follow-up). - localstorage driver gains STRMAllowOutsideRoot; when on, strm targets outside the root are allowed (still resolves symlinks and still rejects nested strm, so no new escape vector). Default off preserves the existing security boundary - Toggle persisted as the strm_allow_outside_root credential; editing a localstorage drive now merges credentials per-key so leaving the path blank keeps the old value while flipping the toggle - Saving a localstorage drive with the toggle on auto-re-enqueues previously-failed thumbnails/previews/fingerprints, so enabling it recovers without manually clicking the three retry buttons - Drives API exposes strmAllowOutsideRoot for form echo-back; admin drive form adds a "允许指向目录外" select with a security warning - Tests cover allow-outside-root on/off and that nested strm stays rejected even when the toggle is on Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string> = {})
|
||||
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 [
|
||||
|
||||
Reference in New Issue
Block a user