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
parent
43141d93c8
commit
b90d140586
@ -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;
|
||||
@ -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…
Reference in new issue