feat: reverse shares (#86)
* add first concept * add reverse share funcionality to frontend * allow creator to limit share expiration * moved reverse share in seperate module * add table to manage reverse shares * delete complete share if reverse share was deleted * optimize function names * add db migration * enable reverse share email notifications * fix config variable descriptions * fix migration for new installations
This commit is contained in:
200
frontend/src/pages/account/reverseShares.tsx
Normal file
200
frontend/src/pages/account/reverseShares.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import moment from "moment";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TbInfoCircle, TbLink, TbPlus, TbTrash } from "react-icons/tb";
|
||||
import showShareLinkModal from "../../components/account/showShareLinkModal";
|
||||
import Meta from "../../components/Meta";
|
||||
import showCreateReverseShareModal from "../../components/share/modals/showCreateReverseShareModal";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import shareService from "../../services/share.service";
|
||||
import { MyReverseShare } from "../../types/share.type";
|
||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const MyShares = () => {
|
||||
const modals = useModals();
|
||||
const clipboard = useClipboard();
|
||||
const router = useRouter();
|
||||
const config = useConfig();
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const [reverseShares, setReverseShares] = useState<MyReverseShare[]>();
|
||||
|
||||
const getReverseShares = () => {
|
||||
shareService
|
||||
.getMyReverseShares()
|
||||
.then((shares) => setReverseShares(shares));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getReverseShares();
|
||||
}, []);
|
||||
|
||||
if (!user) {
|
||||
router.replace("/");
|
||||
} else {
|
||||
if (!reverseShares) return <LoadingOverlay visible />;
|
||||
return (
|
||||
<>
|
||||
<Meta title="My shares" />
|
||||
<Group position="apart" align="baseline" mb={20}>
|
||||
<Group align="center" spacing={3} mb={30}>
|
||||
<Title order={3}>My reverse shares</Title>
|
||||
<Tooltip
|
||||
position="bottom"
|
||||
multiline
|
||||
width={220}
|
||||
label="A reverse share allows you to generate a unique URL for a single-use share for an external user."
|
||||
events={{ hover: true, focus: false, touch: true }}
|
||||
>
|
||||
<ActionIcon>
|
||||
<TbInfoCircle />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Button
|
||||
onClick={() =>
|
||||
showCreateReverseShareModal(
|
||||
modals,
|
||||
config.get("SMTP_ENABLED"),
|
||||
getReverseShares
|
||||
)
|
||||
}
|
||||
leftIcon={<TbPlus size={20} />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
{reverseShares.length == 0 ? (
|
||||
<Center style={{ height: "70vh" }}>
|
||||
<Stack align="center" spacing={10}>
|
||||
<Title order={3}>It's empty here 👀</Title>
|
||||
<Text>You don't have any reverse shares.</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<Box sx={{ display: "block", overflowX: "auto" }}>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Visitors</th>
|
||||
<th>Max share size</th>
|
||||
<th>Expires at</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reverseShares.map((reverseShare) => (
|
||||
<tr key={reverseShare.id}>
|
||||
<td>
|
||||
{reverseShare.share ? (
|
||||
reverseShare.share?.id
|
||||
) : (
|
||||
<Text color="dimmed">No share created yet</Text>
|
||||
)}
|
||||
</td>
|
||||
<td>{reverseShare.share?.views ?? "0"}</td>
|
||||
<td>
|
||||
{byteToHumanSizeString(
|
||||
parseInt(reverseShare.maxShareSize)
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{moment(reverseShare.shareExpiration).unix() === 0
|
||||
? "Never"
|
||||
: moment(reverseShare.shareExpiration).format("LLL")}
|
||||
</td>
|
||||
<td>
|
||||
<Group position="right">
|
||||
{reverseShare.share && (
|
||||
<ActionIcon
|
||||
color="victoria"
|
||||
variant="light"
|
||||
size={25}
|
||||
onClick={() => {
|
||||
if (window.isSecureContext) {
|
||||
clipboard.copy(
|
||||
`${config.get("APP_URL")}/share/${
|
||||
reverseShare.share!.id
|
||||
}`
|
||||
);
|
||||
toast.success(
|
||||
"The share link was copied to the keyboard."
|
||||
);
|
||||
} else {
|
||||
showShareLinkModal(
|
||||
modals,
|
||||
reverseShare.share!.id,
|
||||
config.get("APP_URL")
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TbLink />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="light"
|
||||
size={25}
|
||||
onClick={() => {
|
||||
modals.openConfirmModal({
|
||||
title: `Delete reverse share`,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Do you really want to delete this reverse
|
||||
share? If you do, the share will be deleted as
|
||||
well.
|
||||
</Text>
|
||||
),
|
||||
confirmProps: {
|
||||
color: "red",
|
||||
},
|
||||
labels: { confirm: "Confirm", cancel: "Cancel" },
|
||||
onConfirm: () => {
|
||||
shareService.removeReverseShare(
|
||||
reverseShare.id
|
||||
);
|
||||
setReverseShares(
|
||||
reverseShares.filter(
|
||||
(item) => item.id !== reverseShare.id
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TbTrash />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default MyShares;
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
@@ -61,83 +62,85 @@ const MyShares = () => {
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Visitors</th>
|
||||
<th>Expires at</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shares.map((share) => (
|
||||
<tr key={share.id}>
|
||||
<td>{share.id}</td>
|
||||
<td>{share.views}</td>
|
||||
<td>
|
||||
{moment(share.expiration).unix() === 0
|
||||
? "Never"
|
||||
: moment(share.expiration).format("LLL")}
|
||||
</td>
|
||||
<td>
|
||||
<Group position="right">
|
||||
<ActionIcon
|
||||
color="victoria"
|
||||
variant="light"
|
||||
size={25}
|
||||
onClick={() => {
|
||||
if (window.isSecureContext) {
|
||||
clipboard.copy(
|
||||
`${config.get("APP_URL")}/share/${share.id}`
|
||||
);
|
||||
toast.success(
|
||||
"Your link was copied to the keyboard."
|
||||
);
|
||||
} else {
|
||||
showShareLinkModal(
|
||||
modals,
|
||||
share.id,
|
||||
config.get("APP_URL")
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TbLink />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="light"
|
||||
size={25}
|
||||
onClick={() => {
|
||||
modals.openConfirmModal({
|
||||
title: `Delete share ${share.id}`,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Do you really want to delete this share?
|
||||
</Text>
|
||||
),
|
||||
confirmProps: {
|
||||
color: "red",
|
||||
},
|
||||
labels: { confirm: "Confirm", cancel: "Cancel" },
|
||||
onConfirm: () => {
|
||||
shareService.remove(share.id);
|
||||
setShares(
|
||||
shares.filter((item) => item.id !== share.id)
|
||||
);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TbTrash />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</td>
|
||||
<Box sx={{ display: "block", overflowX: "auto" }}>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Visitors</th>
|
||||
<th>Expires at</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shares.map((share) => (
|
||||
<tr key={share.id}>
|
||||
<td>{share.id}</td>
|
||||
<td>{share.views}</td>
|
||||
<td>
|
||||
{moment(share.expiration).unix() === 0
|
||||
? "Never"
|
||||
: moment(share.expiration).format("LLL")}
|
||||
</td>
|
||||
<td>
|
||||
<Group position="right">
|
||||
<ActionIcon
|
||||
color="victoria"
|
||||
variant="light"
|
||||
size={25}
|
||||
onClick={() => {
|
||||
if (window.isSecureContext) {
|
||||
clipboard.copy(
|
||||
`${config.get("APP_URL")}/share/${share.id}`
|
||||
);
|
||||
toast.success(
|
||||
"Your link was copied to the keyboard."
|
||||
);
|
||||
} else {
|
||||
showShareLinkModal(
|
||||
modals,
|
||||
share.id,
|
||||
config.get("APP_URL")
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TbLink />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="light"
|
||||
size={25}
|
||||
onClick={() => {
|
||||
modals.openConfirmModal({
|
||||
title: `Delete share ${share.id}`,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Do you really want to delete this share?
|
||||
</Text>
|
||||
),
|
||||
confirmProps: {
|
||||
color: "red",
|
||||
},
|
||||
labels: { confirm: "Confirm", cancel: "Cancel" },
|
||||
onConfirm: () => {
|
||||
shareService.remove(share.id);
|
||||
setShares(
|
||||
shares.filter((item) => item.id !== share.id)
|
||||
);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TbTrash />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
43
frontend/src/pages/upload/[reverseShareToken].tsx
Normal file
43
frontend/src/pages/upload/[reverseShareToken].tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { LoadingOverlay } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { useEffect, useState } from "react";
|
||||
import Upload from ".";
|
||||
import showErrorModal from "../../components/share/showErrorModal";
|
||||
import shareService from "../../services/share.service";
|
||||
|
||||
export function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
props: { reverseShareToken: context.params!.reverseShareToken },
|
||||
};
|
||||
}
|
||||
|
||||
const Share = ({ reverseShareToken }: { reverseShareToken: string }) => {
|
||||
const modals = useModals();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const [maxShareSize, setMaxShareSize] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
shareService
|
||||
.setReverseShare(reverseShareToken)
|
||||
.then((reverseShareTokenData) => {
|
||||
setMaxShareSize(parseInt(reverseShareTokenData.maxShareSize));
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
showErrorModal(
|
||||
modals,
|
||||
"Invalid Link",
|
||||
"This link is invalid. Please check your link."
|
||||
);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading) return <LoadingOverlay visible />;
|
||||
|
||||
return <Upload isReverseShare maxShareSize={maxShareSize} />;
|
||||
};
|
||||
|
||||
export default Share;
|
||||
@@ -2,27 +2,34 @@ import { Button, Group } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { cleanNotifications } from "@mantine/notifications";
|
||||
import { AxiosError } from "axios";
|
||||
import { getCookie } from "cookies-next";
|
||||
import { useRouter } from "next/router";
|
||||
import pLimit from "p-limit";
|
||||
import { useEffect, useState } from "react";
|
||||
import Meta from "../components/Meta";
|
||||
import Dropzone from "../components/upload/Dropzone";
|
||||
import FileList from "../components/upload/FileList";
|
||||
import showCompletedUploadModal from "../components/upload/modals/showCompletedUploadModal";
|
||||
import showCreateUploadModal from "../components/upload/modals/showCreateUploadModal";
|
||||
import useConfig from "../hooks/config.hook";
|
||||
import useUser from "../hooks/user.hook";
|
||||
import shareService from "../services/share.service";
|
||||
import { FileUpload } from "../types/File.type";
|
||||
import { CreateShare, Share } from "../types/share.type";
|
||||
import toast from "../utils/toast.util";
|
||||
import Meta from "../../components/Meta";
|
||||
import Dropzone from "../../components/upload/Dropzone";
|
||||
import FileList from "../../components/upload/FileList";
|
||||
import showCompletedUploadModal from "../../components/upload/modals/showCompletedUploadModal";
|
||||
import showCreateUploadModal from "../../components/upload/modals/showCreateUploadModal";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import shareService from "../../services/share.service";
|
||||
import { FileUpload } from "../../types/File.type";
|
||||
import { CreateShare, Share } from "../../types/share.type";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const promiseLimit = pLimit(3);
|
||||
const chunkSize = 10 * 1024 * 1024; // 10MB
|
||||
let errorToastShown = false;
|
||||
let createdShare: Share;
|
||||
|
||||
const Upload = () => {
|
||||
const Upload = ({
|
||||
maxShareSize,
|
||||
isReverseShare = false,
|
||||
}: {
|
||||
maxShareSize?: number;
|
||||
isReverseShare: boolean;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const modals = useModals();
|
||||
|
||||
@@ -31,6 +38,8 @@ const Upload = () => {
|
||||
const [files, setFiles] = useState<FileUpload[]>([]);
|
||||
const [isUploading, setisUploading] = useState(false);
|
||||
|
||||
maxShareSize ??= parseInt(config.get("MAX_SHARE_SIZE"));
|
||||
|
||||
const uploadFiles = async (share: CreateShare) => {
|
||||
setisUploading(true);
|
||||
createdShare = await shareService.create(share);
|
||||
@@ -138,9 +147,9 @@ const Upload = () => {
|
||||
) {
|
||||
shareService
|
||||
.completeShare(createdShare.id)
|
||||
.then(() => {
|
||||
.then((share) => {
|
||||
setisUploading(false);
|
||||
showCompletedUploadModal(modals, createdShare, config.get("APP_URL"));
|
||||
showCompletedUploadModal(modals, share, config.get("APP_URL"));
|
||||
setFiles([]);
|
||||
})
|
||||
.catch(() =>
|
||||
@@ -149,8 +158,13 @@ const Upload = () => {
|
||||
}
|
||||
}, [files]);
|
||||
|
||||
if (!user && !config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
|
||||
if (
|
||||
!user &&
|
||||
!config.get("ALLOW_UNAUTHENTICATED_SHARES") &&
|
||||
!getCookie("reverse_share_token")
|
||||
) {
|
||||
router.replace("/");
|
||||
return null;
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
@@ -164,11 +178,14 @@ const Upload = () => {
|
||||
modals,
|
||||
{
|
||||
isUserSignedIn: user ? true : false,
|
||||
isReverseShare,
|
||||
appUrl: config.get("APP_URL"),
|
||||
allowUnauthenticatedShares: config.get(
|
||||
"ALLOW_UNAUTHENTICATED_SHARES"
|
||||
),
|
||||
enableEmailRecepients: config.get("ENABLE_EMAIL_RECIPIENTS"),
|
||||
enableEmailRecepients: config.get(
|
||||
"ENABLE_SHARE_EMAIL_RECIPIENTS"
|
||||
),
|
||||
},
|
||||
uploadFiles
|
||||
);
|
||||
@@ -177,7 +194,12 @@ const Upload = () => {
|
||||
Share
|
||||
</Button>
|
||||
</Group>
|
||||
<Dropzone files={files} setFiles={setFiles} isUploading={isUploading} />
|
||||
<Dropzone
|
||||
maxShareSize={maxShareSize}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
isUploading={isUploading}
|
||||
/>
|
||||
{files.length > 0 && <FileList files={files} setFiles={setFiles} />}
|
||||
</>
|
||||
);
|
||||
Reference in New Issue
Block a user