Merge pull request #140 from Dvorinka/feature/share-collaboration

feat(share): visitor collaboration — upload, edit, preview-only and drop-box shares
pull/3582/head
Tomáš Dvořák 2 weeks ago committed by GitHub
commit f62488a354
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

@ -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</0> file (case-sensitive) in the directory will be automatically displayed for visitors.",
"viewSetting": "View setting",

@ -606,6 +606,14 @@
"unlinkOnlyDes": "仅删除文件记录,物理文件不会被删除",
"shareView": "分享视图设置",
"shareViewDes": "勾选后,其他用户访问此共享文件夹时可以看到你保存在服务器的视图设置 (布局、排序等)。",
"allowUpload": "允许上传",
"allowUploadDes": "访客可以在此共享文件夹中上传新文件和创建文件夹,存储计入你的配额。",
"allowEdit": "允许编辑",
"allowEditDes": "访客可以上传、重命名、移动和删除此共享文件夹中的文件,包含上传权限。",
"uploadOnly": "仅上传(收集箱)",
"uploadOnlyDes": "访客可以上传文件,但无法查看或下载此共享文件夹中的现有内容。",
"previewOnly": "仅预览",
"previewOnlyDes": "访客可以在线预览文件,但不能下载。",
"showReadme": "显示 README 文件",
"showReadmeDes": "勾选后,会自动为访问者展示目录下的 <0>README.md</0> (区分大小写) 文件。",
"viewSetting": "视图设置",

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

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

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

@ -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
)}
</AccordionDetails>
</Accordion>
<Accordion expanded={expanded === "preview_only"} onChange={handleExpand("preview_only")}>
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
<StyledListItemButton>
<ListItemIcon>
<EyeOff />
</ListItemIcon>
<ListItemText primary={t("application:modals.previewOnly")} />
<ListItemSecondaryAction>
<Checkbox checked={!!setting.preview_only} onChange={handleCheck("preview_only")} />
</ListItemSecondaryAction>
</StyledListItemButton>
</AccordionSummary>
<AccordionDetails>{t("application:modals.previewOnlyDes")}</AccordionDetails>
</Accordion>
{file?.type == FileType.folder && (
<>
<Accordion expanded={expanded === "allow_upload"} onChange={handleExpand("allow_upload")}>
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
<StyledListItemButton>
<ListItemIcon>
<Upload />
</ListItemIcon>
<ListItemText primary={t("application:modals.allowUpload")} />
<ListItemSecondaryAction>
<Checkbox
checked={!!setting.allow_upload || !!setting.allow_edit || !!setting.upload_only}
disabled={!!setting.allow_edit || !!setting.upload_only}
onChange={() => {
if (!setting.allow_upload) {
handleExpand("allow_upload")(null, true);
}
onSettingChange({ ...setting, allow_upload: !setting.allow_upload });
}}
/>
</ListItemSecondaryAction>
</StyledListItemButton>
</AccordionSummary>
<AccordionDetails>{t("application:modals.allowUploadDes")}</AccordionDetails>
</Accordion>
<Accordion expanded={expanded === "allow_edit"} onChange={handleExpand("allow_edit")}>
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
<StyledListItemButton>
<ListItemIcon>
<Edit />
</ListItemIcon>
<ListItemText primary={t("application:modals.allowEdit")} />
<ListItemSecondaryAction>
<Checkbox
checked={!!setting.allow_edit}
disabled={!!setting.upload_only}
onChange={() => {
if (!setting.allow_edit) {
handleExpand("allow_edit")(null, true);
}
onSettingChange({
...setting,
allow_edit: !setting.allow_edit,
allow_upload: true,
});
}}
/>
</ListItemSecondaryAction>
</StyledListItemButton>
</AccordionSummary>
<AccordionDetails>{t("application:modals.allowEditDes")}</AccordionDetails>
</Accordion>
<Accordion expanded={expanded === "upload_only"} onChange={handleExpand("upload_only")}>
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
<StyledListItemButton>
<ListItemIcon>
<FolderAdd />
</ListItemIcon>
<ListItemText primary={t("application:modals.uploadOnly")} />
<ListItemSecondaryAction>
<Checkbox
checked={!!setting.upload_only}
onChange={() => {
if (!setting.upload_only) {
handleExpand("upload_only")(null, true);
}
onSettingChange({
...setting,
upload_only: !setting.upload_only,
allow_edit: false,
});
}}
/>
</ListItemSecondaryAction>
</StyledListItemButton>
</AccordionSummary>
<AccordionDetails>{t("application:modals.uploadOnlyDes")}</AccordionDetails>
</Accordion>
<Accordion expanded={expanded === "share_view"} onChange={handleExpand("share_view")}>
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
<StyledListItemButton>

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

@ -262,9 +262,11 @@ const SingleFileView = forwardRef((_props, ref: React.Ref<any>) => {
</SecondaryButton>
)}
<ButtonGroup disableElevation variant="contained">
{!shareInfo.preview_only && (
<Button onClick={download} disabled={loading} startIcon={<Download />}>
{t("application:fileManager.download")}
</Button>
)}
<Button size="small" onClick={openMore}>
<CaretDown sx={{ fontSize: "12px!important" }} />
</Button>

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

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

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

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

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

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

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

@ -20,12 +20,20 @@ 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)
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
}

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

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

@ -123,6 +123,10 @@ type (
Expire *time.Time
ShareView bool
ShowReadMe bool
AllowUpload bool
AllowEdit bool
PreviewOnly bool
UploadOnly bool
}
FullTextSearchResults struct {

@ -324,6 +324,10 @@ func (l *manager) CreateOrUpdateShare(ctx context.Context, path *fs.URI, args *C
props := &types.ShareProps{
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{

@ -338,6 +338,10 @@ type Share struct {
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

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

Loading…
Cancel
Save