Repository: AlistGo/alist (language: go)

Issue:
希望增加允许general用户管理由自己创建的任务的功能
### Please make sure of the following things

- [X] I have read the [documentation](https://alist.nn.ci).
- [x] I'm sure there are no duplicate issues or discussions.
- [X] I'm sure this feature is not implemented.
- [X] I'm sure it's a reasonable and popular requirement.

### Description of the feature / 需求描述

目前只有admin用户能在“管理”中看到和取消所有离线下载、上传、复制任务，general用户只能在管理中看到个人资料、关于、文档和主页。如果系统有多个用户在使用，则其它general用户无法知道任务进度，也无法中途取消进度，希望增加这一功能。

### Suggested solution / 实现思路

_No response_

### Additional context / 附件

_No response_


Discussion / hints:

Fix patch:
diff --git a/go.mod b/go.mod
index 9b9d859d3fe..f3c5c582064 100644
--- a/go.mod
+++ b/go.mod
@@ -56,7 +56,7 @@ require (
 	github.com/u2takey/ffmpeg-go v0.5.0
 	github.com/upyun/go-sdk/v3 v3.0.4
 	github.com/winfsp/cgofuse v1.5.1-0.20230130140708-f87f5db493b5
-	github.com/xhofe/tache v0.1.2
+	github.com/xhofe/tache v0.1.3
 	github.com/xhofe/wopan-sdk-go v0.1.3
 	github.com/zzzhr1990/go-common-entity v0.0.0-20221216044934-fd1c571e3a22
 	golang.org/x/crypto v0.27.0
diff --git a/go.sum b/go.sum
index f4699bc20b9..90594645c5c 100644
--- a/go.sum
+++ b/go.sum
@@ -512,6 +512,8 @@ github.com/xhofe/gsync v0.0.0-20230917091818-2111ceb38a25 h1:eDfebW/yfq9DtG9RO3K
 github.com/xhofe/gsync v0.0.0-20230917091818-2111ceb38a25/go.mod h1:fH4oNm5F9NfI5dLi0oIMtsLNKQOirUDbEMCIBb/7SU0=
 github.com/xhofe/tache v0.1.2 h1:pHrXlrWcbTb4G7hVUDW7Rc+YTUnLJvnLBrdktVE1Fqg=
 github.com/xhofe/tache v0.1.2/go.mod h1:iKumPFvywf30FRpAHHCt64G0JHLMzT0K+wyGedHsmTQ=
+github.com/xhofe/tache v0.1.3 h1:MipxzlljYX29E1YI/SLC7hVomVF+51iP1OUzlsuq1wE=
+github.com/xhofe/tache v0.1.3/go.mod h1:iKumPFvywf30FRpAHHCt64G0JHLMzT0K+wyGedHsmTQ=
 github.com/xhofe/wopan-sdk-go v0.1.3 h1:J58X6v+n25ewBZjb05pKOr7AWGohb+Rdll4CThGh6+A=
 github.com/xhofe/wopan-sdk-go v0.1.3/go.mod h1:dcY9yA28fnaoZPnXZiVTFSkcd7GnIPTpTIIlfSI5z5Q=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
diff --git a/internal/fs/copy.go b/internal/fs/copy.go
index 38407c9a863..d4ad452b169 100644
--- a/internal/fs/copy.go
+++ b/internal/fs/copy.go
@@ -11,13 +11,14 @@ import (
 	"github.com/alist-org/alist/v3/internal/model"
 	"github.com/alist-org/alist/v3/internal/op"
 	"github.com/alist-org/alist/v3/internal/stream"
+	"github.com/alist-org/alist/v3/internal/task"
 	"github.com/alist-org/alist/v3/pkg/utils"
 	"github.com/pkg/errors"
 	"github.com/xhofe/tache"
 )
 
 type CopyTask struct {
-	tache.Base
+	task.TaskWithCreator
 	Status       string        `json:"-"` //don't save status to save space
 	SrcObjPath   string        `json:"src_path"`
 	DstDirPath   string        `json:"dst_path"`
@@ -53,7 +54,7 @@ var CopyTaskManager *tache.Manager[*CopyTask]
 
 // Copy if in the same storage, call move method
 // if not, add copy task
-func _copy(ctx context.Context, srcObjPath, dstDirPath string, lazyCache ...bool) (tache.TaskWithInfo, error) {
+func _copy(ctx context.Context, srcObjPath, dstDirPath string, lazyCache ...bool) (task.TaskInfoWithCreator, error) {
 	srcStorage, srcObjActualPath, err := op.GetStorageAndActualPath(srcObjPath)
 	if err != nil {
 		return nil, errors.WithMessage(err, "failed get src storage")
@@ -92,7 +93,11 @@ func _copy(ctx context.Context, srcObjPath, dstDirPath string, lazyCache ...bool
 		}
 	}
 	// not in the same storage
+	taskCreator, _ := ctx.Value("user").(*model.User) // taskCreator is nil when convert failed
 	t := &CopyTask{
+		TaskWithCreator: task.TaskWithCreator{
+			Creator: taskCreator,
+		},
 		srcStorage:   srcStorage,
 		dstStorage:   dstStorage,
 		SrcObjPath:   srcObjActualPath,
@@ -123,6 +128,9 @@ func copyBetween2Storages(t *CopyTask, srcStorage, dstStorage driver.Driver, src
 			srcObjPath := stdpath.Join(srcObjPath, obj.GetName())
 			dstObjPath := stdpath.Join(dstDirPath, srcObj.GetName())
 			CopyTaskManager.Add(&CopyTask{
+				TaskWithCreator: task.TaskWithCreator{
+					Creator: t.Creator,
+				},
 				srcStorage:   srcStorage,
 				dstStorage:   dstStorage,
 				SrcObjPath:   srcObjPath,
diff --git a/internal/fs/fs.go b/internal/fs/fs.go
index 23e8a87a6fd..65e5a2c264a 100644
--- a/internal/fs/fs.go
+++ b/internal/fs/fs.go
@@ -5,8 +5,8 @@ import (
 	"github.com/alist-org/alist/v3/internal/driver"
 	"github.com/alist-org/alist/v3/internal/model"
 	"github.com/alist-org/alist/v3/internal/op"
+	"github.com/alist-org/alist/v3/internal/task"
 	log "github.com/sirupsen/logrus"
-	"github.com/xhofe/tache"
 )
 
 // the param named path of functions in this package is a mount path
@@ -69,7 +69,7 @@ func Move(ctx context.Context, srcPath, dstDirPath string, lazyCache ...bool) er
 	return err
 }
 
-func Copy(ctx context.Context, srcObjPath, dstDirPath string, lazyCache ...bool) (tache.TaskWithInfo, error) {
+func Copy(ctx context.Context, srcObjPath, dstDirPath string, lazyCache ...bool) (task.TaskInfoWithCreator, error) {
 	res, err := _copy(ctx, srcObjPath, dstDirPath, lazyCache...)
 	if err != nil {
 		log.Errorf("failed copy %s to %s: %+v", srcObjPath, dstDirPath, err)
@@ -101,8 +101,8 @@ func PutDirectly(ctx context.Context, dstDirPath string, file model.FileStreamer
 	return err
 }
 
-func PutAsTask(dstDirPath string, file model.FileStreamer) (tache.TaskWithInfo, error) {
-	t, err := putAsTask(dstDirPath, file)
+func PutAsTask(ctx context.Context, dstDirPath string, file model.FileStreamer) (task.TaskInfoWithCreator, error) {
+	t, err := putAsTask(ctx, dstDirPath, file)
 	if err != nil {
 		log.Errorf("failed put %s: %+v", dstDirPath, err)
 	}
diff --git a/internal/fs/put.go b/internal/fs/put.go
index 807b15e07d6..23197f5ba54 100644
--- a/internal/fs/put.go
+++ b/internal/fs/put.go
@@ -7,12 +7,13 @@ import (
 	"github.com/alist-org/alist/v3/internal/errs"
 	"github.com/alist-org/alist/v3/internal/model"
 	"github.com/alist-org/alist/v3/internal/op"
+	"github.com/alist-org/alist/v3/internal/task"
 	"github.com/pkg/errors"
 	"github.com/xhofe/tache"
 )
 
 type UploadTask struct {
-	tache.Base
+	task.TaskWithCreator
 	storage          driver.Driver
 	dstDirActualPath string
 	file             model.FileStreamer
@@ -33,7 +34,7 @@ func (t *UploadTask) Run() error {
 var UploadTaskManager *tache.Manager[*UploadTask]
 
 // putAsTask add as a put task and return immediately
-func putAsTask(dstDirPath string, file model.FileStreamer) (tache.TaskWithInfo, error) {
+func putAsTask(ctx context.Context, dstDirPath string, file model.FileStreamer) (task.TaskInfoWithCreator, error) {
 	storage, dstDirActualPath, err := op.GetStorageAndActualPath(dstDirPath)
 	if err != nil {
 		return nil, errors.WithMessage(err, "failed get storage")
@@ -49,7 +50,11 @@ func putAsTask(dstDirPath string, file model.FileStreamer) (tache.TaskWithInfo,
 		//file.SetReader(tempFile)
 		//file.SetTmpFile(tempFile)
 	}
+	taskCreator, _ := ctx.Value("user").(*model.User) // taskCreator is nil when convert failed
 	t := &UploadTask{
+		TaskWithCreator: task.TaskWithCreator{
+			Creator: taskCreator,
+		},
 		storage:          storage,
 		dstDirActualPath: dstDirActualPath,
 		file:             file,
diff --git a/internal/offline_download/tool/add.go b/internal/offline_download/tool/add.go
index c7c5c781f71..1c9da1467b5 100644
--- a/internal/offline_download/tool/add.go
+++ b/internal/offline_download/tool/add.go
@@ -2,6 +2,8 @@ package tool
 
 import (
 	"context"
+	"github.com/alist-org/alist/v3/internal/model"
+	"github.com/alist-org/alist/v3/internal/task"
 	"path/filepath"
 
 	"github.com/alist-org/alist/v3/internal/conf"
@@ -9,7 +11,6 @@ import (
 	"github.com/alist-org/alist/v3/internal/op"
 	"github.com/google/uuid"
 	"github.com/pkg/errors"
-	"github.com/xhofe/tache"
 )
 
 type DeletePolicy string
@@ -28,7 +29,7 @@ type AddURLArgs struct {
 	DeletePolicy DeletePolicy
 }
 
-func AddURL(ctx context.Context, args *AddURLArgs) (tache.TaskWithInfo, error) {
+func AddURL(ctx context.Context, args *AddURLArgs) (task.TaskInfoWithCreator, error) {
 	// get tool
 	tool, err := Tools.Get(args.Tool)
 	if err != nil {
@@ -77,8 +78,12 @@ func AddURL(ctx context.Context, args *AddURLArgs) (tache.TaskWithInfo, error) {
 		// 防止将下载好的文件删除
 		deletePolicy = DeleteNever
 	}
-	
+
+	taskCreator, _ := ctx.Value("user").(*model.User) // taskCreator is nil when convert failed
 	t := &DownloadTask{
+		TaskWithCreator: task.TaskWithCreator{
+			Creator: taskCreator,
+		},
 		Url:          args.URL,
 		DstDirPath:   args.DstDirPath,
 		TempDir:      tempDir,
diff --git a/internal/offline_download/tool/download.go b/internal/offline_download/tool/download.go
index ef9ceabfc8a..038baf9690b 100644
--- a/internal/offline_download/tool/download.go
+++ b/internal/offline_download/tool/download.go
@@ -7,13 +7,14 @@ import (
 	"github.com/alist-org/alist/v3/internal/conf"
 	"github.com/alist-org/alist/v3/internal/errs"
 	"github.com/alist-org/alist/v3/internal/setting"
+	"github.com/alist-org/alist/v3/internal/task"
 	"github.com/pkg/errors"
 	log "github.com/sirupsen/logrus"
 	"github.com/xhofe/tache"
 )
 
 type DownloadTask struct {
-	tache.Base
+	task.TaskWithCreator
 	Url               string       `json:"url"`
 	DstDirPath        string       `json:"dst_dir_path"`
 	TempDir           string       `json:"temp_dir"`
@@ -171,6 +172,9 @@ func (t *DownloadTask) Complete() error {
 	for i := range files {
 		file := files[i]
 		TransferTaskManager.Add(&TransferTask{
+			TaskWithCreator: task.TaskWithCreator{
+				Creator: t.Creator,
+			},
 			file:         file,
 			DstDirPath:   t.DstDirPath,
 			TempDir:      t.TempDir,
diff --git a/internal/offline_download/tool/transfer.go b/internal/offline_download/tool/transfer.go
index 3744c7b500f..085b4a66afa 100644
--- a/internal/offline_download/tool/transfer.go
+++ b/internal/offline_download/tool/transfer.go
@@ -8,6 +8,7 @@ import (
 	"github.com/alist-org/alist/v3/internal/model"
 	"github.com/alist-org/alist/v3/internal/op"
 	"github.com/alist-org/alist/v3/internal/stream"
+	"github.com/alist-org/alist/v3/internal/task"
 	"github.com/alist-org/alist/v3/pkg/utils"
 	"github.com/pkg/errors"
 	log "github.com/sirupsen/logrus"
@@ -15,7 +16,7 @@ import (
 )
 
 type TransferTask struct {
-	tache.Base
+	task.TaskWithCreator
 	FileDir      string       `json:"file_dir"`
 	DstDirPath   string       `json:"dst_dir_path"`
 	TempDir      string       `json:"temp_dir"`
diff --git a/internal/task/base.go b/internal/task/base.go
new file mode 100644
index 00000000000..a30e59876b8
--- /dev/null
+++ b/internal/task/base.go
@@ -0,0 +1,26 @@
+package task
+
+import (
+	"github.com/alist-org/alist/v3/internal/model"
+	"github.com/xhofe/tache"
+)
+
+type TaskWithCreator struct {
+	tache.Base
+	Creator *model.User
+}
+
+func (t *TaskWithCreator) SetCreator(creator *model.User) {
+	t.Creator = creator
+	t.Persist()
+}
+
+func (t *TaskWithCreator) GetCreator() *model.User {
+	return t.Creator
+}
+
+type TaskInfoWithCreator interface {
+	tache.TaskWithInfo
+	SetCreator(creator *model.User)
+	GetCreator() *model.User
+}
diff --git a/server/handles/fsmanage.go b/server/handles/fsmanage.go
index 3d446eda957..42d53d7e7c7 100644
--- a/server/handles/fsmanage.go
+++ b/server/handles/fsmanage.go
@@ -2,7 +2,7 @@ package handles
 
 import (
 	"fmt"
-	"github.com/xhofe/tache"
+	"github.com/alist-org/alist/v3/internal/task"
 	"io"
 	stdpath "path"
 
@@ -121,7 +121,7 @@ func FsCopy(c *gin.Context) {
 		common.ErrorResp(c, err, 403)
 		return
 	}
-	var addedTasks []tache.TaskWithInfo
+	var addedTasks []task.TaskInfoWithCreator
 	for i, name := range req.Names {
 		t, err := fs.Copy(c, stdpath.Join(srcDir, name), dstDir, len(req.Names) > i+1)
 		if t != nil {
diff --git a/server/handles/fsup.go b/server/handles/fsup.go
index ef9baa11dc5..3a366d49fd0 100644
--- a/server/handles/fsup.go
+++ b/server/handles/fsup.go
@@ -1,17 +1,16 @@
 package handles
 
 import (
-	"github.com/xhofe/tache"
+	"github.com/alist-org/alist/v3/internal/task"
 	"io"
 	"net/url"
 	stdpath "path"
 	"strconv"
 	"time"
 
-	"github.com/alist-org/alist/v3/internal/stream"
-
 	"github.com/alist-org/alist/v3/internal/fs"
 	"github.com/alist-org/alist/v3/internal/model"
+	"github.com/alist-org/alist/v3/internal/stream"
 	"github.com/alist-org/alist/v3/server/common"
 	"github.com/gin-gonic/gin"
 )
@@ -58,9 +57,9 @@ func FsStream(c *gin.Context) {
 		Mimetype:     c.GetHeader("Content-Type"),
 		WebPutAsTask: asTask,
 	}
-	var t tache.TaskWithInfo
+	var t task.TaskInfoWithCreator
 	if asTask {
-		t, err = fs.PutAsTask(dir, s)
+		t, err = fs.PutAsTask(c, dir, s)
 	} else {
 		err = fs.PutDirectly(c, dir, s, true)
 	}
@@ -123,12 +122,12 @@ func FsForm(c *gin.Context) {
 		Mimetype:     file.Header.Get("Content-Type"),
 		WebPutAsTask: asTask,
 	}
-	var t tache.TaskWithInfo
+	var t task.TaskInfoWithCreator
 	if asTask {
 		s.Reader = struct {
 			io.Reader
 		}{f}
-		t, err = fs.PutAsTask(dir, &s)
+		t, err = fs.PutAsTask(c, dir, &s)
 	} else {
 		ss, err := stream.NewSeekableStream(s, nil)
 		if err != nil {
diff --git a/server/handles/offline_download.go b/server/handles/offline_download.go
index 1c5f95557ff..ff1fcfa05bc 100644
--- a/server/handles/offline_download.go
+++ b/server/handles/offline_download.go
@@ -5,9 +5,9 @@ import (
 	"github.com/alist-org/alist/v3/internal/model"
 	"github.com/alist-org/alist/v3/internal/offline_download/tool"
 	"github.com/alist-org/alist/v3/internal/op"
+	"github.com/alist-org/alist/v3/internal/task"
 	"github.com/alist-org/alist/v3/server/common"
 	"github.com/gin-gonic/gin"
-	"github.com/xhofe/tache"
 )
 
 type SetAria2Req struct {
@@ -133,7 +133,7 @@ func AddOfflineDownload(c *gin.Context) {
 		common.ErrorResp(c, err, 403)
 		return
 	}
-	var tasks []tache.TaskWithInfo
+	var tasks []task.TaskInfoWithCreator
 	for _, url := range req.Urls {
 		t, err := tool.AddURL(c, &tool.AddURLArgs{
 			URL:          url,
diff --git a/server/handles/task.go b/server/handles/task.go
index a8b4d21b2b9..71b4c622144 100644
--- a/server/handles/task.go
+++ b/server/handles/task.go
@@ -1,6 +1,8 @@
 package handles
 
 import (
+	"github.com/alist-org/alist/v3/internal/model"
+	"github.com/alist-org/alist/v3/internal/task"
 	"math"
 
 	"github.com/alist-org/alist/v3/internal/fs"
@@ -12,15 +14,17 @@ import (
 )
 
 type TaskInfo struct {
-	ID       string      `json:"id"`
-	Name     string      `json:"name"`
-	State    tache.State `json:"state"`
-	Status   string      `json:"status"`
-	Progress float64     `json:"progress"`
-	Error    string      `json:"error"`
+	ID          string      `json:"id"`
+	Name        string      `json:"name"`
+	Creator     string      `json:"creator"`
+	CreatorRole int         `json:"creator_role"`
+	State       tache.State `json:"state"`
+	Status      string      `json:"status"`
+	Progress    float64     `json:"progress"`
+	Error       string      `json:"error"`
 }
 
-func getTaskInfo[T tache.TaskWithInfo](task T) TaskInfo {
+func getTaskInfo[T task.TaskInfoWithCreator](task T) TaskInfo {
 	errMsg := ""
 	if task.GetErr() != nil {
 		errMsg = task.GetErr().Error()
@@ -30,62 +34,142 @@ func getTaskInfo[T tache.TaskWithInfo](task T) TaskInfo {
 	if math.IsNaN(progress) {
 		progress = 100
 	}
+	creatorName := ""
+	creatorRole := -1
+	if task.GetCreator() != nil {
+		creatorName = task.GetCreator().Username
+		creatorRole = task.GetCreator().Role
+	}
 	return TaskInfo{
-		ID:       task.GetID(),
-		Name:     task.GetName(),
-		State:    task.GetState(),
-		Status:   task.GetStatus(),
-		Progress: progress,
-		Error:    errMsg,
+		ID:          task.GetID(),
+		Name:        task.GetName(),
+		Creator:     creatorName,
+		CreatorRole: creatorRole,
+		State:       task.GetState(),
+		Status:      task.GetStatus(),
+		Progress:    progress,
+		Error:       errMsg,
 	}
 }
 
-func getTaskInfos[T tache.TaskWithInfo](tasks []T) []TaskInfo {
+func getTaskInfos[T task.TaskInfoWithCreator](tasks []T) []TaskInfo {
 	return utils.MustSliceConvert(tasks, getTaskInfo[T])
 }
 
-func taskRoute[T tache.TaskWithInfo](g *gin.RouterGroup, manager *tache.Manager[T]) {
+func argsContains[T comparable](v T, slice ...T) bool {
+	return utils.SliceContains(slice, v)
+}
+
+func getUserInfo(c *gin.Context) (bool, uint, bool) {
+	if user, ok := c.Value("user").(*model.User); ok {
+		return user.IsAdmin(), user.ID, true
+	} else {
+		return false, 0, false
+	}
+}
+
+func getTargetedHandler[T task.TaskInfoWithCreator](manager *tache.Manager[T], callback func(c *gin.Context, task T)) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		isAdmin, uid, ok := getUserInfo(c)
+		if !ok {
+			// if there is no bug, here is unreachable
+			common.ErrorStrResp(c, "user invalid", 401)
+			return
+		}
+		t, ok := manager.GetByID(c.Query("tid"))
+		if !ok {
+			common.ErrorStrResp(c, "task not found", 404)
+			return
+		}
+		if !isAdmin && uid != t.GetCreator().ID {
+			// to avoid an attacker using error messages to guess valid TID, return a 404 rather than a 403
+			common.ErrorStrResp(c, "task not found", 404)
+			return
+		}
+		callback(c, t)
+	}
+}
+
+func taskRoute[T task.TaskInfoWithCreator](g *gin.RouterGroup, manager *tache.Manager[T]) {
 	g.GET("/undone", func(c *gin.Context) {
-		common.SuccessResp(c, getTaskInfos(manager.GetByState(tache.StatePending, tache.StateRunning,
-			tache.StateCanceling, tache.StateErrored, tache.StateFailing, tache.StateWaitingRetry, tache.StateBeforeRetry)))
+		isAdmin, uid, ok := getUserInfo(c)
+		if !ok {
+			// if there is no bug, here is unreachable
+			common.ErrorStrResp(c, "user invalid", 401)
+			return
+		}
+		common.SuccessResp(c, getTaskInfos(manager.GetByCondition(func(task T) bool {
+			// avoid directly passing the user object into the function to reduce closure size
+			return (isAdmin || uid == task.GetCreator().ID) &&
+				argsContains(task.GetState(), tache.StatePending, tache.StateRunning, tache.StateCanceling,
+					tache.StateErrored, tache.StateFailing, tache.StateWaitingRetry, tache.StateBeforeRetry)
+		})))
 	})
 	g.GET("/done", func(c *gin.Context) {
-		common.SuccessResp(c, getTaskInfos(manager.GetByState(tache.StateCanceled, tache.StateFailed, tache.StateSucceeded)))
-	})
-	g.POST("/info", func(c *gin.Context) {
-		tid := c.Query("tid")
-		task, ok := manager.GetByID(tid)
+		isAdmin, uid, ok := getUserInfo(c)
 		if !ok {
-			common.ErrorStrResp(c, "task not found", 404)
+			// if there is no bug, here is unreachable
+			common.ErrorStrResp(c, "user invalid", 401)
 			return
 		}
-		common.SuccessResp(c, getTaskInfo(task))
+		common.SuccessResp(c, getTaskInfos(manager.GetByCondition(func(task T) bool {
+			return (isAdmin || uid == task.GetCreator().ID) &&
+				argsContains(task.GetState(), tache.StateCanceled, tache.StateFailed, tache.StateSucceeded)
+		})))
 	})
-	g.POST("/cancel", func(c *gin.Context) {
-		tid := c.Query("tid")
-		manager.Cancel(tid)
+	g.POST("/info", getTargetedHandler(manager, func(c *gin.Context, task T) {
+		common.SuccessResp(c, getTaskInfo(task))
+	}))
+	g.POST("/cancel", getTargetedHandler(manager, func(c *gin.Context, task T) {
+		manager.Cancel(task.GetID())
 		common.SuccessResp(c)
-	})
-	g.POST("/delete", func(c *gin.Context) {
-		tid := c.Query("tid")
-		manager.Remove(tid)
+	}))
+	g.POST("/delete", getTargetedHandler(manager, func(c *gin.Context, task T) {
+		manager.Remove(task.GetID())
 		common.SuccessResp(c)
-	})
-	g.POST("/retry", func(c *gin.Context) {
-		tid := c.Query("tid")
-		manager.Retry(tid)
+	}))
+	g.POST("/retry", getTargetedHandler(manager, func(c *gin.Context, task T) {
+		manager.Retry(task.GetID())
 		common.SuccessResp(c)
-	})
+	}))
 	g.POST("/clear_done", func(c *gin.Context) {
-		manager.RemoveByState(tache.StateCanceled, tache.StateFailed, tache.StateSucceeded)
+		isAdmin, uid, ok := getUserInfo(c)
+		if !ok {
+			// if there is no bug, here is unreachable
+			common.ErrorStrResp(c, "user invalid", 401)
+			return
+		}
+		manager.RemoveByCondition(func(task T) bool {
+			return (isAdmin || uid == task.GetCreator().ID) &&
+				argsContains(task.GetState(), tache.StateCanceled, tache.StateFailed, tache.StateSucceeded)
+		})
 		common.SuccessResp(c)
 	})
 	g.POST("/clear_succeeded", func(c *gin.Context) {
-		manager.RemoveByState(tache.StateSucceeded)
+		isAdmin, uid, ok := getUserInfo(c)
+		if !ok {
+			// if there is no bug, here is unreachable
+			common.ErrorStrResp(c, "user invalid", 401)
+			return
+		}
+		manager.RemoveByCondition(func(task T) bool {
+			return (isAdmin || uid == task.GetCreator().ID) && task.GetState() == tache.StateSucceeded
+		})
 		common.SuccessResp(c)
 	})
 	g.POST("/retry_failed", func(c *gin.Context) {
-		manager.RetryAllFailed()
+		isAdmin, uid, ok := getUserInfo(c)
+		if !ok {
+			// if there is no bug, here is unreachable
+			common.ErrorStrResp(c, "user invalid", 401)
+			return
+		}
+		tasks := manager.GetByCondition(func(task T) bool {
+			return (isAdmin || uid == task.GetCreator().ID) && task.GetState() == tache.StateFailed
+		})
+		for _, t := range tasks {
+			manager.Retry(t.GetID())
+		}
 		common.SuccessResp(c)
 	})
 }
diff --git a/server/middlewares/auth.go b/server/middlewares/auth.go
index 14f186be8bf..d65d1ad648a 100644
--- a/server/middlewares/auth.go
+++ b/server/middlewares/auth.go
@@ -127,6 +127,16 @@ func Authn(c *gin.Context) {
 	c.Next()
 }
 
+func AuthNotGuest(c *gin.Context) {
+	user := c.MustGet("user").(*model.User)
+	if user.IsGuest() {
+		common.ErrorStrResp(c, "You are a guest", 403)
+		c.Abort()
+	} else {
+		c.Next()
+	}
+}
+
 func AuthAdmin(c *gin.Context) {
 	user := c.MustGet("user").(*model.User)
 	if !user.IsAdmin() {
diff --git a/server/router.go b/server/router.go
index 07423f923cd..fffa840e537 100644
--- a/server/router.go
+++ b/server/router.go
@@ -76,6 +76,7 @@ func Init(e *gin.Engine) {
 	public.Any("/offline_download_tools", handles.OfflineDownloadTools)
 
 	_fs(auth.Group("/fs"))
+	_task(auth.Group("/task", middlewares.AuthNotGuest))
 	admin(auth.Group("/admin", middlewares.AuthAdmin))
 	if flags.Debug || flags.Dev {
 		debug(g.Group("/debug"))
@@ -127,8 +128,8 @@ func admin(g *gin.RouterGroup) {
 	setting.POST("/set_qbit", handles.SetQbittorrent)
 	setting.POST("/set_transmission", handles.SetTransmission)
 
-	task := g.Group("/task")
-	handles.SetupTaskRoute(task)
+	// retain /admin/task API to ensure compatibility with legacy automation scripts
+	_task(g.Group("/task"))
 
 	ms := g.Group("/message")
 	ms.POST("/get", message.HttpInstance.GetHandle)
@@ -166,6 +167,10 @@ func _fs(g *gin.RouterGroup) {
 	g.POST("/add_offline_download", handles.AddOfflineDownload)
 }
 
+func _task(g *gin.RouterGroup) {
+	handles.SetupTaskRoute(g)
+}
+
 func Cors(r *gin.Engine) {
 	config := cors.DefaultConfig()
 	// config.AllowAllOrigins = true


Test patch: