package explorer import ( "context" "encoding/json" "errors" "fmt" "net/url" "time" "github.com/cloudreve/Cloudreve/v4/application/dependency" "github.com/cloudreve/Cloudreve/v4/ent" "github.com/cloudreve/Cloudreve/v4/inventory" "github.com/cloudreve/Cloudreve/v4/inventory/types" "github.com/cloudreve/Cloudreve/v4/pkg/auth" "github.com/cloudreve/Cloudreve/v4/pkg/boolset" "github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager" "github.com/cloudreve/Cloudreve/v4/pkg/hashid" "github.com/cloudreve/Cloudreve/v4/pkg/queue" "github.com/cloudreve/Cloudreve/v4/pkg/util" "github.com/cloudreve/Cloudreve/v4/service/user" "github.com/gin-gonic/gin" "github.com/gofrs/uuid" "github.com/samber/lo" ) type PutRelativeResponse struct { Name string Url string } type DirectLinkResponse struct { Link string `json:"link"` FileUrl string `json:"file_url"` } func BuildDirectLinkResponse(links []manager.DirectLink) []DirectLinkResponse { if len(links) == 0 { return nil } var res []DirectLinkResponse for _, link := range links { res = append(res, DirectLinkResponse{ Link: link.Url, FileUrl: link.File.Uri(false).String(), }) } return res } const PathMyRedacted = "redacted" type TaskResponse struct { CreatedAt time.Time `json:"created_at,"` UpdatedAt time.Time `json:"updated_at"` ID string `json:"id"` Status string `json:"status"` Type string `json:"type"` Node *user.Node `json:"node,omitempty"` Summary *queue.Summary `json:"summary,omitempty"` Error string `json:"error,omitempty"` ErrorHistory []string `json:"error_history,omitempty"` Duration int64 `json:"duration,omitempty"` ResumeTime int64 `json:"resume_time,omitempty"` RetryCount int `json:"retry_count,omitempty"` } type TaskListResponse struct { Tasks []TaskResponse `json:"tasks"` Pagination *inventory.PaginationResults `json:"pagination"` } func BuildTaskListResponse(tasks []queue.Task, res *inventory.ListTaskResult, nodeMap map[int]*ent.Node, hasher hashid.Encoder) *TaskListResponse { return &TaskListResponse{ Pagination: res.PaginationResults, Tasks: lo.Map(tasks, func(t queue.Task, index int) TaskResponse { var ( node *ent.Node s = t.Summarize(hasher) ) if s.NodeID > 0 { node = nodeMap[s.NodeID] } return *BuildTaskResponse(t, node, hasher) }), } } func BuildTaskResponse(task queue.Task, node *ent.Node, hasher hashid.Encoder) *TaskResponse { model := task.Model() t := &TaskResponse{ Status: string(task.Status()), CreatedAt: model.CreatedAt, UpdatedAt: model.UpdatedAt, ID: hashid.EncodeTaskID(hasher, task.ID()), Type: task.Type(), Summary: task.Summarize(hasher), Error: auth.RedactSensitiveValues(model.PublicState.Error), ErrorHistory: lo.Map(model.PublicState.ErrorHistory, func(s string, index int) string { return auth.RedactSensitiveValues(s) }), Duration: model.PublicState.ExecutedDuration.Milliseconds(), ResumeTime: model.PublicState.ResumeTime, RetryCount: model.PublicState.RetryCount, } if node != nil { t.Node = user.BuildNode(node, hasher) } return t } type UploadSessionResponse struct { SessionID string `json:"session_id"` UploadID string `json:"upload_id"` ChunkSize int64 `json:"chunk_size"` // 分块大小,0 为部分快 Expires int64 `json:"expires"` // 上传凭证过期时间, Unix 时间戳 UploadURLs []string `json:"upload_urls,omitempty"` Credential string `json:"credential,omitempty"` AccessKey string `json:"ak,omitempty"` KeyTime string `json:"keyTime,omitempty"` // COS用有效期 CompleteURL string `json:"completeURL,omitempty"` StoragePolicy *StoragePolicy `json:"storage_policy,omitempty"` Uri string `json:"uri"` CallbackSecret string `json:"callback_secret"` MimeType string `json:"mime_type,omitempty"` UploadPolicy string `json:"upload_policy,omitempty"` } func BuildUploadSessionResponse(session *fs.UploadCredential, hasher hashid.Encoder) *UploadSessionResponse { return &UploadSessionResponse{ SessionID: session.SessionID, ChunkSize: session.ChunkSize, Expires: session.Expires, UploadURLs: session.UploadURLs, Credential: session.Credential, CompleteURL: session.CompleteURL, Uri: session.Uri, UploadID: session.UploadID, StoragePolicy: BuildStoragePolicy(session.StoragePolicy, hasher), CallbackSecret: session.CallbackSecret, MimeType: session.MimeType, UploadPolicy: session.UploadPolicy, } } // WopiFileInfo Response for `CheckFileInfo` type WopiFileInfo struct { // Required BaseFileName string Version string Size int64 // Breadcrumb BreadcrumbBrandName string BreadcrumbBrandUrl string BreadcrumbFolderName string BreadcrumbFolderUrl string // Post Message FileSharingPostMessage bool FileVersionPostMessage bool ClosePostMessage bool PostMessageOrigin string // Other miscellaneous properties FileNameMaxLength int LastModifiedTime string // User metadata IsAnonymousUser bool UserFriendlyName string UserId string OwnerId string // Permission ReadOnly bool UserCanRename bool UserCanReview bool UserCanWrite bool UserCanNotWriteRelative bool SupportsRename bool SupportsReviewing bool SupportsUpdate bool SupportsLocks bool EnableShare bool } type ViewerSessionResponse struct { Session *manager.ViewerSession `json:"session"` WopiSrc string `json:"wopi_src,omitempty"` } type ListResponse struct { Files []FileResponse `json:"files"` Parent FileResponse `json:"parent,omitempty"` Pagination *inventory.PaginationResults `json:"pagination"` Props *fs.NavigatorProps `json:"props"` // ContextHint is used to speed up following operations under this listed directory. // It persists some intermedia state so that the following request don't need to query database again. // All the operations under this directory that supports context hint should carry this value in header // as X-Cr-Context-Hint. ContextHint *uuid.UUID `json:"context_hint"` RecursionLimitReached bool `json:"recursion_limit_reached,omitempty"` MixedType bool `json:"mixed_type"` SingleFileView bool `json:"single_file_view,omitempty"` StoragePolicy *StoragePolicy `json:"storage_policy,omitempty"` View *types.ExplorerView `json:"view,omitempty"` } type FileResponse struct { Type int `json:"type"` ID string `json:"id"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Size int64 `json:"size"` Metadata map[string]string `json:"metadata"` Path string `json:"path,omitempty"` Shared bool `json:"shared,omitempty"` Capability *boolset.BooleanSet `json:"capability,omitempty"` Owned bool `json:"owned,omitempty"` PrimaryEntity string `json:"primary_entity,omitempty"` FolderSummary *fs.FolderSummary `json:"folder_summary,omitempty"` ExtendedInfo *ExtendedInfo `json:"extended_info,omitempty"` } type ExtendedInfo struct { StoragePolicy *StoragePolicy `json:"storage_policy,omitempty"` StorageUsed int64 `json:"storage_used"` Shares []Share `json:"shares,omitempty"` Entities []Entity `json:"entities,omitempty"` View *types.ExplorerView `json:"view,omitempty"` DirectLinks []DirectLink `json:"direct_links,omitempty"` } type DirectLink struct { ID string `json:"id"` URL string `json:"url"` Downloaded int `json:"downloaded"` CreatedAt time.Time `json:"created_at"` } type StoragePolicy struct { ID string `json:"id"` Name string `json:"name"` AllowedSuffix []string `json:"allowed_suffix,omitempty"` DeniedSuffix []string `json:"denied_suffix,omitempty"` AllowedNameRegexp string `json:"allowed_name_regexp,omitempty"` DeniedNameRegexp string `json:"denied_name_regexp,omitempty"` Type types.PolicyType `json:"type"` MaxSize int64 `json:"max_size"` Relay bool `json:"relay,omitempty"` } type Entity struct { ID string `json:"id"` Size int64 `json:"size"` Type types.EntityType `json:"type"` CreatedAt time.Time `json:"created_at"` StoragePolicy *StoragePolicy `json:"storage_policy,omitempty"` CreatedBy *user.User `json:"created_by,omitempty"` } type Share struct { ID string `json:"id"` Name string `json:"name,omitempty"` RemainDownloads *int `json:"remain_downloads,omitempty"` Visited int `json:"visited"` Downloaded int `json:"downloaded,omitempty"` Expires *time.Time `json:"expires,omitempty"` Unlocked bool `json:"unlocked"` PasswordProtected bool `json:"password_protected,omitempty"` SourceType *types.FileType `json:"source_type,omitempty"` Owner user.User `json:"owner"` CreatedAt time.Time `json:"created_at,omitempty"` Expired bool `json:"expired"` Url string `json:"url"` ShowReadMe bool `json:"show_readme,omitempty"` // Only viewable by owner IsPrivate bool `json:"is_private,omitempty"` Password string `json:"password,omitempty"` ShareView bool `json:"share_view,omitempty"` // Only viewable if explicitly unlocked by owner SourceUri string `json:"source_uri,omitempty"` } func BuildShare(s *ent.Share, base *url.URL, hasher hashid.Encoder, requester *ent.User, owner *ent.User, name string, t types.FileType, unlocked bool, expired bool) *Share { redactLevel := user.RedactLevelAnonymous if !inventory.IsAnonymousUser(requester) { redactLevel = user.RedactLevelUser } res := Share{ Name: name, ID: hashid.EncodeShareID(hasher, s.ID), Unlocked: unlocked, Owner: user.BuildUserRedacted(owner, redactLevel, hasher), Expired: inventory.IsShareExpired(s) != nil || expired, Url: BuildShareLink(s, hasher, base, unlocked), CreatedAt: s.CreatedAt, Visited: s.Views, SourceType: util.ToPtr(t), PasswordProtected: s.Password != "", } if unlocked { res.RemainDownloads = s.RemainDownloads res.Downloaded = s.Downloads res.Expires = s.Expires res.Password = s.Password res.ShowReadMe = s.Props != nil && s.Props.ShowReadMe } if requester.ID == owner.ID { res.IsPrivate = s.Password != "" res.ShareView = s.Props != nil && s.Props.ShareView } return &res } func BuildListResponse(ctx context.Context, u *ent.User, parent fs.File, res *fs.ListFileResult, hasher hashid.Encoder) *ListResponse { r := &ListResponse{ Files: lo.Map(res.Files, func(f fs.File, index int) FileResponse { return *BuildFileResponse(ctx, u, f, hasher, res.Props.Capability) }), Pagination: res.Pagination, Props: res.Props, ContextHint: res.ContextHint, RecursionLimitReached: res.RecursionLimitReached, MixedType: res.MixedType, SingleFileView: res.SingleFileView, StoragePolicy: BuildStoragePolicy(res.StoragePolicy, hasher), View: res.View, } if !res.Parent.IsNil() { r.Parent = *BuildFileResponse(ctx, u, res.Parent, hasher, res.Props.Capability) } return r } func BuildFileResponse(ctx context.Context, u *ent.User, f fs.File, hasher hashid.Encoder, cap *boolset.BooleanSet) *FileResponse { var owner *ent.User if f != nil { owner = f.Owner() } if cap == nil { cap = f.Capabilities() } res := &FileResponse{ Type: int(f.Type()), ID: hashid.EncodeFileID(hasher, f.ID()), Name: f.DisplayName(), CreatedAt: f.CreatedAt(), UpdatedAt: f.UpdatedAt(), Size: f.Size(), Metadata: f.Metadata(), Path: f.Uri(false).String(), Shared: f.Shared(), Capability: cap, Owned: owner == nil || owner.ID == u.ID, FolderSummary: f.FolderSummary(), ExtendedInfo: BuildExtendedInfo(ctx, u, f, hasher), PrimaryEntity: hashid.EncodeEntityID(hasher, f.PrimaryEntityID()), } return res } func BuildExtendedInfo(ctx context.Context, u *ent.User, f fs.File, hasher hashid.Encoder) *ExtendedInfo { extendedInfo := f.ExtendedInfo() if extendedInfo == nil { return nil } dep := dependency.FromContext(ctx) base := dep.SettingProvider().SiteURL(ctx) ext := &ExtendedInfo{ StoragePolicy: BuildStoragePolicy(extendedInfo.StoragePolicy, hasher), StorageUsed: extendedInfo.StorageUsed, Entities: lo.Map(f.Entities(), func(e fs.Entity, index int) Entity { return BuildEntity(extendedInfo, e, hasher) }), DirectLinks: lo.Map(extendedInfo.DirectLinks, func(d *ent.DirectLink, index int) DirectLink { return BuildDirectLink(d, hasher, base) }), } if u.ID == f.OwnerID() { // Only owner can see the shares settings. ext.Shares = lo.Map(extendedInfo.Shares, func(s *ent.Share, index int) Share { return *BuildShare(s, base, hasher, u, u, f.DisplayName(), f.Type(), true, false) }) ext.View = extendedInfo.View } return ext } func BuildDirectLink(d *ent.DirectLink, hasher hashid.Encoder, base *url.URL) DirectLink { return DirectLink{ ID: hashid.EncodeSourceLinkID(hasher, d.ID), URL: routes.MasterDirectLink(base, hashid.EncodeSourceLinkID(hasher, d.ID), d.Name).String(), Downloaded: d.Downloads, CreatedAt: d.CreatedAt, } } func BuildEntity(extendedInfo *fs.FileExtendedInfo, e fs.Entity, hasher hashid.Encoder) Entity { var u *user.User createdBy := e.CreatedBy() if createdBy != nil { userRedacted := user.BuildUserRedacted(e.CreatedBy(), user.RedactLevelAnonymous, hasher) u = &userRedacted } return Entity{ ID: hashid.EncodeEntityID(hasher, e.ID()), Type: e.Type(), CreatedAt: e.CreatedAt(), StoragePolicy: BuildStoragePolicy(extendedInfo.EntityStoragePolicies[e.PolicyID()], hasher), Size: e.Size(), CreatedBy: u, } } func BuildShareLink(s *ent.Share, hasher hashid.Encoder, base *url.URL, unlocked bool) string { shareId := hashid.EncodeShareID(hasher, s.ID) if unlocked { return routes.MasterShareUrl(base, shareId, s.Password).String() } return routes.MasterShareUrl(base, shareId, "").String() } func BuildStoragePolicy(sp *ent.StoragePolicy, hasher hashid.Encoder) *StoragePolicy { if sp == nil { return nil } res := &StoragePolicy{ ID: hashid.EncodePolicyID(hasher, sp.ID), Name: sp.Name, Type: types.PolicyType(sp.Type), MaxSize: sp.MaxSize, Relay: sp.Settings.Relay, } if sp.Settings.IsFileTypeDenyList { res.DeniedSuffix = sp.Settings.FileType } else { res.AllowedSuffix = sp.Settings.FileType } if sp.Settings.NameRegexp != "" { if sp.Settings.IsNameRegexpDenyList { res.DeniedNameRegexp = sp.Settings.NameRegexp } else { res.AllowedNameRegexp = sp.Settings.NameRegexp } } return res } func WriteEventSourceHeader(c *gin.Context) { c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("X-Accel-Buffering", "no") } // WriteEventSource writes a Server-Sent Event to the client. func WriteEventSource(c *gin.Context, event string, data any) { c.Writer.Write([]byte(fmt.Sprintf("event: %s\n", event))) c.Writer.Write([]byte("data:")) json.NewEncoder(c.Writer).Encode(data) c.Writer.Write([]byte("\n")) c.Writer.Flush() } var ErrSSETakeOver = errors.New("SSE take over")