feat: add more options to reverse shares (#495)

* feat(reverse-share): optional simplified interface for reverse sharing. issue #155.

* chore: Remove useless form validation.

* feat: Share Ready modal adds a prompt that an email has been sent to the reverse share creator.

* fix: Simplified reverse shared interface elements lack spacing when not logged in.

* fix: Share Ready modal prompt contrast is too low in dark mode.

* feat: add public access options to reverse share.

* feat: remember reverse share simplified and publicAccess options in cookies.

* style: npm run format.

* chore(i18n): Improve translation.

Co-authored-by: Elias Schneider <login@eliasschneider.com>

Update frontend/src/i18n/translations/en-US.ts

Co-authored-by: Elias Schneider <login@eliasschneider.com>

Update frontend/src/i18n/translations/en-US.ts

Co-authored-by: Elias Schneider <login@eliasschneider.com>

chore(i18n): Improve translation.

* chore: Improved variable naming.

* chore(i18n): Improve translation. x2.

* fix(backend/shares): Misjudged the permission of the share of the reverse share.
This commit is contained in:
Ivan Li
2024-07-30 14:26:56 +08:00
committed by GitHub
parent 3563715f57
commit fe735f9704
22 changed files with 355 additions and 28 deletions

View File

@@ -0,0 +1,20 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_ReverseShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"token" TEXT NOT NULL,
"shareExpiration" DATETIME NOT NULL,
"maxShareSize" TEXT NOT NULL,
"sendEmailNotification" BOOLEAN NOT NULL,
"remainingUses" INTEGER NOT NULL,
"simplified" BOOLEAN NOT NULL DEFAULT false,
"creatorId" TEXT NOT NULL,
CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_ReverseShare" ("createdAt", "creatorId", "id", "maxShareSize", "remainingUses", "sendEmailNotification", "shareExpiration", "token") SELECT "createdAt", "creatorId", "id", "maxShareSize", "remainingUses", "sendEmailNotification", "shareExpiration", "token" FROM "ReverseShare";
DROP TABLE "ReverseShare";
ALTER TABLE "new_ReverseShare" RENAME TO "ReverseShare";
CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,22 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_ReverseShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"token" TEXT NOT NULL,
"shareExpiration" DATETIME NOT NULL,
"maxShareSize" TEXT NOT NULL,
"sendEmailNotification" BOOLEAN NOT NULL,
"remainingUses" INTEGER NOT NULL,
"simplified" BOOLEAN NOT NULL DEFAULT false,
"publicAccess" BOOLEAN NOT NULL DEFAULT true,
"creatorId" TEXT NOT NULL,
CONSTRAINT "ReverseShare_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_ReverseShare" ("createdAt", "creatorId", "id", "maxShareSize", "remainingUses", "sendEmailNotification", "shareExpiration", "simplified", "token") SELECT "createdAt", "creatorId", "id", "maxShareSize", "remainingUses", "sendEmailNotification", "shareExpiration", "simplified", "token" FROM "ReverseShare";
DROP TABLE "ReverseShare";
ALTER TABLE "new_ReverseShare" RENAME TO "ReverseShare";
CREATE UNIQUE INDEX "ReverseShare_token_key" ON "ReverseShare"("token");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -103,6 +103,8 @@ model ReverseShare {
maxShareSize String
sendEmailNotification Boolean
remainingUses Int
simplified Boolean @default(false)
publicAccess Boolean @default(true)
creatorId String
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)

View File

@@ -9,14 +9,16 @@ import * as moment from "moment";
import { PrismaService } from "src/prisma/prisma.service";
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
import { ShareService } from "src/share/share.service";
import { ConfigService } from "src/config/config.service";
@Injectable()
export class FileSecurityGuard extends ShareSecurityGuard {
constructor(
private _shareService: ShareService,
private _prisma: PrismaService,
_config: ConfigService,
) {
super(_shareService, _prisma);
super(_shareService, _prisma, _config);
}
async canActivate(context: ExecutionContext) {

View File

@@ -13,4 +13,10 @@ export class CreateReverseShareDTO {
@Min(1)
@Max(1000)
maxUseCount: number;
@IsBoolean()
simplified: boolean;
@IsBoolean()
publicAccess: boolean;
}

View File

@@ -13,6 +13,9 @@ export class ReverseShareDTO {
@Expose()
token: string;
@Expose()
simplified: boolean;
from(partial: Partial<ReverseShareDTO>) {
return plainToClass(ReverseShareDTO, partial, {
excludeExtraneousValues: true,

View File

@@ -49,6 +49,8 @@ export class ReverseShareService {
remainingUses: data.maxUseCount,
maxShareSize: data.maxShareSize,
sendEmailNotification: data.sendEmailNotification,
simplified: data.simplified,
publicAccess: data.publicAccess,
creatorId,
},
});

View File

@@ -0,0 +1,19 @@
import { Expose, plainToClass } from "class-transformer";
import { ShareDTO } from "./share.dto";
export class CompletedShareDTO extends ShareDTO {
@Expose()
notifyReverseShareCreator?: boolean;
from(partial: Partial<CompletedShareDTO>) {
return plainToClass(CompletedShareDTO, partial, {
excludeExtraneousValues: true,
});
}
fromList(partial: Partial<CompletedShareDTO>[]) {
return partial.map((part) =>
plainToClass(CompletedShareDTO, part, { excludeExtraneousValues: true }),
);
}
}

View File

@@ -1,5 +1,4 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
@@ -9,13 +8,19 @@ import { Request } from "express";
import * as moment from "moment";
import { PrismaService } from "src/prisma/prisma.service";
import { ShareService } from "src/share/share.service";
import { ConfigService } from "src/config/config.service";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { User } from "@prisma/client";
@Injectable()
export class ShareSecurityGuard implements CanActivate {
export class ShareSecurityGuard extends JwtGuard {
constructor(
private shareService: ShareService,
private prisma: PrismaService,
) {}
configService: ConfigService,
) {
super(configService);
}
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
@@ -31,7 +36,7 @@ export class ShareSecurityGuard implements CanActivate {
const share = await this.prisma.share.findUnique({
where: { id: shareId },
include: { security: true },
include: { security: true, reverseShare: true },
});
if (
@@ -53,6 +58,19 @@ export class ShareSecurityGuard implements CanActivate {
"share_token_required",
);
// Run the JWTGuard to set the user
await super.canActivate(context);
const user = request.user as User;
// Only the creator and reverse share creator can access the reverse share if it's not public
if (share.reverseShare && !share.reverseShare.publicAccess
&& share.creatorId !== user?.id
&& share.reverseShare.creatorId !== user?.id)
throw new ForbiddenException(
"Only reverse share creator can access this share",
"private_share",
);
return true;
}
}

View File

@@ -29,6 +29,7 @@ import { ShareOwnerGuard } from "./guard/shareOwner.guard";
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
import { ShareService } from "./share.service";
import { CompletedShareDTO } from "./dto/shareComplete.dto";
@Controller("shares")
export class ShareController {
constructor(
@@ -86,7 +87,7 @@ export class ShareController {
@UseGuards(CreateShareGuard, ShareOwnerGuard)
async complete(@Param("id") id: string, @Req() request: Request) {
const { reverse_share_token } = request.cookies;
return new ShareDTO().from(
return new CompletedShareDTO().from(
await this.shareService.complete(id, reverse_share_token),
);
}

View File

@@ -159,11 +159,12 @@ export class ShareService {
);
}
if (
share.reverseShare &&
this.config.get("smtp.enabled") &&
share.reverseShare.sendEmailNotification
) {
const notifyReverseShareCreator = share.reverseShare
? this.config.get("smtp.enabled") &&
share.reverseShare.sendEmailNotification
: undefined;
if (notifyReverseShareCreator) {
await this.emailService.sendMailToReverseShareCreator(
share.reverseShare.creator.email,
share.id,
@@ -180,10 +181,15 @@ export class ShareService {
});
}
return this.prisma.share.update({
const updatedShare = await this.prisma.share.update({
where: { id },
data: { uploadLocked: true },
});
return {
...updatedShare,
notifyReverseShareCreator,
};
}
async revertComplete(id: string) {

View File

@@ -22,6 +22,7 @@ import { getExpirationPreview } from "../../../utils/date.util";
import toast from "../../../utils/toast.util";
import FileSizeInput from "../FileSizeInput";
import showCompletedReverseShareModal from "./showCompletedReverseShareModal";
import { getCookie, setCookie } from "cookies-next";
const showCreateReverseShareModal = (
modals: ModalsContextProps,
@@ -61,10 +62,16 @@ const Body = ({
sendEmailNotification: false,
expiration_num: 1,
expiration_unit: "-days",
simplified: !!(getCookie("reverse-share.simplified") ?? false),
publicAccess: !!(getCookie("reverse-share.public-access") ?? true),
},
});
const onSubmit = form.onSubmit(async (values) => {
// remember simplified and publicAccess in cookies
setCookie("reverse-share.simplified", values.simplified);
setCookie("reverse-share.public-access", values.publicAccess);
const expirationDate = moment().add(
form.values.expiration_num,
form.values.expiration_unit.replace(
@@ -91,6 +98,8 @@ const Body = ({
values.maxShareSize,
values.maxUseCount,
values.sendEmailNotification,
values.simplified,
values.publicAccess,
)
.then(({ link }) => {
modals.closeAll();
@@ -210,7 +219,28 @@ const Body = ({
})}
/>
)}
<Switch
mt="xs"
labelPosition="left"
label={t("account.reverseShares.modal.simplified")}
description={t(
"account.reverseShares.modal.simplified.description",
)}
{...form.getInputProps("simplified", {
type: "checkbox",
})}
/>
<Switch
mt="xs"
labelPosition="left"
label={t("account.reverseShares.modal.public-access")}
description={t(
"account.reverseShares.modal.public-access.description",
)}
{...form.getInputProps("publicAccess", {
type: "checkbox",
})}
/>
<Button mt="md" type="submit">
<FormattedMessage id="common.button.create" />
</Button>

View File

@@ -7,12 +7,12 @@ import { FormattedMessage } from "react-intl";
import useTranslate, {
translateOutsideContext,
} from "../../../hooks/useTranslate.hook";
import { Share } from "../../../types/share.type";
import { CompletedShare } from "../../../types/share.type";
import CopyTextField from "../CopyTextField";
const showCompletedUploadModal = (
modals: ModalsContextProps,
share: Share,
share: CompletedShare,
appUrl: string,
) => {
const t = translateOutsideContext();
@@ -25,7 +25,7 @@ const showCompletedUploadModal = (
});
};
const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
const Body = ({ share, appUrl }: { share: CompletedShare; appUrl: string }) => {
const modals = useModals();
const router = useRouter();
const t = useTranslate();
@@ -35,6 +35,19 @@ const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
return (
<Stack align="stretch">
<CopyTextField link={link} />
{share.notifyReverseShareCreator === true && (
<Text
size="sm"
sx={(theme) => ({
color:
theme.colorScheme === "dark"
? theme.colors.gray[3]
: theme.colors.dark[4],
})}
>
{t("upload.modal.completed.notified-reverse-share-creator")}
</Text>
)}
<Text
size="xs"
sx={(theme) => ({

View File

@@ -31,6 +31,7 @@ import { FileUpload } from "../../../types/File.type";
import { CreateShare } from "../../../types/share.type";
import { getExpirationPreview } from "../../../utils/date.util";
import React from "react";
import toast from "../../../utils/toast.util";
const showCreateUploadModal = (
modals: ModalsContextProps,
@@ -41,12 +42,26 @@ const showCreateUploadModal = (
allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean;
maxExpirationInHours: number;
simplified: boolean;
},
files: FileUpload[],
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void,
) => {
const t = translateOutsideContext();
if (options.simplified) {
return modals.openModal({
title: t("upload.modal.title"),
children: (
<SimplifiedCreateUploadModalModal
options={options}
files={files}
uploadCallback={uploadCallback}
/>
),
});
}
return modals.openModal({
title: t("upload.modal.title"),
children: (
@@ -59,6 +74,23 @@ const showCreateUploadModal = (
});
};
const generateLink = () =>
Buffer.from(Math.random().toString(), "utf8")
.toString("base64")
.substring(10, 17);
const generateAvailableLink = async (times = 10): Promise<string> => {
if (times <= 0) {
throw new Error("Could not generate available link");
}
const _link = generateLink();
if (!(await shareService.isShareIdAvailable(_link))) {
return await generateAvailableLink(times - 1);
} else {
return _link;
}
};
const CreateUploadModalBody = ({
uploadCallback,
files,
@@ -78,9 +110,7 @@ const CreateUploadModalBody = ({
const modals = useModals();
const t = useTranslate();
const generatedLink = Buffer.from(Math.random().toString(), "utf8")
.toString("base64")
.substr(10, 7);
const generatedLink = generateLink();
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true);
@@ -202,14 +232,7 @@ const CreateUploadModalBody = ({
<Button
style={{ flex: "0 0 auto" }}
variant="outline"
onClick={() =>
form.setFieldValue(
"link",
Buffer.from(Math.random().toString(), "utf8")
.toString("base64")
.substr(10, 7),
)
}
onClick={() => form.setFieldValue("link", generateLink())}
>
<FormattedMessage id="common.button.generate" />
</Button>
@@ -429,4 +452,108 @@ const CreateUploadModalBody = ({
);
};
const SimplifiedCreateUploadModalModal = ({
uploadCallback,
files,
options,
}: {
files: FileUpload[];
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void;
options: {
isUserSignedIn: boolean;
isReverseShare: boolean;
appUrl: string;
allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean;
maxExpirationInHours: number;
};
}) => {
const modals = useModals();
const t = useTranslate();
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true);
const validationSchema = yup.object().shape({
name: yup
.string()
.transform((value) => value || undefined)
.min(3, t("common.error.too-short", { length: 3 }))
.max(30, t("common.error.too-long", { length: 30 })),
});
const form = useForm({
initialValues: {
name: undefined,
description: undefined,
},
validate: yupResolver(validationSchema),
});
const onSubmit = form.onSubmit(async (values) => {
const link = await generateAvailableLink().catch(() => {
toast.error(t("upload.modal.link.error.taken"));
return undefined;
});
if (!link) {
return;
}
uploadCallback(
{
id: link,
name: values.name,
expiration: "never",
recipients: [],
description: values.description,
security: {
password: undefined,
maxViews: undefined,
},
},
files,
);
modals.closeAll();
});
return (
<Stack>
{showNotSignedInAlert && !options.isUserSignedIn && (
<Alert
withCloseButton
onClose={() => setShowNotSignedInAlert(false)}
icon={<TbAlertCircle size={16} />}
title={t("upload.modal.not-signed-in")}
color="yellow"
>
<FormattedMessage id="upload.modal.not-signed-in-description" />
</Alert>
)}
<form onSubmit={onSubmit}>
<Stack align="stretch">
<Stack align="stretch">
<TextInput
variant="filled"
placeholder={t(
"upload.modal.accordion.name-and-description.name.placeholder",
)}
{...form.getInputProps("name")}
/>
<Textarea
variant="filled"
placeholder={t(
"upload.modal.accordion.name-and-description.description.placeholder",
)}
{...form.getInputProps("description")}
/>
</Stack>
<Button type="submit" data-autofocus>
<FormattedMessage id="common.button.share" />
</Button>
</Stack>
</form>
</Stack>
);
};
export default showCreateUploadModal;

View File

@@ -199,6 +199,14 @@ export default {
"account.reverseShares.modal.send-email.description":
"Send an email notification when a share is created with this reverse share link.",
"account.reverseShares.modal.simplified": "Simple mode",
"account.reverseShares.modal.simplified.description":
"Make it easy for the person uploading the file to share it with you. They will be able to customize only the name and description of the share.",
"account.reverseShares.modal.public-access": "Public access",
"account.reverseShares.modal.public-access.description":
"Make the created shares with this reverse share public. If disabled, only you and the creator of the share can view it.",
"account.reverseShares.modal.max-use.label": "Max uses",
"account.reverseShares.modal.max-use.description":
"The maximum amount of times this URL can be used to create a share.",
@@ -342,6 +350,7 @@ export default {
"upload.modal.completed.expires-on":
"This share will expire on {expiration}.",
"upload.modal.completed.share-ready": "Share ready",
"upload.modal.completed.notified-reverse-share-creator": "We have notified the creator of the reverse share. You can also manually share this link with them through other means.",
// END /upload
@@ -355,6 +364,8 @@ export default {
"share.error.not-found.title": "Share not found",
"share.error.not-found.description":
"The share you're looking for doesn't exist.",
"share.error.access-denied.title": "Private share",
"share.error.access-denied.description": "The current account does not have permission to access this share",
"share.modal.password.title": "Password required",
"share.modal.password.description":

View File

@@ -152,6 +152,12 @@ export default {
"account.reverseShares.modal.max-size.label": "共享文件上限",
"account.reverseShares.modal.send-email": "发送邮件提醒",
"account.reverseShares.modal.send-email.description": "当这个预留共享链接被用于共享时,发送邮件提醒",
"account.reverseShares.modal.simplified": "简单模式",
"account.reverseShares.modal.simplified.description":
"让上传者更轻松地与你共享文件,他们仅能自定义共享的名称和描述。",
"account.reverseShares.modal.public-access": "公开访问",
"account.reverseShares.modal.public-access.description":
"让通过这个预留共享创建共享能被公开访问。如果禁用,将只有您和创建者能够访问。",
"account.reverseShares.modal.max-use.label": "最大使用次数",
"account.reverseShares.modal.max-use.description": "这个预留共享链接可被用于创建共享的最大使用次数",
"account.reverseShare.never-expires": "这个预留共享永不过期",
@@ -255,6 +261,7 @@ export default {
"upload.modal.completed.never-expires": "这个共享永不过期",
"upload.modal.completed.expires-on": "这个共享将过期于 {expiration}.",
"upload.modal.completed.share-ready": "共享创建完毕",
"upload.modal.completed.notified-reverse-share-creator": "我们已经通知预留共享的创建者。您也可以通过其他方式将该链接手动分享给他们。",
// END /upload
// /share/[id]
"share.title": "共享 {shareId}",
@@ -264,6 +271,8 @@ export default {
"share.error.removed.title": "共享已删除",
"share.error.not-found.title": "共享未找到",
"share.error.not-found.description": "共享文件走丢了",
"share.error.access-denied.title": "私有共享",
"share.error.access-denied.description": "当前账户没有权限访问此共享",
"share.modal.password.title": "需要密码",
"share.modal.password.description": "请输入密码来访问此共享",
"share.modal.password": "密码",

View File

@@ -43,6 +43,12 @@ const Share = ({ shareId }: { shareId: string }) => {
t("share.error.not-found.description"),
);
}
} else if (e.response.status == 403 && error == "share_removed") {
showErrorModal(
modals,
t("share.error.access-denied.title"),
t("share.error.access-denied.description"),
);
} else {
showErrorModal(modals, t("common.error"), t("common.error.unknown"));
}

View File

@@ -69,6 +69,12 @@ const Share = ({ shareId }: { shareId: string }) => {
"go-home",
);
}
} else if (e.response.status == 403 && error == "private_share") {
showErrorModal(
modals,
t("share.error.access-denied.title"),
t("share.error.access-denied.description"),
);
} else if (error == "share_password_required") {
showEnterPasswordModal(modals, getShareToken);
} else if (error == "share_token_required") {

View File

@@ -17,12 +17,14 @@ const Share = ({ reverseShareToken }: { reverseShareToken: string }) => {
const [isLoading, setIsLoading] = useState(true);
const [maxShareSize, setMaxShareSize] = useState(0);
const [simplified, setSimplified] = useState(false);
useEffect(() => {
shareService
.setReverseShare(reverseShareToken)
.then((reverseShareTokenData) => {
setMaxShareSize(parseInt(reverseShareTokenData.maxShareSize));
setSimplified(reverseShareTokenData.simplified);
setIsLoading(false);
})
.catch(() => {
@@ -38,7 +40,13 @@ const Share = ({ reverseShareToken }: { reverseShareToken: string }) => {
if (isLoading) return <LoadingOverlay visible />;
return <Upload isReverseShare maxShareSize={maxShareSize} />;
return (
<Upload
isReverseShare
maxShareSize={maxShareSize}
simplified={simplified}
/>
);
};
export default Share;

View File

@@ -25,9 +25,11 @@ let createdShare: Share;
const Upload = ({
maxShareSize,
isReverseShare = false,
simplified,
}: {
maxShareSize?: number;
isReverseShare: boolean;
simplified: boolean;
}) => {
const modals = useModals();
const t = useTranslate();
@@ -133,6 +135,7 @@ const Upload = ({
),
enableEmailRecepients: config.get("email.enableShareEmailRecipients"),
maxExpirationInHours: config.get("share.maxExpiration"),
simplified,
},
files,
uploadFiles,

View File

@@ -109,6 +109,8 @@ const createReverseShare = async (
maxShareSize: number,
maxUseCount: number,
sendEmailNotification: boolean,
simplified: boolean,
publicAccess: boolean,
) => {
return (
await api.post("reverseShares", {
@@ -116,6 +118,8 @@ const createReverseShare = async (
maxShareSize: maxShareSize.toString(),
maxUseCount,
sendEmailNotification,
simplified,
publicAccess,
})
).data;
};

View File

@@ -11,6 +11,15 @@ export type Share = {
hasPassword: boolean;
};
export type CompletedShare = Share & {
/**
* undefined means is not reverse share
* true means server was send email to reverse share creator
* false means server was not send email to reverse share creator
* */
notifyReverseShareCreator: boolean | undefined;
};
export type CreateShare = {
id: string;
name?: string;