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 <info@tdvorak.dev>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3579/head
Tomas Dvorak 2 weeks ago
parent 021ea073b3
commit 14e23a4c6d

@ -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">
<Button onClick={download} disabled={loading} startIcon={<Download />}>
{t("application:fileManager.download")}
</Button>
{!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)
})
}

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

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

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

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

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