From 14e23a4c6dd1273829573e5ee5fe3fa37a20600a Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 17:52:51 +0200 Subject: [PATCH 1/2] feat(share): visitor write access, preview-only and drop-box shares Share props gain four flags: allow_upload, allow_edit, preview_only, upload_only. Enforcement is server-side through a layered model: - static share-navigator capability superset admits operations to share resolution; props-derived capability set is stamped on resolved files - writePermitted() replaces owner-equality checks on write paths so visitors act only within the share's granted capabilities - upload-only shares return no children and strip read/edit caps - preview-only shares keep entity fetches for inline viewers but deny explicit downloads and direct links via IsDownloadCtxKey in hooks - move/copy allowed within the same share only; cross-share rejected - new files created under a share belong to the share owner and count towards their storage quota Frontend exposes the flags in the share dialog (folder-scoped for write toggles), renders write actions for permitted share visitors via the capability boolset, and hides the download button on preview-only share pages. en-US and zh-CN strings added. Metadata/props, version control and restore remain owner-only. Authored By: TDvorak Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../public/locales/en-US/application.json | 8 + .../public/locales/zh-CN/application.json | 8 + frontend/src/api/explorer.ts | 8 + .../ContextMenu/useActionDisplayOpt.ts | 26 ++- .../FileManager/Dialogs/Share/ShareDialog.tsx | 4 + .../Dialogs/Share/ShareSetting.tsx | 102 ++++++++- .../FileManager/Dnd/DndWrappedFile.tsx | 9 +- .../FileManager/Explorer/SingleFileView.tsx | 8 +- frontend/src/redux/thunks/share.ts | 4 + inventory/types/types.go | 8 + pkg/filemanager/fs/dbfs/dbfs.go | 26 ++- pkg/filemanager/fs/dbfs/lock.go | 2 +- pkg/filemanager/fs/dbfs/manage.go | 18 +- pkg/filemanager/fs/dbfs/navigator.go | 7 + .../fs/dbfs/share_collaboration_test.go | 194 ++++++++++++++++++ pkg/filemanager/fs/dbfs/share_navigator.go | 92 ++++++++- pkg/filemanager/fs/dbfs/upload.go | 12 +- pkg/filemanager/manager/entity.go | 24 ++- pkg/filemanager/manager/manager.go | 4 + pkg/filemanager/manager/operation.go | 8 +- service/explorer/response.go | 23 ++- service/share/manage.go | 8 + 22 files changed, 565 insertions(+), 38 deletions(-) create mode 100644 pkg/filemanager/fs/dbfs/share_collaboration_test.go diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index a4ba1473..7813e9c6 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -606,6 +606,14 @@ "unlinkOnlyDes": "Delete file records only, physical files will not be deleted.", "shareView": "Share view setting", "shareViewDes": "If selected, other users can see your view setting (layout, sorting, etc.) saved on the server server when accessing this shared folder.", + "allowUpload": "Allow uploads", + "allowUploadDes": "Visitors can upload new files and create folders in this shared folder. Storage is counted towards your quota.", + "allowEdit": "Allow editing", + "allowEditDes": "Visitors can upload, rename, move and delete files in this shared folder. Implies upload permission.", + "uploadOnly": "Upload only (drop box)", + "uploadOnlyDes": "Visitors can upload files but cannot see or download existing content in this shared folder.", + "previewOnly": "Preview only", + "previewOnlyDes": "Visitors can preview files inline but cannot download them.", "showReadme": "Show README file", "showReadmeDes": "If selected, the <0>README.md file (case-sensitive) in the directory will be automatically displayed for visitors.", "viewSetting": "View setting", diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index 9264f674..789b5167 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -606,6 +606,14 @@ "unlinkOnlyDes": "仅删除文件记录,物理文件不会被删除", "shareView": "分享视图设置", "shareViewDes": "勾选后,其他用户访问此共享文件夹时可以看到你保存在服务器的视图设置 (布局、排序等)。", + "allowUpload": "允许上传", + "allowUploadDes": "访客可以在此共享文件夹中上传新文件和创建文件夹,存储计入你的配额。", + "allowEdit": "允许编辑", + "allowEditDes": "访客可以上传、重命名、移动和删除此共享文件夹中的文件,包含上传权限。", + "uploadOnly": "仅上传(收集箱)", + "uploadOnlyDes": "访客可以上传文件,但无法查看或下载此共享文件夹中的现有内容。", + "previewOnly": "仅预览", + "previewOnlyDes": "访客可以在线预览文件,但不能下载。", "showReadme": "显示 README 文件", "showReadmeDes": "勾选后,会自动为访问者展示目录下的 <0>README.md (区分大小写) 文件。", "viewSetting": "视图设置", diff --git a/frontend/src/api/explorer.ts b/frontend/src/api/explorer.ts index af6093ee..5bf62ebc 100644 --- a/frontend/src/api/explorer.ts +++ b/frontend/src/api/explorer.ts @@ -90,6 +90,10 @@ export interface Share { source_uri?: string; password?: string; show_readme?: boolean; + allow_upload?: boolean; + allow_edit?: boolean; + preview_only?: boolean; + upload_only?: boolean; } export enum PolicyType { @@ -308,6 +312,10 @@ export interface ShareCreateService { expire?: number; share_view?: boolean; show_readme?: boolean; + allow_upload?: boolean; + allow_edit?: boolean; + preview_only?: boolean; + upload_only?: boolean; } export interface CreateFileService { diff --git a/frontend/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts b/frontend/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts index a2e1e622..3a37a4ab 100644 --- a/frontend/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts +++ b/frontend/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts @@ -26,11 +26,12 @@ export const canShowInfo = (cap: Boolset) => { }; export const canUpdate = (opt: DisplayOption) => { + // Overwriting an existing file requires the rename capability server-side. return !!( opt.allUpdatable && opt.hasFile && opt.orCapability?.enabled(NavigatorCapability.upload_file) && - opt.allUpdatable + opt.orCapability?.enabled(NavigatorCapability.rename_file) ); }; @@ -117,7 +118,9 @@ export const getActionOpt = ( } const parentCap = new Boolset(parent.capability); - display.showCreateFolder = parentCap.enabled(NavigatorCapability.create_file) && parent.owned; + const parentInShare = new CrUri(parent.path).fs() == Filesystem.share; + display.showCreateFolder = + parentCap.enabled(NavigatorCapability.create_file) && (parent.owned || parentInShare); display.showCreateFile = display.showCreateFolder && fmIndex == FileManagerIndex.main; display.showUpload = display.showCreateFile; if (display.showCreateFile) { @@ -138,9 +141,12 @@ export const getActionOpt = ( } const parentUrl = new CrUri(targets?.[0]?.path ?? defaultPath); + const inShare = parentUrl.fs() == Filesystem.share; targets.forEach((target) => { - let readable = true; - let updatable = target.owned && parentUrl.fs() != Filesystem.share; + const readable = true; + // Share visitors are never owners; their writable actions are gated by + // the per-file capability boolset stamped from the share's props. + const updatable = target.owned || inShare; if (display.allReadable && !readable) { display.allReadable = false; @@ -210,7 +216,11 @@ export const getActionOpt = ( display.allUpdatable && display.orCapability && display.orCapability.enabled(NavigatorCapability.rename_file); - display.showCopy = display.hasUpdatable && !!display.orCapability; + display.showCopy = + display.hasUpdatable && + display.orCapability && + display.orCapability.enabled(NavigatorCapability.download_file) && + (!inShare || display.orCapability.enabled(NavigatorCapability.create_file)); display.showShare = targets.length == 1 && !!currentUser && @@ -221,7 +231,11 @@ export const getActionOpt = ( display.orCapability.enabled(NavigatorCapability.share) && (!targets[0].metadata || (!targets[0].metadata[Metadata.share_redirect] && !targets[0].metadata[Metadata.restore_uri])); - display.showMove = display.hasUpdatable && !!display.orCapability; + display.showMove = + display.hasUpdatable && + display.orCapability && + display.orCapability.enabled(NavigatorCapability.delete_file) && + (!inShare || display.orCapability.enabled(NavigatorCapability.create_file)); display.showTags = display.hasUpdatable && display.orCapability && display.orCapability.enabled(NavigatorCapability.update_metadata); display.showChangeFolderColor = diff --git a/frontend/src/component/FileManager/Dialogs/Share/ShareDialog.tsx b/frontend/src/component/FileManager/Dialogs/Share/ShareDialog.tsx index 6a82ef23..43676403 100644 --- a/frontend/src/component/FileManager/Dialogs/Share/ShareDialog.tsx +++ b/frontend/src/component/FileManager/Dialogs/Share/ShareDialog.tsx @@ -34,6 +34,10 @@ const shareToSetting = (share: ShareModel, t: TFunction): ShareSetting => { use_custom_password: true, share_view: share.share_view, show_readme: share.show_readme, + allow_upload: share.allow_upload, + allow_edit: share.allow_edit, + preview_only: share.preview_only, + upload_only: share.upload_only, downloads: share.remain_downloads != undefined && share.remain_downloads > 0, expires_val: expireOptions[2], diff --git a/frontend/src/component/FileManager/Dialogs/Share/ShareSetting.tsx b/frontend/src/component/FileManager/Dialogs/Share/ShareSetting.tsx index 2ac95262..bd5837e7 100644 --- a/frontend/src/component/FileManager/Dialogs/Share/ShareSetting.tsx +++ b/frontend/src/component/FileManager/Dialogs/Share/ShareSetting.tsx @@ -24,9 +24,13 @@ import { Code } from "../../../Common/Code.tsx"; import { FilledTextField, SmallFormControlLabel } from "../../../Common/StyledComponents.tsx"; import BookInformation from "../../../Icons/BookInformation.tsx"; import ClockArrowDownload from "../../../Icons/ClockArrowDownload.tsx"; +import Edit from "../../../Icons/Edit.tsx"; import Eye from "../../../Icons/Eye.tsx"; +import EyeOff from "../../../Icons/EyeOff.tsx"; +import FolderAdd from "../../../Icons/FolderAdd.tsx"; import TableSettingsOutlined from "../../../Icons/TableSettings.tsx"; import Timer from "../../../Icons/Timer.tsx"; +import Upload from "../../../Icons/Upload.tsx"; const Accordion = styled(MuiAccordion)(() => ({ border: "0px solid rgba(0, 0, 0, .125)", @@ -79,6 +83,10 @@ export interface ShareSetting { password?: string; share_view?: boolean; show_readme?: boolean; + allow_upload?: boolean; + allow_edit?: boolean; + preview_only?: boolean; + upload_only?: boolean; downloads?: boolean; expires?: boolean; @@ -132,7 +140,9 @@ const ShareSettingContent = ({ setting, file, editing, onSettingChange }: ShareS setExpanded(isExpanded ? panel : undefined); }; - const handleCheck = (prop: "is_private" | "share_view" | "show_readme" | "expires" | "downloads") => () => { + const handleCheck = ( + prop: "is_private" | "share_view" | "show_readme" | "preview_only" | "expires" | "downloads", + ) => () => { if (!setting[prop]) { handleExpand(prop)(null, true); } @@ -200,8 +210,98 @@ const ShareSettingContent = ({ setting, file, editing, onSettingChange }: ShareS )} + + + + + + + + + + + + + {t("application:modals.previewOnlyDes")} + {file?.type == FileType.folder && ( <> + + + + + + + + + { + if (!setting.allow_upload) { + handleExpand("allow_upload")(null, true); + } + onSettingChange({ ...setting, allow_upload: !setting.allow_upload }); + }} + /> + + + + {t("application:modals.allowUploadDes")} + + + + + + + + + + { + if (!setting.allow_edit) { + handleExpand("allow_edit")(null, true); + } + onSettingChange({ + ...setting, + allow_edit: !setting.allow_edit, + allow_upload: true, + }); + }} + /> + + + + {t("application:modals.allowEditDes")} + + + + + + + + + + { + if (!setting.upload_only) { + handleExpand("upload_only")(null, true); + } + onSettingChange({ + ...setting, + upload_only: !setting.upload_only, + allow_edit: false, + }); + }} + /> + + + + {t("application:modals.uploadOnlyDes")} + diff --git a/frontend/src/component/FileManager/Dnd/DndWrappedFile.tsx b/frontend/src/component/FileManager/Dnd/DndWrappedFile.tsx index a976fcab..2909c870 100644 --- a/frontend/src/component/FileManager/Dnd/DndWrappedFile.tsx +++ b/frontend/src/component/FileManager/Dnd/DndWrappedFile.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useContext, useEffect } from "react"; import { useDrag, useDrop } from "react-dnd"; import { getEmptyImage } from "react-dnd-html5-backend"; -import { FileResponse, FileType } from "../../../api/explorer.ts"; +import { FileResponse, FileType, NavigatorCapability } from "../../../api/explorer.ts"; import { setDragging } from "../../../redux/globalStateSlice.ts"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; import { processDnd } from "../../../redux/thunks/file.ts"; @@ -9,6 +9,7 @@ import { getFileLinkedUri, mergeRefs } from "../../../util"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; +import Boolset from "../../../util/boolset.ts"; import CrUri, { Filesystem } from "../../../util/uri.ts"; import { FileBlockProps } from "../Explorer/Explorer.tsx"; import { FileManagerIndex } from "../FileManager.tsx"; @@ -66,7 +67,11 @@ export const useFileDrag = ({ file, includeSelected, dropUri }: UseFileDragProps } const crUri = new CrUri(file.path); - return file.owned && crUri.fs() != Filesystem.share; + if (crUri.fs() == Filesystem.share) { + // Moving a file out of a share folder requires delete permission on it. + return !!file.capability && new Boolset(file.capability).enabled(NavigatorCapability.delete_file); + } + return !!file.owned; }, collect: (monitor) => ({ isDragging: monitor.isDragging(), diff --git a/frontend/src/component/FileManager/Explorer/SingleFileView.tsx b/frontend/src/component/FileManager/Explorer/SingleFileView.tsx index 83018002..41135df9 100644 --- a/frontend/src/component/FileManager/Explorer/SingleFileView.tsx +++ b/frontend/src/component/FileManager/Explorer/SingleFileView.tsx @@ -262,9 +262,11 @@ const SingleFileView = forwardRef((_props, ref: React.Ref) => { )} - + {!shareInfo.preview_only && ( + + )} diff --git a/frontend/src/redux/thunks/share.ts b/frontend/src/redux/thunks/share.ts index a5ad74e7..fcdbe4b5 100644 --- a/frontend/src/redux/thunks/share.ts +++ b/frontend/src/redux/thunks/share.ts @@ -30,6 +30,10 @@ export function createOrUpdateShareLink( password: setting.password, share_view: setting.share_view, show_readme: setting.show_readme, + allow_upload: setting.allow_upload || setting.allow_edit, + allow_edit: setting.allow_edit, + preview_only: setting.preview_only, + upload_only: setting.upload_only, downloads: setting.downloads && setting.downloads_val.value > 0 ? setting.downloads_val.value : undefined, expire: setting.expires && setting.expires_val.value > 0 ? setting.expires_val.value : undefined, }; diff --git a/inventory/types/types.go b/inventory/types/types.go index 8af1614c..d6162ceb 100644 --- a/inventory/types/types.go +++ b/inventory/types/types.go @@ -225,6 +225,14 @@ type ( ShareView bool `json:"share_view,omitempty"` // Whether to automatically show readme file in share view ShowReadMe bool `json:"show_read_me,omitempty"` + // Whether share visitors can upload new files into the shared folder + AllowUpload bool `json:"allow_upload,omitempty"` + // Whether share visitors can rename, move and delete files (implies upload) + AllowEdit bool `json:"allow_edit,omitempty"` + // Whether share visitors can browse and preview but not download + PreviewOnly bool `json:"preview_only,omitempty"` + // Whether share visitors can upload but cannot list or download (drop box) + UploadOnly bool `json:"upload_only,omitempty"` } OAuthClientProps struct { diff --git a/pkg/filemanager/fs/dbfs/dbfs.go b/pkg/filemanager/fs/dbfs/dbfs.go index e350d932..ef2d4581 100644 --- a/pkg/filemanager/fs/dbfs/dbfs.go +++ b/pkg/filemanager/fs/dbfs/dbfs.go @@ -40,8 +40,23 @@ const ( type ( ContextHintCtxKey struct{} ByPassOwnerCheckCtxKey struct{} + // IsDownloadCtxKey marks the request as an explicit file download (as + // opposed to an inline preview fetch). Navigator hooks consult it. + IsDownloadCtxKey struct{} ) +// writePermitted reports whether the user may mutate file under the given +// capability. File owners are always permitted; non-owners (e.g. share +// visitors) require the capability in the file's resolved capability set, +// which for share file systems is derived from the share's props. +func (f *DBFS) writePermitted(file *File, capability NavigatorCapability) bool { + if file.Owner().ID == f.user.ID { + return true + } + caps := file.Capabilities() + return caps != nil && caps.Enabled(int(capability)) +} + func NewDatabaseFS(u *ent.User, fileClient inventory.FileClient, shareClient inventory.ShareClient, l logging.Logger, ls lock.LockSystem, settingClient setting.Provider, storagePolicyClient inventory.StoragePolicyClient, hasher hashid.Encoder, userClient inventory.UserClient, @@ -451,7 +466,7 @@ func (f *DBFS) Get(ctx context.Context, path *fs.URI, opts ...fs.Option) (fs.Fil // Calculate folder summary if requested if o.loadFolderSummary && target != nil && target.Type() == types.FileTypeFolder { - if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && target.OwnerID() != f.user.ID { + if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && !f.writePermitted(target, NavigatorCapabilityRenameFile) { return nil, fs.ErrOwnerOnly } @@ -804,6 +819,11 @@ func generateSavePath(policy *ent.StoragePolicy, req *fs.UploadRequest, user *en func canMoveOrCopyTo(src, dst *fs.URI, isCopy bool) bool { if isCopy { + if src.FileSystem() == constants.FileSystemShare { + // Copy within the same share is allowed; write permission on the + // destination is enforced separately. + return dst.FileSystem() == constants.FileSystemShare && src.ID("") != "" && src.ID("") == dst.ID("") + } return src.FileSystem() == dst.FileSystem() && src.FileSystem() == constants.FileSystemMy } else { switch src.FileSystem() { @@ -811,7 +831,9 @@ func canMoveOrCopyTo(src, dst *fs.URI, isCopy bool) bool { return dst.FileSystem() == constants.FileSystemMy || dst.FileSystem() == constants.FileSystemTrash case constants.FileSystemTrash: return dst.FileSystem() == constants.FileSystemMy - + case constants.FileSystemShare: + // Move within the same share is allowed; cross-share moves are not. + return dst.FileSystem() == constants.FileSystemShare && src.ID("") != "" && src.ID("") == dst.ID("") } } diff --git a/pkg/filemanager/fs/dbfs/lock.go b/pkg/filemanager/fs/dbfs/lock.go index cee880f4..c4c3a99b 100644 --- a/pkg/filemanager/fs/dbfs/lock.go +++ b/pkg/filemanager/fs/dbfs/lock.go @@ -76,7 +76,7 @@ func (f *DBFS) Lock(ctx context.Context, d time.Duration, requester *ent.User, z } // Lock require create or update permission - if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && ancestor.Owner().ID != requester.ID { + if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && !f.writePermitted(ancestor, NavigatorCapabilityLockFile) { return nil, fs.ErrOwnerOnly } diff --git a/pkg/filemanager/fs/dbfs/manage.go b/pkg/filemanager/fs/dbfs/manage.go index 48a38c09..5e870cb9 100644 --- a/pkg/filemanager/fs/dbfs/manage.go +++ b/pkg/filemanager/fs/dbfs/manage.go @@ -57,7 +57,7 @@ func (f *DBFS) Create(ctx context.Context, path *fs.URI, fileType types.FileType WithError(fmt.Errorf("object with the same name but different type %v already exist", ancestor.Type())) } - if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && ancestor.Owner().ID != f.user.ID { + if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && !f.writePermitted(ancestor, NavigatorCapabilityCreateFile) { return nil, fs.ErrOwnerOnly } @@ -162,7 +162,7 @@ func (f *DBFS) Rename(ctx context.Context, path *fs.URI, newName string) (fs.Fil } oldName := target.Name() - if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && target.Owner().ID != f.user.ID { + if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && !f.writePermitted(target, NavigatorCapabilityRenameFile) { return nil, nil, fs.ErrOwnerOnly } @@ -265,7 +265,7 @@ func (f *DBFS) SoftDelete(ctx context.Context, path ...*fs.URI) error { continue } - if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && target.Owner().ID != f.user.ID { + if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && !f.writePermitted(target, NavigatorCapabilitySoftDelete) { ae.Add(p.String(), fs.ErrOwnerOnly.WithError(fmt.Errorf("only file owner can delete file without trash bin"))) continue } @@ -359,7 +359,7 @@ func (f *DBFS) Delete(ctx context.Context, path []*fs.URI, opts ...fs.Option) ([ continue } - if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !o.SysSkipSoftDelete && !ok && target.Owner().ID != f.user.ID { + if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !o.SysSkipSoftDelete && !ok && !f.writePermitted(target, NavigatorCapabilityDeleteFile) { ae.Add(p.String(), fs.ErrOwnerOnly) continue } @@ -546,7 +546,7 @@ func (f *DBFS) MoveOrCopy(ctx context.Context, path []*fs.URI, dst *fs.URI, isCo return nil, fmt.Errorf("faield to get destination folder: %w", err) } - if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && destination.Owner().ID != f.user.ID { + if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && !f.writePermitted(destination, NavigatorCapabilityCreateFile) { return nil, fs.ErrOwnerOnly } @@ -582,7 +582,13 @@ func (f *DBFS) MoveOrCopy(ctx context.Context, path []*fs.URI, dst *fs.URI, isCo continue } - if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && target.Owner().ID != f.user.ID { + // Copy reads the source, move deletes it. + requiredSrcCap := NavigatorCapabilityDownloadFile + if !isCopy { + requiredSrcCap = NavigatorCapabilityDeleteFile + } + if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && + !f.writePermitted(target, requiredSrcCap) { ae.Add(p.String(), fs.ErrOwnerOnly) continue } diff --git a/pkg/filemanager/fs/dbfs/navigator.go b/pkg/filemanager/fs/dbfs/navigator.go index db642fb2..d98735e4 100644 --- a/pkg/filemanager/fs/dbfs/navigator.go +++ b/pkg/filemanager/fs/dbfs/navigator.go @@ -125,11 +125,18 @@ func init() { NavigatorCapabilityEnterFolder: true, NavigatorCapabilityModifyProps: true, }, myNavigatorCapability) + // Static superset admitting every action a share's props may grant; + // per-share props narrow it after share resolution. boolset.Sets(map[NavigatorCapability]bool{ + NavigatorCapabilityCreateFile: true, + NavigatorCapabilityRenameFile: true, + NavigatorCapabilityUploadFile: true, NavigatorCapabilityDownloadFile: true, NavigatorCapabilityListChildren: true, NavigatorCapabilityGenerateThumb: true, + NavigatorCapabilityDeleteFile: true, NavigatorCapabilityLockFile: true, + NavigatorCapabilitySoftDelete: true, NavigatorCapabilityInfo: true, NavigatorCapabilityVersionControl: true, NavigatorCapabilityEnterFolder: true, diff --git a/pkg/filemanager/fs/dbfs/share_collaboration_test.go b/pkg/filemanager/fs/dbfs/share_collaboration_test.go new file mode 100644 index 00000000..944697a1 --- /dev/null +++ b/pkg/filemanager/fs/dbfs/share_collaboration_test.go @@ -0,0 +1,194 @@ +package dbfs + +import ( + "context" + "testing" + + "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/boolset" + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/stretchr/testify/require" +) + +func shareWithProps(props *types.ShareProps) *ent.Share { + return &ent.Share{Props: props} +} + +func TestShareCapabilities(t *testing.T) { + capsOf := func(props *types.ShareProps) *boolset.BooleanSet { + n := &shareNavigator{share: shareWithProps(props)} + return n.shareCapabilities() + } + enabled := func(bs *boolset.BooleanSet, c NavigatorCapability) bool { + return bs.Enabled(int(c)) + } + + t.Run("no props grants read only", func(t *testing.T) { + caps := capsOf(nil) + require.True(t, enabled(caps, NavigatorCapabilityListChildren)) + require.True(t, enabled(caps, NavigatorCapabilityDownloadFile)) + require.True(t, enabled(caps, NavigatorCapabilityEnterFolder)) + require.False(t, enabled(caps, NavigatorCapabilityUploadFile)) + require.False(t, enabled(caps, NavigatorCapabilityCreateFile)) + require.False(t, enabled(caps, NavigatorCapabilityRenameFile)) + require.False(t, enabled(caps, NavigatorCapabilityDeleteFile)) + require.False(t, enabled(caps, NavigatorCapabilitySoftDelete)) + }) + + t.Run("allow upload grants upload create and lock", func(t *testing.T) { + caps := capsOf(&types.ShareProps{AllowUpload: true}) + require.True(t, enabled(caps, NavigatorCapabilityUploadFile)) + require.True(t, enabled(caps, NavigatorCapabilityCreateFile)) + require.True(t, enabled(caps, NavigatorCapabilityLockFile)) + require.True(t, enabled(caps, NavigatorCapabilityDownloadFile)) + require.True(t, enabled(caps, NavigatorCapabilityListChildren)) + require.False(t, enabled(caps, NavigatorCapabilityRenameFile)) + require.False(t, enabled(caps, NavigatorCapabilityDeleteFile)) + }) + + t.Run("allow edit grants all write caps", func(t *testing.T) { + caps := capsOf(&types.ShareProps{AllowEdit: true}) + require.True(t, enabled(caps, NavigatorCapabilityUploadFile)) + require.True(t, enabled(caps, NavigatorCapabilityCreateFile)) + require.True(t, enabled(caps, NavigatorCapabilityLockFile)) + require.True(t, enabled(caps, NavigatorCapabilityRenameFile)) + require.True(t, enabled(caps, NavigatorCapabilityDeleteFile)) + require.True(t, enabled(caps, NavigatorCapabilitySoftDelete)) + }) + + t.Run("preview only keeps read caps", func(t *testing.T) { + caps := capsOf(&types.ShareProps{PreviewOnly: true}) + require.True(t, enabled(caps, NavigatorCapabilityDownloadFile)) + require.True(t, enabled(caps, NavigatorCapabilityListChildren)) + require.False(t, enabled(caps, NavigatorCapabilityUploadFile)) + }) + + t.Run("upload only strips read and edit", func(t *testing.T) { + caps := capsOf(&types.ShareProps{UploadOnly: true}) + require.True(t, enabled(caps, NavigatorCapabilityUploadFile)) + require.True(t, enabled(caps, NavigatorCapabilityCreateFile)) + require.True(t, enabled(caps, NavigatorCapabilityLockFile)) + require.False(t, enabled(caps, NavigatorCapabilityDownloadFile)) + require.False(t, enabled(caps, NavigatorCapabilityListChildren)) + }) + + t.Run("upload only wins over edit", func(t *testing.T) { + caps := capsOf(&types.ShareProps{UploadOnly: true, AllowEdit: true}) + require.True(t, enabled(caps, NavigatorCapabilityUploadFile)) + require.False(t, enabled(caps, NavigatorCapabilityDownloadFile)) + require.False(t, enabled(caps, NavigatorCapabilityListChildren)) + require.False(t, enabled(caps, NavigatorCapabilityRenameFile)) + require.False(t, enabled(caps, NavigatorCapabilityDeleteFile)) + require.False(t, enabled(caps, NavigatorCapabilitySoftDelete)) + }) +} + +func TestCanMoveOrCopyTo(t *testing.T) { + shareA := func() *fs.URI { + u, err := fs.NewUriFromString("cloudreve://aaa@share/folder") + require.NoError(t, err) + return u + } + shareB := func() *fs.URI { + u, err := fs.NewUriFromString("cloudreve://bbb@share/folder") + require.NoError(t, err) + return u + } + my := func() *fs.URI { + u, err := fs.NewUriFromString("cloudreve://my/folder") + require.NoError(t, err) + return u + } + trash := func() *fs.URI { + u, err := fs.NewUriFromString("cloudreve://trash/folder") + require.NoError(t, err) + return u + } + + require.True(t, canMoveOrCopyTo(shareA(), shareA(), true), "same-share copy") + require.True(t, canMoveOrCopyTo(shareA(), shareA(), false), "same-share move") + require.False(t, canMoveOrCopyTo(shareA(), shareB(), true), "cross-share copy") + require.False(t, canMoveOrCopyTo(shareA(), shareB(), false), "cross-share move") + require.False(t, canMoveOrCopyTo(shareA(), my(), true), "share to my copy") + require.False(t, canMoveOrCopyTo(shareA(), my(), false), "share to my move") + require.False(t, canMoveOrCopyTo(my(), shareA(), true), "my to share copy") + require.False(t, canMoveOrCopyTo(my(), shareA(), false), "my to share move") + require.True(t, canMoveOrCopyTo(my(), my(), true), "my copy") + require.True(t, canMoveOrCopyTo(my(), my(), false), "my move") + require.True(t, canMoveOrCopyTo(my(), trash(), false), "my to trash move") + require.False(t, canMoveOrCopyTo(my(), trash(), true), "my to trash copy") + require.True(t, canMoveOrCopyTo(trash(), my(), false), "trash restore") +} + +func TestWritePermitted(t *testing.T) { + owner := &ent.User{ID: 1} + visitor := &ent.User{ID: 2} + + newOwnedFile := func(caps *boolset.BooleanSet) *File { + f := newFile(nil, &ent.File{ID: 10, Name: "f", OwnerID: owner.ID}) + f.OwnerModel = owner + f.CapabilitiesBs = caps + return f + } + + writeCaps := &boolset.BooleanSet{} + boolset.Set(int(NavigatorCapabilityRenameFile), true, writeCaps) + readCaps := &boolset.BooleanSet{} + boolset.Set(int(NavigatorCapabilityDownloadFile), true, readCaps) + + ownerFS := &DBFS{user: owner} + visitorFS := &DBFS{user: visitor} + + require.True(t, ownerFS.writePermitted(newOwnedFile(nil), NavigatorCapabilityRenameFile), "owner always permitted") + require.True(t, visitorFS.writePermitted(newOwnedFile(writeCaps), NavigatorCapabilityRenameFile), "visitor with cap") + require.False(t, visitorFS.writePermitted(newOwnedFile(readCaps), NavigatorCapabilityRenameFile), "visitor without cap") + require.False(t, visitorFS.writePermitted(newOwnedFile(nil), NavigatorCapabilityRenameFile), "visitor nil caps") +} + +type downloadCountShareClient struct { + inventory.ShareClient + calls int + err error +} + +func (c *downloadCountShareClient) Downloaded(_ context.Context, _ *ent.Share) error { + c.calls++ + return c.err +} + +func TestShareNavigatorExecuteHookPreviewOnly(t *testing.T) { + newNav := func(props *types.ShareProps) (*shareNavigator, *downloadCountShareClient) { + sc := &downloadCountShareClient{} + return &shareNavigator{ + l: logging.NewConsoleLogger(logging.LevelError), + shareClient: sc, + share: shareWithProps(props), + }, sc + } + + t.Run("explicit download denied", func(t *testing.T) { + n, sc := newNav(&types.ShareProps{PreviewOnly: true}) + ctx := context.WithValue(context.Background(), IsDownloadCtxKey{}, true) + err := n.ExecuteHook(ctx, fs.HookTypeBeforeDownload, nil) + require.ErrorIs(t, err, ErrShareDownloadDisabled) + require.Zero(t, sc.calls, "denied downloads must not bump the counter") + }) + + t.Run("preview fetch allowed", func(t *testing.T) { + n, sc := newNav(&types.ShareProps{PreviewOnly: true}) + err := n.ExecuteHook(context.Background(), fs.HookTypeBeforeDownload, nil) + require.NoError(t, err) + require.Equal(t, 1, sc.calls) + }) + + t.Run("normal share counts download", func(t *testing.T) { + n, sc := newNav(&types.ShareProps{}) + ctx := context.WithValue(context.Background(), IsDownloadCtxKey{}, true) + err := n.ExecuteHook(ctx, fs.HookTypeBeforeDownload, nil) + require.NoError(t, err) + require.Equal(t, 1, sc.calls) + }) +} diff --git a/pkg/filemanager/fs/dbfs/share_navigator.go b/pkg/filemanager/fs/dbfs/share_navigator.go index be5f688d..0eb36de6 100644 --- a/pkg/filemanager/fs/dbfs/share_navigator.go +++ b/pkg/filemanager/fs/dbfs/share_navigator.go @@ -18,14 +18,22 @@ import ( ) var ( - ErrShareNotFound = serializer.NewError(serializer.CodeNotFound, "Shared file does not exist", nil) - ErrNotPurchased = serializer.NewError(serializer.CodePurchaseRequired, "You need to purchased this share", nil) + ErrShareNotFound = serializer.NewError(serializer.CodeNotFound, "Shared file does not exist", nil) + ErrNotPurchased = serializer.NewError(serializer.CodePurchaseRequired, "You need to purchased this share", nil) + ErrShareDownloadDisabled = serializer.NewError(serializer.CodeNoPermissionErr, "Download is disabled for this share", nil) + ErrShareOperationDisabled = serializer.NewError(serializer.CodeNoPermissionErr, "This operation is not allowed for this share", nil) ) const ( PurchaseTicketHeader = constants.CrHeaderPrefix + "Purchase-Ticket" ) +// shareNavigatorCapability is the static capability superset of the share +// file system, populated in init() alongside the other navigator sets. The +// navigator-level gate in getNavigator runs before the share is resolved, so +// it must admit every action a share may grant. The effective per-share +// permission is enforced after share resolution via the capability set +// stamped onto resolved files (see Capabilities) and writePermitted. var shareNavigatorCapability = &boolset.BooleanSet{} // NewShareNavigator creates a navigator for user's "shared" file system. @@ -131,6 +139,9 @@ func (n *shareNavigator) Root(ctx context.Context, path *fs.URI) (*File, error) return nil, ErrShareIncorrectPassword } + // Share must be assigned before capabilities are derived from its props. + n.share = share + // Share permission setting should overwrite root folder's permission n.shareRoot = newFile(nil, share.Edges.File) @@ -174,7 +185,6 @@ func (n *shareNavigator) Root(ctx context.Context, path *fs.URI) (*File, error) n.ownerRoot = ownerRoot n.ownerRoot.Path[pathIndexRoot] = newMyIDUri(hashid.EncodeUserID(n.hasher, n.owner.ID)) - n.share = share return n.shareRoot, nil } @@ -240,6 +250,14 @@ func (n *shareNavigator) Children(ctx context.Context, parent *File, args *ListA }, nil } + // Drop-box shares accept uploads but never list existing content. + if n.share != nil && n.share.Props != nil && n.share.Props.UploadOnly { + return &ListResult{ + Files: []*File{}, + Pagination: &inventory.PaginationResults{}, + }, nil + } + return n.baseNavigator.children(ctx, parent, args) } @@ -267,6 +285,12 @@ func (n *shareNavigator) Capabilities(isSearching bool) *fs.NavigatorProps { MaxPageSize: n.config.MaxPageSize, } + // Once the share is resolved, narrow capabilities to what its props grant. + // This set is stamped onto resolved files and consulted by writePermitted. + if n.share != nil { + res.Capability = n.shareCapabilities() + } + if isSearching { res.OrderByOptions = nil res.OrderDirectionOptions = nil @@ -275,6 +299,57 @@ func (n *shareNavigator) Capabilities(isSearching bool) *fs.NavigatorProps { return res } +// shareCapabilities derives the effective capability set from share props. +func (n *shareNavigator) shareCapabilities() *boolset.BooleanSet { + bs := &boolset.BooleanSet{} + boolset.Sets(map[NavigatorCapability]bool{ + NavigatorCapabilityListChildren: true, + NavigatorCapabilityDownloadFile: true, + NavigatorCapabilityEnterFolder: true, + NavigatorCapabilityInfo: true, + NavigatorCapabilityGenerateThumb: true, + }, bs) + + props := n.share.Props + if props == nil { + return bs + } + + // Drop-box shares accept uploads but deny any read or edit of existing + // content; UploadOnly implies upload access and wins over edit grants. + // It is folder-only: on a single-file share it would strip download from + // the shared file itself. + if props.UploadOnly && !n.singleFileShare { + boolset.Sets(map[NavigatorCapability]bool{ + NavigatorCapabilityListChildren: false, + NavigatorCapabilityDownloadFile: false, + NavigatorCapabilityUploadFile: true, + NavigatorCapabilityCreateFile: true, + NavigatorCapabilityLockFile: true, + NavigatorCapabilityRenameFile: false, + NavigatorCapabilityDeleteFile: false, + NavigatorCapabilitySoftDelete: false, + }, bs) + return bs + } + + // PreviewOnly keeps DownloadFile so viewers can fetch entities; the + // download action itself is denied in ExecuteHook when the request is an + // explicit download. + if props.AllowUpload || props.AllowEdit { + boolset.Set(int(NavigatorCapabilityUploadFile), true, bs) + boolset.Set(int(NavigatorCapabilityCreateFile), true, bs) + boolset.Set(int(NavigatorCapabilityLockFile), true, bs) + } + if props.AllowEdit { + boolset.Set(int(NavigatorCapabilityRenameFile), true, bs) + boolset.Set(int(NavigatorCapabilityDeleteFile), true, bs) + boolset.Set(int(NavigatorCapabilitySoftDelete), true, bs) + } + + return bs +} + func (n *shareNavigator) FollowTx(ctx context.Context) (func(), error) { if _, ok := ctx.Value(inventory.TxCtx{}).(*inventory.Tx); !ok { return nil, fmt.Errorf("navigator: no inherited transaction found in context") @@ -302,7 +377,16 @@ func (n *shareNavigator) FollowTx(ctx context.Context) (func(), error) { func (n *shareNavigator) ExecuteHook(ctx context.Context, hookType fs.HookType, file *File) error { switch hookType { case fs.HookTypeBeforeDownload: - return n.shareClient.Downloaded(ctx, n.share) + // Preview-only shares deny explicit downloads but still allow + // entity fetches for inline viewers. + if n.share != nil && n.share.Props != nil && n.share.Props.PreviewOnly { + if isDownload, _ := ctx.Value(IsDownloadCtxKey{}).(bool); isDownload { + return ErrShareDownloadDisabled + } + } + if err := n.shareClient.Downloaded(ctx, n.share); err != nil { + n.l.Warning("Failed to increase share download count: %s", err) + } } return nil } diff --git a/pkg/filemanager/fs/dbfs/upload.go b/pkg/filemanager/fs/dbfs/upload.go index 29e9378f..b871ddd3 100644 --- a/pkg/filemanager/fs/dbfs/upload.go +++ b/pkg/filemanager/fs/dbfs/upload.go @@ -34,8 +34,8 @@ func (f *DBFS) PreValidateUpload(ctx context.Context, dst *fs.URI, files ...fs.P } // check ownership - if f.user.ID != dstFile.OwnerID() { - return fmt.Errorf("failed to evaluate permission: %w", err) + if !f.writePermitted(dstFile, NavigatorCapabilityUploadFile) { + return fmt.Errorf("failed to evaluate permission: %w", fs.ErrOwnerOnly) } total := int64(0) @@ -107,7 +107,11 @@ func (f *DBFS) PrepareUpload(ctx context.Context, req *fs.UploadRequest, opts .. return nil, fs.ErrPathNotExist } - if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && ancestor.OwnerID() != f.user.ID { + requiredWriteCap := NavigatorCapabilityUploadFile + if fileExisted { + requiredWriteCap = NavigatorCapabilityRenameFile + } + if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && !f.writePermitted(ancestor, requiredWriteCap) { return nil, fs.ErrOwnerOnly } @@ -409,7 +413,7 @@ func (f *DBFS) CancelUploadSession(ctx context.Context, path *fs.URI, sessionID } } - if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && filePrivate.OwnerID() != f.user.ID { + if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && !f.writePermitted(filePrivate, NavigatorCapabilityUploadFile) { return nil, nil, fs.ErrOwnerOnly } diff --git a/pkg/filemanager/manager/entity.go b/pkg/filemanager/manager/entity.go index 27ce4108..12119c87 100644 --- a/pkg/filemanager/manager/entity.go +++ b/pkg/filemanager/manager/entity.go @@ -91,9 +91,12 @@ func (m *manager) GetDirectLink(ctx context.Context, urls ...*fs.URI) ([]DirectL continue } - // Hooks for entity download + // Hooks for entity download; failures deny the link. Direct links are + // always explicit downloads. + ctx = context.WithValue(ctx, dbfs.IsDownloadCtxKey{}, true) if err := m.fs.ExecuteNavigatorHooks(ctx, fs.HookTypeBeforeDownload, file); err != nil { - m.l.Warning("Failed to execute navigator hooks: %s", err) + ae.Add(url.String(), err) + continue } if useRedirect { @@ -222,6 +225,14 @@ func (m *manager) GetEntityUrls(ctx context.Context, args []GetEntityUrlArgs, op continue } + // The navigator-level capability check runs before a share is resolved; + // the resolved file's capability set is authoritative (e.g. drop-box + // shares strip DownloadFile from it). + if caps := file.Capabilities(); caps == nil || !caps.Enabled(int(dbfs.NavigatorCapabilityDownloadFile)) { + ae.Add(arg.URI.String(), fs.ErrNotSupportedAction.WithError(fmt.Errorf("download is not allowed"))) + continue + } + if file.Type() != types.FileTypeFile { ae.Add(arg.URI.String(), fs.ErrEntityNotExist) continue @@ -246,9 +257,14 @@ func (m *manager) GetEntityUrls(ctx context.Context, args []GetEntityUrlArgs, op } } - // Hooks for entity download + // Hooks for entity download; failures deny the URL. Preview-only + // shares deny explicit downloads here via the IsDownload flag. + if o.IsDownload { + ctx = context.WithValue(ctx, dbfs.IsDownloadCtxKey{}, true) + } if err := m.fs.ExecuteNavigatorHooks(ctx, fs.HookTypeBeforeDownload, file); err != nil { - m.l.Warning("Failed to execute navigator hooks: %s", err) + ae.Add(arg.URI.String(), err) + continue } policy, d, err := m.getEntityPolicyDriver(ctx, target, nil) diff --git a/pkg/filemanager/manager/manager.go b/pkg/filemanager/manager/manager.go index 31b2ec7a..2f022f7a 100644 --- a/pkg/filemanager/manager/manager.go +++ b/pkg/filemanager/manager/manager.go @@ -123,6 +123,10 @@ type ( Expire *time.Time ShareView bool ShowReadMe bool + AllowUpload bool + AllowEdit bool + PreviewOnly bool + UploadOnly bool } FullTextSearchResults struct { diff --git a/pkg/filemanager/manager/operation.go b/pkg/filemanager/manager/operation.go index 56f787df..ca87f280 100644 --- a/pkg/filemanager/manager/operation.go +++ b/pkg/filemanager/manager/operation.go @@ -322,8 +322,12 @@ func (l *manager) CreateOrUpdateShare(ctx context.Context, path *fs.URI, args *C } props := &types.ShareProps{ - ShareView: args.ShareView, - ShowReadMe: args.ShowReadMe, + ShareView: args.ShareView, + ShowReadMe: args.ShowReadMe, + AllowUpload: args.AllowUpload || args.AllowEdit, + AllowEdit: args.AllowEdit, + PreviewOnly: args.PreviewOnly, + UploadOnly: args.UploadOnly, } share, err := shareClient.Upsert(ctx, &inventory.CreateShareParams{ diff --git a/service/explorer/response.go b/service/explorer/response.go index 92de9bb5..7b4688d8 100644 --- a/service/explorer/response.go +++ b/service/explorer/response.go @@ -335,9 +335,13 @@ type Share struct { Size int64 `json:"size"` // Only viewable by owner - IsPrivate bool `json:"is_private,omitempty"` - Password string `json:"password,omitempty"` - ShareView bool `json:"share_view,omitempty"` + IsPrivate bool `json:"is_private,omitempty"` + Password string `json:"password,omitempty"` + ShareView bool `json:"share_view,omitempty"` + AllowUpload bool `json:"allow_upload,omitempty"` + AllowEdit bool `json:"allow_edit,omitempty"` + PreviewOnly bool `json:"preview_only,omitempty"` + UploadOnly bool `json:"upload_only,omitempty"` // Only viewable if explicitly unlocked by owner SourceUri string `json:"source_uri,omitempty"` @@ -369,6 +373,13 @@ func BuildShare(ctx context.Context, s *ent.Share, base *url.URL, hasher hashid. res.Password = s.Password res.ShowReadMe = s.Props != nil && s.Props.ShowReadMe + if s.Props != nil { + // Visitors need these to render the correct affordances + // (e.g. hiding download on preview-only shares). + res.PreviewOnly = s.Props.PreviewOnly + res.UploadOnly = s.Props.UploadOnly + } + if t == types.FileTypeFile && s.Edges.File != nil { res.Size = s.Edges.File.Size } @@ -377,6 +388,12 @@ func BuildShare(ctx context.Context, s *ent.Share, base *url.URL, hasher hashid. if requester.ID == owner.ID { res.IsPrivate = s.Password != "" res.ShareView = s.Props != nil && s.Props.ShareView + if s.Props != nil { + res.AllowUpload = s.Props.AllowUpload + res.AllowEdit = s.Props.AllowEdit + res.PreviewOnly = s.Props.PreviewOnly + res.UploadOnly = s.Props.UploadOnly + } } return &res diff --git a/service/share/manage.go b/service/share/manage.go index f1121e7c..45ef01b1 100644 --- a/service/share/manage.go +++ b/service/share/manage.go @@ -27,6 +27,10 @@ type ( Expire int `json:"expire"` ShareView bool `json:"share_view"` ShowReadMe bool `json:"show_readme"` + AllowUpload bool `json:"allow_upload"` + AllowEdit bool `json:"allow_edit"` + PreviewOnly bool `json:"preview_only"` + UploadOnly bool `json:"upload_only"` } ShareCreateParamCtx struct{} @@ -90,6 +94,10 @@ func (service *ShareCreateService) Upsert(c *gin.Context, existed int) (string, ExistedShareID: existed, ShareView: service.ShareView, ShowReadMe: service.ShowReadMe, + AllowUpload: service.AllowUpload, + AllowEdit: service.AllowEdit, + PreviewOnly: service.PreviewOnly, + UploadOnly: service.UploadOnly, }) if err != nil { return "", err From cd7533ce1748ebb686dde7856db374eb50ec171c Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 17:54:37 +0200 Subject: [PATCH 2/2] docs(roadmap): mark B.1 share-collaboration increment shipped Authored By: TDvorak Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index ed55bdbd..11d62b97 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -124,6 +124,8 @@ cloudreve/ Go backend (existing code, repo root) Order = user-visible value first; each ships with backend + UI + tests. 1. **Share collaboration** — write/upload/delete via share link, anonymous upload, share ACL (users/groups), preview-only mode (fixes #3555, #3390, #3340, #3517, #3578; uses `NavigatorCapability` placeholder slots + `ShareProps` extension + `share` entity fields) + - [x] PR #140 — `allow_upload`/`allow_edit`/`preview_only`/`upload_only` props, props-derived capability sets enforced server-side (`writePermitted`), same-share move/copy, anonymous upload, drop-box listing suppression, download denial via `IsDownloadCtxKey` hooks (fixes #3555 preview-only, #3340 drop-box) + - [ ] Share ACL — per-user/group grants on shares (#3517); paid share links deferred to VAS phase; default shares for new users (#3390) open 2. **Storage policy advanced** — multiple policies per group (group→policies join table), per-directory binding, load-balancer policy, file migration between policies (fixes #3518, #2961, #2262) 3. **SSO** — generic OIDC provider (PR #3472 base), Logto connector, multi-account switching, sign-up email filtering (fixes #3464, #3056, #3505) 4. **VAS/monetization-free** — credits + redemption codes as *free* features (gift codes for admin use), storage/membership plan definitions; skip payment processor integration initially — YAGNI until a real user asks (fixes #3231)