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 && (
+ <>
+
+
+ }
+ disabled={saving}
+ onClick={saveToMyFiles}
+ >
+ {t("application:share.saveToMyFiles")}
+
+
+ >
+ )}
);
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 (
+
+
+
+
+
+
+
+ );
+};
+
+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)
+ })
+}