feat: save share links as browsable shortcuts in file list (#147)

POST /file/create accepts type=share with share_id/share_password and
materializes a symbolic entry carrying sys:shared_redirect — the same
primitive seeded default shares use — so a saved share appears under
My Files and Shared with me and opens without re-entering the share.
Folder and multi-file shares create folder entries; file shares create
file entries. Expired or invalid shares are rejected at save time.

Frontend: "Save to my files" in the share info popover and a "Save
share link" dialog on /shares that accepts /s/<id>[/pw], cloudreve://
share URIs, or a bare share id.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3587/head
Tomas Dvorak 2 weeks ago
parent 43141d93c8
commit b90d140586

@ -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 - 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) - 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. - #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] `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] 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] 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 ## 6. Phase D — desktop, all platforms

@ -838,7 +838,16 @@
"paidShareDes": "This is a paid share. Purchasing costs {{price}} points and unlocks downloads.", "paidShareDes": "This is a paid share. Purchasing costs {{price}} points and unlocks downloads.",
"purchaseForPoints": "Purchase for {{price}} pts", "purchaseForPoints": "Purchase for {{price}} pts",
"signInToPurchase": "Sign in to purchase", "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": { "download": {
"noFilesFound": "No files found", "noFilesFound": "No files found",

@ -838,7 +838,16 @@
"paidShareDes": "此分享为付费分享,需支付 {{price}} 积分购买后才能下载。", "paidShareDes": "此分享为付费分享,需支付 {{price}} 积分购买后才能下载。",
"purchaseForPoints": "支付 {{price}} 积分购买", "purchaseForPoints": "支付 {{price}} 积分购买",
"signInToPurchase": "登录后购买", "signInToPurchase": "登录后购买",
"yourBalance": "余额:{{credits}} 积分" "yourBalance": "余额:{{credits}} 积分",
"saveToMyFiles": "保存到我的文件",
"savedToMyFiles": "分享已保存到你的文件。",
"saveShareLink": "保存分享链接",
"saveShareLinkHint": "粘贴分享链接,例如 https://example.com/s/abc123",
"shareLink": "分享链接",
"savedAs": "保存名称(可选)",
"sharePassword": "分享密码(可选)",
"sharePasswordRequired": "此分享需要密码。",
"invalidShareLink": "无法解析此分享链接。"
}, },
"download": { "download": {
"noFilesFound": "没有找到任何文件", "noFilesFound": "没有找到任何文件",

@ -370,11 +370,15 @@ export interface ShareCreateService {
export interface CreateFileService { export interface CreateFileService {
uri: string; uri: string;
type: "file" | "folder"; type: "file" | "folder" | "share";
err_on_conflict?: boolean; err_on_conflict?: boolean;
metadata?: { metadata?: {
[key: string]: string; [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 { export interface FileURLService extends MultipleUriService {

@ -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 HoverPopover from "material-ui-popup-state/HoverPopover";
import { useCallback } from "react"; import { useCallback, useState } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Share } from "../../../api/explorer.ts"; 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 TimeBadge from "../../Common/TimeBadge.tsx";
import UserBadge from "../../Common/User/UserBadge.tsx"; import UserBadge from "../../Common/User/UserBadge.tsx";
import Eye from "../../Icons/Eye.tsx"; import Eye from "../../Icons/Eye.tsx";
import Save from "../../Icons/Save.tsx";
import Timer from "../../Icons/Timer.tsx"; import Timer from "../../Icons/Timer.tsx";
interface ShareInfoPopoverProps extends PopoverProps { interface ShareInfoPopoverProps extends PopoverProps {
@ -64,7 +68,15 @@ export const ShareStatistics = ({ shareInfo }: { shareInfo: Share }) => {
const ShareInfoPopover = ({ displayName, shareInfo, ...rest }: ShareInfoPopoverProps) => { const ShareInfoPopover = ({ displayName, shareInfo, ...rest }: ShareInfoPopoverProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch();
const [saving, setSaving] = useState(false);
const loggedIn = SessionManager.currentLoginOrNull() != null;
const stopPropagation = useCallback((e: any) => e.stopPropagation(), []); 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 ( return (
<HoverPopover <HoverPopover
onMouseDown={stopPropagation} onMouseDown={stopPropagation}
@ -119,6 +131,22 @@ const ShareInfoPopover = ({ displayName, shareInfo, ...rest }: ShareInfoPopoverP
<TimeBadge sx={{ ml: 1 }} variant={"body2"} color={"text.secondary"} datetime={shareInfo.created_at} /> <TimeBadge sx={{ ml: 1 }} variant={"body2"} color={"text.secondary"} datetime={shareInfo.created_at} />
)} )}
</Box> </Box>
{loggedIn && (
<>
<Divider />
<Box sx={{ px: 1.5, py: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<Save />}
disabled={saving}
onClick={saveToMyFiles}
>
{t("application:share.saveToMyFiles")}
</Button>
</Box>
</>
)}
</Box> </Box>
</HoverPopover> </HoverPopover>
); );

@ -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<HTMLFormElement>(null);
const reset = useCallback(() => {
setLink("");
setName("");
setPassword("");
setLinkError("");
}, []);
const handleClose = useCallback(() => {
reset();
onClose();
}, [reset, onClose]);
const onAccept = useCallback(
(e?: React.FormEvent<HTMLFormElement>) => {
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<HTMLInputElement | HTMLTextAreaElement>) => {
setLink(e.target.value);
setLinkError("");
},
[],
);
return (
<DraggableDialog
title={t("application:share.saveShareLink")}
showActions
loading={loading}
showCancel
onAccept={onOkClicked}
dialogProps={{
open,
onClose: handleClose,
fullWidth: true,
maxWidth: "sm",
disableRestoreFocus: true,
}}
>
<DialogContent>
<Stack spacing={2}>
<form ref={formRef} onSubmit={onAccept}>
<Stack spacing={2}>
<FilledTextField
variant="filled"
autoFocus
error={!!linkError}
helperText={linkError || t("application:share.saveShareLinkHint")}
margin="dense"
label={t("application:share.shareLink")}
type="text"
value={link}
onChange={onLinkChange}
fullWidth
required
/>
<FilledTextField
variant="filled"
margin="dense"
label={t("application:share.savedAs")}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
fullWidth
/>
<FilledTextField
variant="filled"
margin="dense"
label={t("application:share.sharePassword")}
type="text"
value={password}
onChange={(e) => setPassword(e.target.value)}
fullWidth
/>
</Stack>
</form>
</Stack>
</DialogContent>
</DraggableDialog>
);
};
export default SaveShareLinkDialog;

@ -11,6 +11,7 @@ import Nothing from "../../Common/Nothing.tsx";
import { DefaultCloseAction } from "../../Common/Snackbar/snackbar.tsx"; import { DefaultCloseAction } from "../../Common/Snackbar/snackbar.tsx";
import { setSelected } from "../../../redux/fileManagerSlice.ts"; import { setSelected } from "../../../redux/fileManagerSlice.ts";
import ShareCard from "./ShareCard.tsx"; import ShareCard from "./ShareCard.tsx";
import SaveShareLinkDialog from "./SaveShareLinkDialog.tsx";
import { Share } from "../../../api/explorer.ts"; import { Share } from "../../../api/explorer.ts";
import { DenseSelect } from "../../Common/StyledComponents.tsx"; import { DenseSelect } from "../../Common/StyledComponents.tsx";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx"; import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
@ -28,6 +29,7 @@ const ShareList = () => {
const [orderDirection, setOrderDirection] = useState("desc"); const [orderDirection, setOrderDirection] = useState("desc");
const [selecting, setSelecting] = useState(false); const [selecting, setSelecting] = useState(false);
const [selected, setSelectedIds] = useState<Set<string>>(new Set()); const [selected, setSelectedIds] = useState<Set<string>>(new Set());
const [saveLinkOpen, setSaveLinkOpen] = useState(false);
const loadNextPage = useCallback( const loadNextPage = useCallback(
(originShares: Share[], token?: string, direction?: string) => () => { (originShares: Share[], token?: string, direction?: string) => () => {
@ -136,6 +138,9 @@ const ShareList = () => {
</Button> </Button>
</> </>
)} )}
<Button size="small" variant="text" onClick={() => setSaveLinkOpen(true)}>
{t("application:share.saveShareLink")}
</Button>
<Button size="small" variant="text" onClick={selecting ? exitSelecting : () => setSelecting(true)}> <Button size="small" variant="text" onClick={selecting ? exitSelecting : () => setSelecting(true)}>
{selecting ? t("common:cancel") : t("common:select")} {selecting ? t("common:cancel") : t("common:select")}
</Button> </Button>
@ -198,6 +203,7 @@ const ShareList = () => {
<Nothing size={0.8} top={63} primary={t("setting.listEmpty")} /> <Nothing size={0.8} top={63} primary={t("setting.listEmpty")} />
</Box> </Box>
)} )}
<SaveShareLinkDialog open={saveLinkOpen} onClose={() => setSaveLinkOpen(false)} />
</Container> </Container>
</PageContainer> </PageContainer>
); );

@ -1,11 +1,18 @@
import i18next from "i18next"; import i18next from "i18next";
import { closeSnackbar, enqueueSnackbar, SnackbarKey } from "notistack"; 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 { FileResponse, Share, ShareCreateService } from "../../api/explorer.ts";
import { DefaultCloseAction, OpenReadMeAction } from "../../component/Common/Snackbar/snackbar.tsx"; import { DefaultCloseAction, OpenReadMeAction } from "../../component/Common/Snackbar/snackbar.tsx";
import { ShareSetting } from "../../component/FileManager/Dialogs/Share/ShareSetting.tsx"; import { ShareSetting } from "../../component/FileManager/Dialogs/Share/ShareSetting.tsx";
import { getPaginationState } from "../../component/FileManager/Pagination/PaginationFooter.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 { fileUpdated } from "../fileManagerSlice.ts";
import { import {
addShareInfo, 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/<id>[/password]", "/s/<id>[/password]",
// "cloudreve://<id>[:<password>]@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<Promise<void>> {
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 { export function openShareEditByID(shareId: string, password?: string, singleFile?: boolean): AppThunk {
return async (dispatch) => { return async (dispatch) => {
try { try {

@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/cloudreve/Cloudreve/v4/application/dependency" "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"
"github.com/cloudreve/Cloudreve/v4/inventory/types" "github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/activity" "github.com/cloudreve/Cloudreve/v4/pkg/activity"
@ -216,9 +217,14 @@ type (
CreateFileParameterCtx struct{} CreateFileParameterCtx struct{}
CreateFileService struct { CreateFileService struct {
Uri string `json:"uri" binding:"required"` 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"` Metadata map[string]string `json:"metadata"`
ErrOnConflict bool `json:"err_on_conflict"` 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) return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
} }
fileType := types.FileTypeFromString(service.Type)
opts := []fs.Option{ opts := []fs.Option{
fs.WithMetadata(service.Metadata), 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 { if service.ErrOnConflict {
opts = append(opts, dbfs.WithErrorOnConflict()) 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 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 ( type (
RenameFileParameterCtx struct{} RenameFileParameterCtx struct{}
RenameFileService struct { RenameFileService struct {

@ -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)
})
}
Loading…
Cancel
Save