diff --git a/ROADMAP.md b/ROADMAP.md index 859a1e1a..ae22e0f7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -205,9 +205,13 @@ Order = user-visible value first; each ships with backend + UI + tests. - Own security review on top of upstream fixes: session/token entropy audit, SSRF guard re-test (NAT64 class), rate limiting on auth endpoints - Fix upstream bug backlog by impact: ~~#3574 OOM~~ (done — paged tree walk + batched delete), ~~#3118/#3005 WebDAV large-file~~ (done — Content-Range assembly into one session; non-local policies get honest 501; single-PUT giant-file 500s are proxy/client timeouts, not fixable server-side), ~~#3375 SMTP auth discovery~~ (done — `smtp_auth` setting) - #3454 (PG FK on upload) is **Pro-only** — `audit_logs` doesn't exist in this codebase. When B.5 adds our own audit log: insert the audit row in the same tx *after* the file row, never before. +- [ ] #199 (upstream #3584) — markdown editor lag: profile the MDX editor path; likely re-render-per-keystroke, evaluate debounce/virtualization or lighter editor before swapping libraries +- [ ] #198 (upstream #3581) — "import files" task shows source storage policy "unknown": check task props → policy name resolution in admin import path (Pro report, likely same code path in CE) +- [ ] #200 (upstream #3586) — Pro crash on SIGHUP; log shows a clean signal-driven shutdown, no stack trace — watch for a CE repro, likely not actionable yet - [x] `desloppify` pass — 73 review items dispositioned (46 fixed, 27 honestly skipped), strict score 77.1 (was 18.9); scorecard lives in README. `security-reviewer` pass done incrementally per batch (OAuth secrets, SSRF, process exec, path safety) - [x] Tag management page (upstream #2962) — owner-scoped `tag:` metadata stats/rename/recolor/delete in `inventory.FileClient`, `GET/PATCH/DELETE /file/tag` routes, Settings → Tags tab with merge-on-rename semantics - [x] Private space / vault (upstream #3447) — opt-in root folder flagged `sys:vault`; ancestry-based membership (zero flag maintenance; chain-less search results resolved lazily via `file_children`); `vaultNavigator` decorator gating `To`/`Children`/`Walk`/`ExecuteHook`; separate vault password (`salt:sha256`, sensitive) + 30-min cache-backed unlock session, unlock rate-limited 10/h; vault content never shareable and never direct-linkable; search filtered while locked; `vault_enabled`/`vault_unlocked` in user settings; unlock prompt in `ExplorerError`, Private space section in security settings, lock badge on vault folder (#195) +- [x] Saved share links (#147) — `POST /file/create` accepts `type: share` + `share_id`/`share_password`, materializing a symbolic shortcut (`sys:shared_redirect`) that lists under My Files and Shared with me; "Save to my files" in the share popover + "Save share link" dialog on /shares ## 6. Phase D — desktop, all platforms diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index f7c897e5..35e8d1d6 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -838,7 +838,16 @@ "paidShareDes": "This is a paid share. Purchasing costs {{price}} points and unlocks downloads.", "purchaseForPoints": "Purchase for {{price}} pts", "signInToPurchase": "Sign in to purchase", - "yourBalance": "Balance: {{credits}} points" + "yourBalance": "Balance: {{credits}} points", + "saveToMyFiles": "Save to my files", + "savedToMyFiles": "Share saved to your files.", + "saveShareLink": "Save share link", + "saveShareLinkHint": "Paste a share link like https://example.com/s/abc123", + "shareLink": "Share link", + "savedAs": "Saved as (optional)", + "sharePassword": "Share password (optional)", + "sharePasswordRequired": "This share requires a password.", + "invalidShareLink": "Cannot parse this share link." }, "download": { "noFilesFound": "No files found", diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index 254f8424..4282c274 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -838,7 +838,16 @@ "paidShareDes": "此分享为付费分享,需支付 {{price}} 积分购买后才能下载。", "purchaseForPoints": "支付 {{price}} 积分购买", "signInToPurchase": "登录后购买", - "yourBalance": "余额:{{credits}} 积分" + "yourBalance": "余额:{{credits}} 积分", + "saveToMyFiles": "保存到我的文件", + "savedToMyFiles": "分享已保存到你的文件。", + "saveShareLink": "保存分享链接", + "saveShareLinkHint": "粘贴分享链接,例如 https://example.com/s/abc123", + "shareLink": "分享链接", + "savedAs": "保存名称(可选)", + "sharePassword": "分享密码(可选)", + "sharePasswordRequired": "此分享需要密码。", + "invalidShareLink": "无法解析此分享链接。" }, "download": { "noFilesFound": "没有找到任何文件", diff --git a/frontend/src/api/explorer.ts b/frontend/src/api/explorer.ts index 19bfa0a6..8873e390 100644 --- a/frontend/src/api/explorer.ts +++ b/frontend/src/api/explorer.ts @@ -370,11 +370,15 @@ export interface ShareCreateService { export interface CreateFileService { uri: string; - type: "file" | "folder"; + type: "file" | "folder" | "share"; err_on_conflict?: boolean; metadata?: { [key: string]: string; }; + // share_id + share_password create a symbolic share shortcut when + // type == "share", persisting a saved share link in the file list. + share_id?: string; + share_password?: string; } export interface FileURLService extends MultipleUriService { diff --git a/frontend/src/component/FileManager/TopBar/ShareInfoPopover.tsx b/frontend/src/component/FileManager/TopBar/ShareInfoPopover.tsx index 4a32cc72..45340b5b 100644 --- a/frontend/src/component/FileManager/TopBar/ShareInfoPopover.tsx +++ b/frontend/src/component/FileManager/TopBar/ShareInfoPopover.tsx @@ -1,11 +1,15 @@ -import { Box, Divider, PopoverProps, Stack, styled, Typography } from "@mui/material"; +import { Box, Button, Divider, PopoverProps, Stack, styled, Typography } from "@mui/material"; import HoverPopover from "material-ui-popup-state/HoverPopover"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Share } from "../../../api/explorer.ts"; +import { useAppDispatch } from "../../../redux/hooks.ts"; +import { saveShareToMyFiles } from "../../../redux/thunks/share.ts"; +import SessionManager from "../../../session"; import TimeBadge from "../../Common/TimeBadge.tsx"; import UserBadge from "../../Common/User/UserBadge.tsx"; import Eye from "../../Icons/Eye.tsx"; +import Save from "../../Icons/Save.tsx"; import Timer from "../../Icons/Timer.tsx"; interface ShareInfoPopoverProps extends PopoverProps { @@ -64,7 +68,15 @@ export const ShareStatistics = ({ shareInfo }: { shareInfo: Share }) => { const ShareInfoPopover = ({ displayName, shareInfo, ...rest }: ShareInfoPopoverProps) => { const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [saving, setSaving] = useState(false); + const loggedIn = SessionManager.currentLoginOrNull() != null; const stopPropagation = useCallback((e: any) => e.stopPropagation(), []); + + const saveToMyFiles = useCallback(() => { + setSaving(true); + dispatch(saveShareToMyFiles(shareInfo, shareInfo.password, displayName)).finally(() => setSaving(false)); + }, [dispatch, shareInfo, displayName]); return ( )} + {loggedIn && ( + <> + + + + + + )} ); diff --git a/frontend/src/component/Pages/Shares/SaveShareLinkDialog.tsx b/frontend/src/component/Pages/Shares/SaveShareLinkDialog.tsx new file mode 100644 index 00000000..fd1f385d --- /dev/null +++ b/frontend/src/component/Pages/Shares/SaveShareLinkDialog.tsx @@ -0,0 +1,149 @@ +import { DialogContent, Stack } from "@mui/material"; +import { useSnackbar } from "notistack"; +import { ChangeEvent, useCallback, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { getShareInfo } from "../../../api/api.ts"; +import { useAppDispatch } from "../../../redux/hooks.ts"; +import { parseShareLink, saveShareToMyFiles } from "../../../redux/thunks/share.ts"; +import { DefaultCloseAction } from "../../Common/Snackbar/snackbar.tsx"; +import { FilledTextField } from "../../Common/StyledComponents.tsx"; +import DraggableDialog from "../../Dialogs/DraggableDialog.tsx"; + +interface SaveShareLinkDialogProps { + open: boolean; + onClose: () => void; +} + +const SaveShareLinkDialog = ({ open, onClose }: SaveShareLinkDialogProps) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { enqueueSnackbar } = useSnackbar(); + + const [link, setLink] = useState(""); + const [name, setName] = useState(""); + const [password, setPassword] = useState(""); + const [linkError, setLinkError] = useState(""); + const [loading, setLoading] = useState(false); + const formRef = useRef(null); + + const reset = useCallback(() => { + setLink(""); + setName(""); + setPassword(""); + setLinkError(""); + }, []); + + const handleClose = useCallback(() => { + reset(); + onClose(); + }, [reset, onClose]); + + const onAccept = useCallback( + (e?: React.FormEvent) => { + e?.preventDefault(); + const parsed = parseShareLink(link); + if (!parsed) { + setLinkError(t("application:share.invalidShareLink")); + return; + } + + setLoading(true); + const sharePassword = parsed.password || password || undefined; + dispatch(getShareInfo(parsed.id, sharePassword)) + .then((share) => { + if (!share.unlocked && share.password_protected && !sharePassword) { + setLinkError(t("application:share.sharePasswordRequired")); + return; + } + return dispatch(saveShareToMyFiles(share, sharePassword, name)).then(() => { + reset(); + onClose(); + }); + }) + .catch(() => { + enqueueSnackbar({ + message: t("application:share.shareNotExist"), + variant: "error", + action: DefaultCloseAction, + }); + }) + .finally(() => { + setLoading(false); + }); + }, + [dispatch, link, password, name, t, enqueueSnackbar, reset, onClose], + ); + + const onOkClicked = useCallback(() => { + if (formRef.current?.reportValidity()) { + onAccept(); + } + }, [onAccept]); + + const onLinkChange = useCallback( + (e: ChangeEvent) => { + setLink(e.target.value); + setLinkError(""); + }, + [], + ); + + return ( + + + +
+ + + setName(e.target.value)} + fullWidth + /> + setPassword(e.target.value)} + fullWidth + /> + +
+
+
+
+ ); +}; + +export default SaveShareLinkDialog; diff --git a/frontend/src/component/Pages/Shares/ShareList.tsx b/frontend/src/component/Pages/Shares/ShareList.tsx index 0ceb0116..16fade04 100644 --- a/frontend/src/component/Pages/Shares/ShareList.tsx +++ b/frontend/src/component/Pages/Shares/ShareList.tsx @@ -11,6 +11,7 @@ import Nothing from "../../Common/Nothing.tsx"; import { DefaultCloseAction } from "../../Common/Snackbar/snackbar.tsx"; import { setSelected } from "../../../redux/fileManagerSlice.ts"; import ShareCard from "./ShareCard.tsx"; +import SaveShareLinkDialog from "./SaveShareLinkDialog.tsx"; import { Share } from "../../../api/explorer.ts"; import { DenseSelect } from "../../Common/StyledComponents.tsx"; import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx"; @@ -28,6 +29,7 @@ const ShareList = () => { const [orderDirection, setOrderDirection] = useState("desc"); const [selecting, setSelecting] = useState(false); const [selected, setSelectedIds] = useState>(new Set()); + const [saveLinkOpen, setSaveLinkOpen] = useState(false); const loadNextPage = useCallback( (originShares: Share[], token?: string, direction?: string) => () => { @@ -136,6 +138,9 @@ const ShareList = () => { )} + @@ -198,6 +203,7 @@ const ShareList = () => { )} + setSaveLinkOpen(false)} /> ); diff --git a/frontend/src/redux/thunks/share.ts b/frontend/src/redux/thunks/share.ts index 65a28106..b36be9fe 100644 --- a/frontend/src/redux/thunks/share.ts +++ b/frontend/src/redux/thunks/share.ts @@ -1,11 +1,18 @@ import i18next from "i18next"; import { closeSnackbar, enqueueSnackbar, SnackbarKey } from "notistack"; -import { getFileInfo, getFileList, getShareInfo, sendCreateShare, sendUpdateShare } from "../../api/api.ts"; +import { + getFileInfo, + getFileList, + getShareInfo, + sendCreateFile, + sendCreateShare, + sendUpdateShare, +} from "../../api/api.ts"; import { FileResponse, Share, ShareCreateService } from "../../api/explorer.ts"; import { DefaultCloseAction, OpenReadMeAction } from "../../component/Common/Snackbar/snackbar.tsx"; import { ShareSetting } from "../../component/FileManager/Dialogs/Share/ShareSetting.tsx"; import { getPaginationState } from "../../component/FileManager/Pagination/PaginationFooter.tsx"; -import CrUri from "../../util/uri.ts"; +import CrUri, { Filesystem } from "../../util/uri.ts"; import { fileUpdated } from "../fileManagerSlice.ts"; import { addShareInfo, @@ -121,6 +128,54 @@ export function queueLoadShareInfo(uri: CrUri, countViews: boolean = false): App }; } +export interface ParsedShareLink { + id: string; + password?: string; +} + +// parseShareLink accepts "https://host/s/[/password]", "/s/[/password]", +// "cloudreve://[:]@share", or a bare share id. +export function parseShareLink(input: string): ParsedShareLink | undefined { + const trimmed = input.trim(); + if (!trimmed) { + return undefined; + } + if (trimmed.startsWith("cloudreve://")) { + const uri = new CrUri(trimmed); + return uri.fs() == Filesystem.share && uri.id() + ? { id: uri.id(), password: uri.password() || undefined } + : undefined; + } + const match = trimmed.match(/\/s\/([A-Za-z0-9]+)(?:\/([^/?#]+))?/); + if (match) { + return { id: match[1], password: match[2] ? decodeURIComponent(match[2]) : undefined }; + } + return /^[A-Za-z0-9]+$/.test(trimmed) ? { id: trimmed } : undefined; +} + +export function saveShareToMyFiles(shareInfo: Share, password?: string, name?: string): AppThunk> { + return async (dispatch) => { + const displayName = + name?.trim() || + shareInfo.name || + i18next.t("application:share.somebodyShare", { name: shareInfo.owner.nickname }); + const uri = new CrUri("cloudreve://" + Filesystem.my).join(displayName); + await dispatch( + sendCreateFile({ + uri: uri.toString(), + type: "share", + share_id: shareInfo.id, + share_password: password ?? shareInfo.password, + }), + ); + enqueueSnackbar({ + message: i18next.t("application:share.savedToMyFiles"), + variant: "success", + action: DefaultCloseAction, + }); + }; +} + export function openShareEditByID(shareId: string, password?: string, singleFile?: boolean): AppThunk { return async (dispatch) => { try { diff --git a/service/explorer/file.go b/service/explorer/file.go index c558b0b0..10a3ef41 100644 --- a/service/explorer/file.go +++ b/service/explorer/file.go @@ -8,6 +8,7 @@ import ( "time" "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/ent" "github.com/cloudreve/Cloudreve/v4/inventory" "github.com/cloudreve/Cloudreve/v4/inventory/types" "github.com/cloudreve/Cloudreve/v4/pkg/activity" @@ -216,9 +217,14 @@ type ( CreateFileParameterCtx struct{} CreateFileService struct { Uri string `json:"uri" binding:"required"` - Type string `json:"type" binding:"required,eq=file|eq=folder"` + Type string `json:"type" binding:"required,eq=file|eq=folder|eq=share"` Metadata map[string]string `json:"metadata"` ErrOnConflict bool `json:"err_on_conflict"` + // ShareID and SharePassword create a symbolic share shortcut instead + // of a regular entry when Type == "share", so a saved share link + // stays browsable from the owner's file list. + ShareID string `json:"share_id"` + SharePassword string `json:"share_password"` } ) @@ -233,10 +239,35 @@ func (service *CreateFileService) Create(c *gin.Context) (*FileResponse, error) return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err) } - fileType := types.FileTypeFromString(service.Type) opts := []fs.Option{ fs.WithMetadata(service.Metadata), } + fileType := types.FileTypeFromString(service.Type) + if service.Type == "share" { + if service.ShareID == "" { + return nil, serializer.NewError(serializer.CodeParamErr, "share_id is required", nil) + } + + shareCtx := context.WithValue(c, inventory.LoadShareUser{}, true) + shareCtx = context.WithValue(shareCtx, inventory.LoadShareFile{}, true) + shareCtx = context.WithValue(shareCtx, inventory.LoadShareFiles{}, true) + share, err := dep.ShareClient().GetByHashID(shareCtx, service.ShareID) + if err != nil || share == nil { + return nil, serializer.NewError(serializer.CodeNotFound, "Share not found", err) + } + + ft, metadata, sErr := shareShortcutEntry(share, dep.HashIDEncoder(), service.SharePassword) + if sErr != nil { + return nil, sErr + } + fileType = ft + + opts = []fs.Option{ + dbfs.WithSymbolicLink(), + fs.WithMetadata(metadata), + } + } + if service.ErrOnConflict { opts = append(opts, dbfs.WithErrorOnConflict()) } @@ -248,6 +279,30 @@ func (service *CreateFileService) Create(c *gin.Context) (*FileResponse, error) return BuildFileResponse(c, user, file, dep.HashIDEncoder(), nil), nil } +// shareShortcutEntry resolves a share into the symbolic entry attributes used +// for saved share links: folder type for folder and multi-file shares, file +// type otherwise. +func shareShortcutEntry(share *ent.Share, hasher hashid.Encoder, password string) (types.FileType, map[string]string, error) { + if err := inventory.IsValidShare(share); err != nil { + return 0, nil, err + } + + fileType := types.FileTypeFolder + if len(share.Edges.Files) == 0 && share.Edges.File != nil && + types.FileType(share.Edges.File.Type) != types.FileTypeFolder { + fileType = types.FileTypeFile + } + + metadata := map[string]string{ + dbfs.MetadataSharedRedirect: fs.NewShareUri(hashid.EncodeShareID(hasher, share.ID), password), + } + if share.Edges.User != nil { + metadata[dbfs.MetadataSharedOwner] = hashid.EncodeUserID(hasher, share.Edges.User.ID) + } + + return fileType, metadata, nil +} + type ( RenameFileParameterCtx struct{} RenameFileService struct { diff --git a/service/explorer/file_test.go b/service/explorer/file_test.go new file mode 100644 index 00000000..73952891 --- /dev/null +++ b/service/explorer/file_test.go @@ -0,0 +1,149 @@ +package explorer + +import ( + "context" + "testing" + "time" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + "github.com/cloudreve/Cloudreve/v4/ent/storagepolicy" + "github.com/cloudreve/Cloudreve/v4/ent/user" + "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/conf" + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs" + "github.com/cloudreve/Cloudreve/v4/pkg/hashid" + "github.com/stretchr/testify/require" +) + +type shareShortcutFixture struct { + client *ent.Client + shares inventory.ShareClient + hasher hashid.Encoder + owner *ent.User + group *ent.Group + root *ent.File +} + +func newShareShortcutFixture(t *testing.T) *shareShortcutFixture { + t.Helper() + client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared") + t.Cleanup(func() { require.NoError(t, client.Close()) }) + ctx := context.Background() + + hasher, err := hashid.New("share-shortcut-test-salt") + require.NoError(t, err) + + policy := client.StoragePolicy.Create().SetName("local").SetType("local"). + SetStatus(storagepolicy.StatusActive).SaveX(ctx) + permissions := &boolset.BooleanSet{} + boolset.Set(types.GroupPermissionShare, true, permissions) + group := client.Group.Create().SetName("seed").SetPermissions(permissions). + SetStoragePolicies(policy).SaveX(ctx) + + owner := client.User.Create().SetEmail("owner@example.com").SetNick("o").SetGroup(group).SaveX(ctx) + root := client.File.Create().SetName(inventory.RootFolderName).SetType(int(types.FileTypeFolder)).SetOwner(owner).SaveX(ctx) + + return &shareShortcutFixture{ + client: client, + shares: inventory.NewShareClient(client, conf.SQLiteDB, hasher), + hasher: hasher, + owner: owner, + group: group, + root: root, + } +} + +func (f *shareShortcutFixture) anchor(name string, fileType types.FileType) *ent.File { + return f.client.File.Create().SetName(name).SetType(int(fileType)). + SetOwner(f.owner).SetParent(f.root).SaveX(context.Background()) +} + +func (f *shareShortcutFixture) load(t *testing.T, shareID int) *ent.Share { + ctx := context.WithValue(context.Background(), inventory.LoadShareUser{}, true) + ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true) + ctx = context.WithValue(ctx, inventory.LoadShareFiles{}, true) + share, err := f.shares.GetByID(ctx, shareID) + require.NoError(t, err) + return share +} + +func TestShareShortcutEntry(t *testing.T) { + fx := newShareShortcutFixture(t) + ctx := context.Background() + + t.Run("folder share resolves to symbolic folder", func(t *testing.T) { + anchor := fx.anchor("shared_dir", types.FileTypeFolder) + share := fx.client.Share.Create().SetUser(fx.owner).SetFile(anchor).SaveX(ctx) + loaded := fx.load(t, share.ID) + + fileType, metadata, err := shareShortcutEntry(loaded, fx.hasher, "") + require.NoError(t, err) + require.Equal(t, types.FileTypeFolder, fileType) + require.Equal(t, + fs.NewShareUri(hashid.EncodeShareID(fx.hasher, share.ID), ""), + metadata[dbfs.MetadataSharedRedirect]) + require.Equal(t, hashid.EncodeUserID(fx.hasher, fx.owner.ID), metadata[dbfs.MetadataSharedOwner]) + }) + + t.Run("file share resolves to symbolic file", func(t *testing.T) { + anchor := fx.anchor("shared.txt", types.FileTypeFile) + share := fx.client.Share.Create().SetUser(fx.owner).SetFile(anchor).SaveX(ctx) + loaded := fx.load(t, share.ID) + + fileType, metadata, err := shareShortcutEntry(loaded, fx.hasher, "") + require.NoError(t, err) + require.Equal(t, types.FileTypeFile, fileType) + require.NotEmpty(t, metadata[dbfs.MetadataSharedRedirect]) + }) + + t.Run("multi-file share resolves to symbolic folder", func(t *testing.T) { + anchorA := fx.anchor("a.txt", types.FileTypeFile) + anchorB := fx.anchor("b.txt", types.FileTypeFile) + share := fx.client.Share.Create().SetUser(fx.owner).AddFiles(anchorA, anchorB).SaveX(ctx) + loaded := fx.load(t, share.ID) + + fileType, _, err := shareShortcutEntry(loaded, fx.hasher, "") + require.NoError(t, err) + require.Equal(t, types.FileTypeFolder, fileType) + }) + + t.Run("password is embedded into the redirect uri", func(t *testing.T) { + anchor := fx.anchor("protected_dir", types.FileTypeFolder) + share := fx.client.Share.Create().SetUser(fx.owner).SetFile(anchor).SetPassword("secret").SaveX(ctx) + loaded := fx.load(t, share.ID) + + _, metadata, err := shareShortcutEntry(loaded, fx.hasher, "secret") + require.NoError(t, err) + require.Equal(t, + fs.NewShareUri(hashid.EncodeShareID(fx.hasher, share.ID), "secret"), + metadata[dbfs.MetadataSharedRedirect]) + }) + + t.Run("expired share is rejected", func(t *testing.T) { + anchor := fx.anchor("expired_dir", types.FileTypeFolder) + past := time.Now().Add(-time.Hour) + share := fx.client.Share.Create().SetUser(fx.owner).SetFile(anchor).SetExpires(past).SaveX(ctx) + loaded := fx.load(t, share.ID) + + _, _, err := shareShortcutEntry(loaded, fx.hasher, "") + require.ErrorIs(t, err, inventory.ErrShareLinkExpired) + }) + + t.Run("inactive owner is rejected", func(t *testing.T) { + inactive := fx.client.User.Create().SetEmail("inactive@example.com").SetNick("i"). + SetGroup(fx.group).SetStatus(user.StatusInactive).SaveX(ctx) + inactiveRoot := fx.client.File.Create().SetName(inventory.RootFolderName). + SetType(int(types.FileTypeFolder)).SetOwner(inactive).SaveX(ctx) + anchor := fx.client.File.Create().SetName("inactive_dir").SetType(int(types.FileTypeFolder)). + SetOwner(inactive).SetParent(inactiveRoot).SaveX(ctx) + share := fx.client.Share.Create().SetUser(inactive).SetFile(anchor).SaveX(ctx) + loaded := fx.load(t, share.ID) + + _, _, err := shareShortcutEntry(loaded, fx.hasher, "") + require.ErrorIs(t, err, inventory.ErrOwnerInactive) + }) +}