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. 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) 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) 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) 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) 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.", "unlinkOnlyDes": "Delete file records only, physical files will not be deleted.",
"shareView": "Share view setting", "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.", "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", "showReadme": "Show README file",
"showReadmeDes": "If selected, the <0>README.md</0> file (case-sensitive) in the directory will be automatically displayed for visitors.", "showReadmeDes": "If selected, the <0>README.md</0> file (case-sensitive) in the directory will be automatically displayed for visitors.",
"viewSetting": "View setting", "viewSetting": "View setting",

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

@ -90,6 +90,10 @@ export interface Share {
source_uri?: string; source_uri?: string;
password?: string; password?: string;
show_readme?: boolean; show_readme?: boolean;
allow_upload?: boolean;
allow_edit?: boolean;
preview_only?: boolean;
upload_only?: boolean;
} }
export enum PolicyType { export enum PolicyType {
@ -308,6 +312,10 @@ export interface ShareCreateService {
expire?: number; expire?: number;
share_view?: boolean; share_view?: boolean;
show_readme?: boolean; show_readme?: boolean;
allow_upload?: boolean;
allow_edit?: boolean;
preview_only?: boolean;
upload_only?: boolean;
} }
export interface CreateFileService { export interface CreateFileService {

@ -26,11 +26,12 @@ export const canShowInfo = (cap: Boolset) => {
}; };
export const canUpdate = (opt: DisplayOption) => { export const canUpdate = (opt: DisplayOption) => {
// Overwriting an existing file requires the rename capability server-side.
return !!( return !!(
opt.allUpdatable && opt.allUpdatable &&
opt.hasFile && opt.hasFile &&
opt.orCapability?.enabled(NavigatorCapability.upload_file) && 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); 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.showCreateFile = display.showCreateFolder && fmIndex == FileManagerIndex.main;
display.showUpload = display.showCreateFile; display.showUpload = display.showCreateFile;
if (display.showCreateFile) { if (display.showCreateFile) {
@ -138,9 +141,12 @@ export const getActionOpt = (
} }
const parentUrl = new CrUri(targets?.[0]?.path ?? defaultPath); const parentUrl = new CrUri(targets?.[0]?.path ?? defaultPath);
const inShare = parentUrl.fs() == Filesystem.share;
targets.forEach((target) => { targets.forEach((target) => {
let readable = true; const readable = true;
let updatable = target.owned && parentUrl.fs() != Filesystem.share; // 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) { if (display.allReadable && !readable) {
display.allReadable = false; display.allReadable = false;
@ -210,7 +216,11 @@ export const getActionOpt = (
display.allUpdatable && display.allUpdatable &&
display.orCapability && display.orCapability &&
display.orCapability.enabled(NavigatorCapability.rename_file); 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 = display.showShare =
targets.length == 1 && targets.length == 1 &&
!!currentUser && !!currentUser &&
@ -221,7 +231,11 @@ export const getActionOpt = (
display.orCapability.enabled(NavigatorCapability.share) && display.orCapability.enabled(NavigatorCapability.share) &&
(!targets[0].metadata || (!targets[0].metadata ||
(!targets[0].metadata[Metadata.share_redirect] && !targets[0].metadata[Metadata.restore_uri])); (!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.showTags =
display.hasUpdatable && display.orCapability && display.orCapability.enabled(NavigatorCapability.update_metadata); display.hasUpdatable && display.orCapability && display.orCapability.enabled(NavigatorCapability.update_metadata);
display.showChangeFolderColor = display.showChangeFolderColor =

@ -34,6 +34,10 @@ const shareToSetting = (share: ShareModel, t: TFunction): ShareSetting => {
use_custom_password: true, use_custom_password: true,
share_view: share.share_view, share_view: share.share_view,
show_readme: share.show_readme, 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, downloads: share.remain_downloads != undefined && share.remain_downloads > 0,
expires_val: expireOptions[2], expires_val: expireOptions[2],

@ -24,9 +24,13 @@ import { Code } from "../../../Common/Code.tsx";
import { FilledTextField, SmallFormControlLabel } from "../../../Common/StyledComponents.tsx"; import { FilledTextField, SmallFormControlLabel } from "../../../Common/StyledComponents.tsx";
import BookInformation from "../../../Icons/BookInformation.tsx"; import BookInformation from "../../../Icons/BookInformation.tsx";
import ClockArrowDownload from "../../../Icons/ClockArrowDownload.tsx"; import ClockArrowDownload from "../../../Icons/ClockArrowDownload.tsx";
import Edit from "../../../Icons/Edit.tsx";
import Eye from "../../../Icons/Eye.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 TableSettingsOutlined from "../../../Icons/TableSettings.tsx";
import Timer from "../../../Icons/Timer.tsx"; import Timer from "../../../Icons/Timer.tsx";
import Upload from "../../../Icons/Upload.tsx";
const Accordion = styled(MuiAccordion)(() => ({ const Accordion = styled(MuiAccordion)(() => ({
border: "0px solid rgba(0, 0, 0, .125)", border: "0px solid rgba(0, 0, 0, .125)",
@ -79,6 +83,10 @@ export interface ShareSetting {
password?: string; password?: string;
share_view?: boolean; share_view?: boolean;
show_readme?: boolean; show_readme?: boolean;
allow_upload?: boolean;
allow_edit?: boolean;
preview_only?: boolean;
upload_only?: boolean;
downloads?: boolean; downloads?: boolean;
expires?: boolean; expires?: boolean;
@ -132,7 +140,9 @@ const ShareSettingContent = ({ setting, file, editing, onSettingChange }: ShareS
setExpanded(isExpanded ? panel : undefined); 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]) { if (!setting[prop]) {
handleExpand(prop)(null, true); handleExpand(prop)(null, true);
} }
@ -200,8 +210,98 @@ const ShareSettingContent = ({ setting, file, editing, onSettingChange }: ShareS
)} )}
</AccordionDetails> </AccordionDetails>
</Accordion> </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 && ( {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")}> <Accordion expanded={expanded === "share_view"} onChange={handleExpand("share_view")}>
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header"> <AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
<StyledListItemButton> <StyledListItemButton>

@ -1,7 +1,7 @@
import { memo, useCallback, useContext, useEffect } from "react"; import { memo, useCallback, useContext, useEffect } from "react";
import { useDrag, useDrop } from "react-dnd"; import { useDrag, useDrop } from "react-dnd";
import { getEmptyImage } from "react-dnd-html5-backend"; 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 { setDragging } from "../../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import { processDnd } from "../../../redux/thunks/file.ts"; import { processDnd } from "../../../redux/thunks/file.ts";
@ -9,6 +9,7 @@ import { getFileLinkedUri, mergeRefs } from "../../../util";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import Boolset from "../../../util/boolset.ts";
import CrUri, { Filesystem } from "../../../util/uri.ts"; import CrUri, { Filesystem } from "../../../util/uri.ts";
import { FileBlockProps } from "../Explorer/Explorer.tsx"; import { FileBlockProps } from "../Explorer/Explorer.tsx";
import { FileManagerIndex } from "../FileManager.tsx"; import { FileManagerIndex } from "../FileManager.tsx";
@ -66,7 +67,11 @@ export const useFileDrag = ({ file, includeSelected, dropUri }: UseFileDragProps
} }
const crUri = new CrUri(file.path); 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) => ({ collect: (monitor) => ({
isDragging: monitor.isDragging(), isDragging: monitor.isDragging(),

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

@ -30,6 +30,10 @@ export function createOrUpdateShareLink(
password: setting.password, password: setting.password,
share_view: setting.share_view, share_view: setting.share_view,
show_readme: setting.show_readme, 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, 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, expire: setting.expires && setting.expires_val.value > 0 ? setting.expires_val.value : undefined,
}; };

@ -225,6 +225,14 @@ type (
ShareView bool `json:"share_view,omitempty"` ShareView bool `json:"share_view,omitempty"`
// Whether to automatically show readme file in share view // Whether to automatically show readme file in share view
ShowReadMe bool `json:"show_read_me,omitempty"` 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 { OAuthClientProps struct {

@ -40,8 +40,23 @@ const (
type ( type (
ContextHintCtxKey struct{} ContextHintCtxKey struct{}
ByPassOwnerCheckCtxKey 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, func NewDatabaseFS(u *ent.User, fileClient inventory.FileClient, shareClient inventory.ShareClient,
l logging.Logger, ls lock.LockSystem, settingClient setting.Provider, l logging.Logger, ls lock.LockSystem, settingClient setting.Provider,
storagePolicyClient inventory.StoragePolicyClient, hasher hashid.Encoder, userClient inventory.UserClient, 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 // Calculate folder summary if requested
if o.loadFolderSummary && target != nil && target.Type() == types.FileTypeFolder { 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 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 { func canMoveOrCopyTo(src, dst *fs.URI, isCopy bool) bool {
if isCopy { 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 return src.FileSystem() == dst.FileSystem() && src.FileSystem() == constants.FileSystemMy
} else { } else {
switch src.FileSystem() { 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 return dst.FileSystem() == constants.FileSystemMy || dst.FileSystem() == constants.FileSystemTrash
case constants.FileSystemTrash: case constants.FileSystemTrash:
return dst.FileSystem() == constants.FileSystemMy 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 // 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 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())) 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 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() 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 return nil, nil, fs.ErrOwnerOnly
} }
@ -265,7 +265,7 @@ func (f *DBFS) SoftDelete(ctx context.Context, path ...*fs.URI) error {
continue 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"))) ae.Add(p.String(), fs.ErrOwnerOnly.WithError(fmt.Errorf("only file owner can delete file without trash bin")))
continue continue
} }
@ -359,7 +359,7 @@ func (f *DBFS) Delete(ctx context.Context, path []*fs.URI, opts ...fs.Option) ([
continue 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) ae.Add(p.String(), fs.ErrOwnerOnly)
continue 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) 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 return nil, fs.ErrOwnerOnly
} }
@ -582,7 +582,13 @@ func (f *DBFS) MoveOrCopy(ctx context.Context, path []*fs.URI, dst *fs.URI, isCo
continue 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) ae.Add(p.String(), fs.ErrOwnerOnly)
continue continue
} }

@ -125,11 +125,18 @@ func init() {
NavigatorCapabilityEnterFolder: true, NavigatorCapabilityEnterFolder: true,
NavigatorCapabilityModifyProps: true, NavigatorCapabilityModifyProps: true,
}, myNavigatorCapability) }, 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{ boolset.Sets(map[NavigatorCapability]bool{
NavigatorCapabilityCreateFile: true,
NavigatorCapabilityRenameFile: true,
NavigatorCapabilityUploadFile: true,
NavigatorCapabilityDownloadFile: true, NavigatorCapabilityDownloadFile: true,
NavigatorCapabilityListChildren: true, NavigatorCapabilityListChildren: true,
NavigatorCapabilityGenerateThumb: true, NavigatorCapabilityGenerateThumb: true,
NavigatorCapabilityDeleteFile: true,
NavigatorCapabilityLockFile: true, NavigatorCapabilityLockFile: true,
NavigatorCapabilitySoftDelete: true,
NavigatorCapabilityInfo: true, NavigatorCapabilityInfo: true,
NavigatorCapabilityVersionControl: true, NavigatorCapabilityVersionControl: true,
NavigatorCapabilityEnterFolder: 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 ( var (
ErrShareNotFound = serializer.NewError(serializer.CodeNotFound, "Shared file does not exist", nil) ErrShareNotFound = serializer.NewError(serializer.CodeNotFound, "Shared file does not exist", nil)
ErrNotPurchased = serializer.NewError(serializer.CodePurchaseRequired, "You need to purchased this share", 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 ( const (
PurchaseTicketHeader = constants.CrHeaderPrefix + "Purchase-Ticket" 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{} var shareNavigatorCapability = &boolset.BooleanSet{}
// NewShareNavigator creates a navigator for user's "shared" file system. // 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 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 // Share permission setting should overwrite root folder's permission
n.shareRoot = newFile(nil, share.Edges.File) 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 = ownerRoot
n.ownerRoot.Path[pathIndexRoot] = newMyIDUri(hashid.EncodeUserID(n.hasher, n.owner.ID)) n.ownerRoot.Path[pathIndexRoot] = newMyIDUri(hashid.EncodeUserID(n.hasher, n.owner.ID))
n.share = share
return n.shareRoot, nil return n.shareRoot, nil
} }
@ -240,6 +250,14 @@ func (n *shareNavigator) Children(ctx context.Context, parent *File, args *ListA
}, nil }, 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) return n.baseNavigator.children(ctx, parent, args)
} }
@ -267,6 +285,12 @@ func (n *shareNavigator) Capabilities(isSearching bool) *fs.NavigatorProps {
MaxPageSize: n.config.MaxPageSize, 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 { if isSearching {
res.OrderByOptions = nil res.OrderByOptions = nil
res.OrderDirectionOptions = nil res.OrderDirectionOptions = nil
@ -275,6 +299,57 @@ func (n *shareNavigator) Capabilities(isSearching bool) *fs.NavigatorProps {
return res 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) { func (n *shareNavigator) FollowTx(ctx context.Context) (func(), error) {
if _, ok := ctx.Value(inventory.TxCtx{}).(*inventory.Tx); !ok { if _, ok := ctx.Value(inventory.TxCtx{}).(*inventory.Tx); !ok {
return nil, fmt.Errorf("navigator: no inherited transaction found in context") 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 { func (n *shareNavigator) ExecuteHook(ctx context.Context, hookType fs.HookType, file *File) error {
switch hookType { switch hookType {
case fs.HookTypeBeforeDownload: 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 return nil
} }

@ -34,8 +34,8 @@ func (f *DBFS) PreValidateUpload(ctx context.Context, dst *fs.URI, files ...fs.P
} }
// check ownership // check ownership
if f.user.ID != dstFile.OwnerID() { if !f.writePermitted(dstFile, NavigatorCapabilityUploadFile) {
return fmt.Errorf("failed to evaluate permission: %w", err) return fmt.Errorf("failed to evaluate permission: %w", fs.ErrOwnerOnly)
} }
total := int64(0) total := int64(0)
@ -107,7 +107,11 @@ func (f *DBFS) PrepareUpload(ctx context.Context, req *fs.UploadRequest, opts ..
return nil, fs.ErrPathNotExist 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 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 return nil, nil, fs.ErrOwnerOnly
} }

@ -91,9 +91,12 @@ func (m *manager) GetDirectLink(ctx context.Context, urls ...*fs.URI) ([]DirectL
continue 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 { 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 { if useRedirect {
@ -222,6 +225,14 @@ func (m *manager) GetEntityUrls(ctx context.Context, args []GetEntityUrlArgs, op
continue 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 { if file.Type() != types.FileTypeFile {
ae.Add(arg.URI.String(), fs.ErrEntityNotExist) ae.Add(arg.URI.String(), fs.ErrEntityNotExist)
continue 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 { 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) policy, d, err := m.getEntityPolicyDriver(ctx, target, nil)

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

@ -322,8 +322,12 @@ func (l *manager) CreateOrUpdateShare(ctx context.Context, path *fs.URI, args *C
} }
props := &types.ShareProps{ props := &types.ShareProps{
ShareView: args.ShareView, ShareView: args.ShareView,
ShowReadMe: args.ShowReadMe, ShowReadMe: args.ShowReadMe,
AllowUpload: args.AllowUpload || args.AllowEdit,
AllowEdit: args.AllowEdit,
PreviewOnly: args.PreviewOnly,
UploadOnly: args.UploadOnly,
} }
share, err := shareClient.Upsert(ctx, &inventory.CreateShareParams{ share, err := shareClient.Upsert(ctx, &inventory.CreateShareParams{

@ -335,9 +335,13 @@ type Share struct {
Size int64 `json:"size"` Size int64 `json:"size"`
// Only viewable by owner // Only viewable by owner
IsPrivate bool `json:"is_private,omitempty"` IsPrivate bool `json:"is_private,omitempty"`
Password string `json:"password,omitempty"` Password string `json:"password,omitempty"`
ShareView bool `json:"share_view,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 // Only viewable if explicitly unlocked by owner
SourceUri string `json:"source_uri,omitempty"` 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.Password = s.Password
res.ShowReadMe = s.Props != nil && s.Props.ShowReadMe 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 { if t == types.FileTypeFile && s.Edges.File != nil {
res.Size = s.Edges.File.Size 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 { if requester.ID == owner.ID {
res.IsPrivate = s.Password != "" res.IsPrivate = s.Password != ""
res.ShareView = s.Props != nil && s.Props.ShareView 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 return &res

@ -27,6 +27,10 @@ type (
Expire int `json:"expire"` Expire int `json:"expire"`
ShareView bool `json:"share_view"` ShareView bool `json:"share_view"`
ShowReadMe bool `json:"show_readme"` 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{} ShareCreateParamCtx struct{}
@ -90,6 +94,10 @@ func (service *ShareCreateService) Upsert(c *gin.Context, existed int) (string,
ExistedShareID: existed, ExistedShareID: existed,
ShareView: service.ShareView, ShareView: service.ShareView,
ShowReadMe: service.ShowReadMe, ShowReadMe: service.ShowReadMe,
AllowUpload: service.AllowUpload,
AllowEdit: service.AllowEdit,
PreviewOnly: service.PreviewOnly,
UploadOnly: service.UploadOnly,
}) })
if err != nil { if err != nil {
return "", err return "", err

Loading…
Cancel
Save