feat: support general users view and cancel own tasks (#7416 close #7398)

* feat: support general users view and cancel own tasks

Add a creator attribute to the upload, copy and offline download
tasks, so that a GENERAL task creator can view and cancel them.

BREAKING CHANGE:

1. A new internal package `task` including the struct `TaskWithCreator`
   which embeds `tache.Base` is created, and the past dependence on
   `tache.Task` will all be transferred to dependence on this package.
2. The API `/admin/task` can now also be accessed via `/task`, and the
   old endpoint is retained to ensure compatibility with legacy
   automation scripts.

Closes #7398

* fix(deps): update github.com/xhofe/tache to v0.1.3
pull/7453/head
KirCute_ECT 2024-11-01 23:32:26 +08:00 committed by GitHub
parent 10c7ebb1c0
commit 64ceb5afb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 217 additions and 68 deletions

2
go.mod
View File

@ -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

2
go.sum
View File

@ -514,6 +514,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=

View File

@ -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,

View File

@ -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)
}

View File

@ -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,

View File

@ -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 {
@ -78,7 +79,11 @@ 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,

View File

@ -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,

View File

@ -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"`

26
internal/task/base.go Normal file
View File

@ -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
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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,

View File

@ -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]) {
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)))
})
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)
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) {
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) {
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 {
return (isAdmin || uid == task.GetCreator().ID) &&
argsContains(task.GetState(), tache.StateCanceled, tache.StateFailed, tache.StateSucceeded)
})))
})
g.POST("/info", getTargetedHandler(manager, func(c *gin.Context, task T) {
common.SuccessResp(c, getTaskInfo(task))
})
g.POST("/cancel", func(c *gin.Context) {
tid := c.Query("tid")
manager.Cancel(tid)
}))
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)
})
}

View File

@ -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() {

View File

@ -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