Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions docs/task-path-batch-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Task path-prefix batch API

The task API provides batch operations for every task type registered under
`/api/task`:

- `POST /api/task/{type}/delete_by_path`
- `POST /api/task/{type}/cancel_by_path`
- `POST /api/task/{type}/retry_by_path`

Supported task types are `upload`, `copy`, `move`, `offline_download`,
`offline_download_transfer`, `decompress`, and `decompress_upload`.

## Request

```json
{
"path": "/storage/folder"
}
```

`path` is normalized with the same virtual-path rules used by OpenList. Both
forward and backward slashes are accepted, repeated separators are collapsed,
and `.` / `..` segments are cleaned.

A root-wide operation is destructive and therefore requires the explicit input
`/`. If the path after user-base-path resolution is `/`, inputs such as `.`,
`./`, `//`, or other non-empty values that normalize to `/` are rejected with
HTTP 400.

For non-admin users, the normalized path is resolved against the user's base
path. A non-admin operation can only match tasks created by that user. An admin
can match tasks from all users.

## Matching contract

A task matches when either its virtual source path or virtual destination path:

1. equals the normalized request path; or
2. is a descendant of the normalized request path.

Matching is path-segment aware: `/a` matches `/a/file`, but not `/ab/file`.

Upload tasks expose the final destination object path, including the uploaded
file name. Archive-content file and non-in-place directory tasks also include
their object name. An in-place archive directory task represents expansion into
the destination directory itself; its generated child tasks expose their final
object paths.

Local temporary paths and download URLs are not virtual storage paths and are
not considered for source-path matching.

## Operation behavior

### `delete_by_path`

Matches tasks in any state. Terminal tasks and queued tasks that have never
started are removed immediately. Active tasks are first cancelled, then the API
waits for execution to stop and failure cleanup hooks to finish before removing
them from the manager. A task that does not stop before the server-side timeout
remains visible in the manager and is not counted as processed.

### `cancel_by_path`

Matches only non-terminal tasks. Matching tasks are asked to cancel but remain
in the manager so their final state can be inspected.

### `retry_by_path`

Matches only tasks currently in the failed state. Matching tasks are queued for
retry.

## Response

```json
{
"code": 200,
"message": "success",
"data": {
"matched": 125,
"processed": 124
}
}
```

- `matched` is the number of tasks selected by the path, ownership, and state
filters at the start of the operation.
- `processed` is the number of those tasks on which the requested state-checked
operation was successfully applied. For deletion, it is the number confirmed
removed from the manager after any required cancellation and cleanup.

Concurrent task transitions can make `processed` lower than `matched`. Clients
should display both values rather than treating `matched` as a success count.
15 changes: 15 additions & 0 deletions internal/fs/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,21 @@ func (t *ArchiveContentUploadTask) GetName() string {
return fmt.Sprintf("upload %s to [%s](%s)", t.ObjName, t.DstStorageMp, t.DstActualPath)
}

func (t *ArchiveContentUploadTask) GetSrcPath() string {
return ""
}

func (t *ArchiveContentUploadTask) GetDstPath() string {
if t.DstStorageMp == "" {
return ""
}
path := stdpath.Join(t.DstStorageMp, t.DstActualPath)
if t.InPlace {
return path
}
return stdpath.Join(path, t.ObjName)
}

func (t *ArchiveContentUploadTask) GetStatus() string {
return t.status
}
Expand Down
18 changes: 18 additions & 0 deletions internal/fs/other.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package fs

import (
"context"
stdpath "path"

"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
Expand Down Expand Up @@ -62,3 +63,20 @@ type TaskData struct {
func (t *TaskData) GetStatus() string {
return t.Status
}

// GetSrcPath returns the virtual source path for task filtering.
// Local temp paths without a storage mount are excluded.
func (t *TaskData) GetSrcPath() string {
if t.SrcStorageMp == "" {
return ""
}
return stdpath.Join(t.SrcStorageMp, t.SrcActualPath)
}

// GetDstPath returns the virtual destination path for task filtering.
func (t *TaskData) GetDstPath() string {
if t.DstStorageMp == "" {
return ""
}
return stdpath.Join(t.DstStorageMp, t.DstActualPath)
}
11 changes: 11 additions & 0 deletions internal/fs/put.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ func (t *UploadTask) GetName() string {
return fmt.Sprintf("upload %s to [%s](%s)", t.file.GetName(), t.storage.GetStorage().MountPath, t.dstDirActualPath)
}

func (t *UploadTask) GetSrcPath() string {
return ""
}

func (t *UploadTask) GetDstPath() string {
if t.storage == nil || t.file == nil {
return ""
}
return stdpath.Join(t.storage.GetStorage().MountPath, t.dstDirActualPath, t.file.GetName())
}

func (t *UploadTask) GetStatus() string {
return "uploading"
}
Expand Down
97 changes: 97 additions & 0 deletions internal/fs/task_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package fs

import (
"testing"

"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/task"
)

type pathTestDriver struct {
driver.Driver
storage model.Storage
}

func (d *pathTestDriver) GetStorage() *model.Storage { return &d.storage }

type pathTestFile struct {
model.FileStreamer
name string
}

func (f *pathTestFile) GetName() string { return f.name }

func TestTaskDataGetPaths(t *testing.T) {
td := &TaskData{
SrcActualPath: "src/file",
DstActualPath: "dst/dir",
SrcStorageMp: "/srcMount",
DstStorageMp: "/dstMount",
}
if got := td.GetSrcPath(); got != "/srcMount/src/file" {
t.Fatalf("GetSrcPath = %q", got)
}
if got := td.GetDstPath(); got != "/dstMount/dst/dir" {
t.Fatalf("GetDstPath = %q", got)
}

localTemp := &TaskData{
SrcActualPath: "/tmp/local-file",
DstActualPath: "dst",
DstStorageMp: "/cloud",
}
if localTemp.GetSrcPath() != "" {
t.Fatalf("local temp src must be empty for matching, got %q", localTemp.GetSrcPath())
}
if localTemp.GetDstPath() != "/cloud/dst" {
t.Fatalf("GetDstPath = %q", localTemp.GetDstPath())
}
}

func TestArchiveContentUploadTaskGetPaths(t *testing.T) {
t1 := &ArchiveContentUploadTask{
ObjName: "report.txt",
InPlace: false,
DstActualPath: "out",
DstStorageMp: "/mount",
}
if t1.GetSrcPath() != "" {
t.Fatal("src should be empty")
}
if t1.GetDstPath() != "/mount/out/report.txt" {
t.Fatalf("dst = %q", t1.GetDstPath())
}
if !task.MatchTaskPath(t1.GetSrcPath(), t1.GetDstPath(), "/mount/out/report.txt") {
t.Fatal("exact archive upload destination file must match")
}

inPlace := &ArchiveContentUploadTask{
ObjName: "expanded-directory",
InPlace: true,
DstActualPath: "out",
DstStorageMp: "/mount",
}
if inPlace.GetDstPath() != "/mount/out" {
t.Fatalf("in-place directory task dst = %q", inPlace.GetDstPath())
}
}

func TestUploadTaskGetPaths(t *testing.T) {
t1 := &UploadTask{
storage: &pathTestDriver{storage: model.Storage{MountPath: "/mount"}},
dstDirActualPath: "uploads",
file: &pathTestFile{name: "photo.jpg"},
}
if t1.GetDstPath() != "/mount/uploads/photo.jpg" {
t.Fatalf("dst = %q", t1.GetDstPath())
}
if !task.MatchTaskPath(t1.GetSrcPath(), t1.GetDstPath(), "/mount/uploads/photo.jpg") {
t.Fatal("exact upload destination file must match")
}

nilTask := &UploadTask{}
if nilTask.GetSrcPath() != "" || nilTask.GetDstPath() != "" {
t.Fatal("nil storage upload task paths should be empty")
}
}
8 changes: 8 additions & 0 deletions internal/offline_download/tool/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,14 @@ func (t *DownloadTask) GetName() string {
return fmt.Sprintf("download %s to (%s)", t.Url, t.DstDirPath)
}

func (t *DownloadTask) GetSrcPath() string {
return ""
}

func (t *DownloadTask) GetDstPath() string {
return t.DstDirPath
}

func (t *DownloadTask) GetStatus() string {
return t.Status
}
Expand Down
33 changes: 33 additions & 0 deletions internal/offline_download/tool/task_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package tool

import "testing"

func TestDownloadTaskGetPaths(t *testing.T) {
tk := &DownloadTask{DstDirPath: "/cloud/dl"}
if tk.GetSrcPath() != "" {
t.Fatal("download src should be empty")
}
if tk.GetDstPath() != "/cloud/dl" {
t.Fatalf("dst = %q", tk.GetDstPath())
}
}

func TestTransferTaskGetPaths(t *testing.T) {
// TransferTask embeds fs.TaskData — local temp src excluded
tk := &TransferTask{}
tk.SrcActualPath = "/tmp/x"
tk.DstActualPath = "dir"
tk.DstStorageMp = "/dst"
if tk.GetSrcPath() != "" {
t.Fatalf("temp src must be empty, got %q", tk.GetSrcPath())
}
if tk.GetDstPath() != "/dst/dir" {
t.Fatalf("dst = %q", tk.GetDstPath())
}

tk.SrcStorageMp = "/src"
tk.SrcActualPath = "a/b"
if tk.GetSrcPath() != "/src/a/b" {
t.Fatalf("src = %q", tk.GetSrcPath())
}
}
26 changes: 26 additions & 0 deletions internal/task/path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package task

import (
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)

// TaskWithPaths is a task that exposes virtual src/dst paths for filtering.
// Empty string means the path side is not applicable for matching.
type TaskWithPaths interface {
TaskExtensionInfo
GetSrcPath() string
GetDstPath() string
}

// MatchTaskPath reports whether src or dst is equal to prefix or under prefix.
// Empty src/dst sides are ignored. prefix/src/dst are cleaned before compare.
func MatchTaskPath(src, dst, prefix string) bool {
prefix = utils.FixAndCleanPath(prefix)
if src != "" && utils.IsSubPath(prefix, utils.FixAndCleanPath(src)) {
return true
}
if dst != "" && utils.IsSubPath(prefix, utils.FixAndCleanPath(dst)) {
return true
}
return false
}
31 changes: 31 additions & 0 deletions internal/task/path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package task

import "testing"

func TestMatchTaskPath(t *testing.T) {
tests := []struct {
name string
src string
dst string
prefix string
want bool
}{
{name: "dst equal", src: "", dst: "/a/b", prefix: "/a/b", want: true},
{name: "dst child", src: "", dst: "/a/b/c", prefix: "/a/b", want: true},
{name: "src child", src: "/a/b/c", dst: "/other", prefix: "/a/b", want: true},
{name: "no false prefix", src: "", dst: "/ab", prefix: "/a", want: false},
{name: "unrelated", src: "/x", dst: "/y", prefix: "/a", want: false},
{name: "empty sides", src: "", dst: "", prefix: "/a", want: false},
{name: "root matches all", src: "/a", dst: "", prefix: "/", want: true},
{name: "backslash cleaned", src: "", dst: "\\a\\b\\c", prefix: "/a/b", want: true},
{name: "relative cleaned", src: "", dst: "a/b/c", prefix: "/a/b", want: true},
{name: "only src empty ignored", src: "", dst: "/keep/me", prefix: "/other", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := MatchTaskPath(tt.src, tt.dst, tt.prefix); got != tt.want {
t.Fatalf("MatchTaskPath(%q,%q,%q)=%v want %v", tt.src, tt.dst, tt.prefix, got, tt.want)
}
})
}
}
Loading