feat: managed user shares (#63)
* Implement managed user shares features: - Add recipient restriction settings for shares - Add received shares overview page for logged in users - Support automatic linking of user accounts to shares based on email address - Stop exposing registered/unregistered status of recipient emails to prevent account enumeration - Enable folder uploads via drag-and-drop and selection - Allow administrators to bypass maximum file expiration limits - Swap and align recipient icons, hover tips, and colors in My Shares * harden user recipient security validations on file downloads - Query recipient relations in FileSecurityGuard - Validate restrictToRecipients for direct/tokenless file downloads and throw a ForbiddenException - Credit to Leonardo for reporting this issue, that led to this patch, before the code was released. * i18n: extract restrictToRecipients hardcoded string to translations key - Use share.restrictedToRecipients key in ShareSecurityGuard and FileSecurityGuard --------- Co-authored-by: smp46 <me@smp46.me>
This commit is contained in:
committed by
smp46
co-authored by
smp46
parent
27414e6d97
commit
d69281aeff
@@ -0,0 +1,31 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ShareUserRecipient" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" TEXT NOT NULL,
|
||||
"shareId" TEXT NOT NULL,
|
||||
CONSTRAINT "ShareUserRecipient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ShareUserRecipient_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_ShareSecurity" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"password" TEXT,
|
||||
"maxViews" INTEGER,
|
||||
"restrictToRecipients" BOOLEAN NOT NULL DEFAULT false,
|
||||
"shareId" TEXT,
|
||||
CONSTRAINT "ShareSecurity_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_ShareSecurity" ("createdAt", "id", "maxViews", "password", "shareId") SELECT "createdAt", "id", "maxViews", "password", "shareId" FROM "ShareSecurity";
|
||||
DROP TABLE "ShareSecurity";
|
||||
ALTER TABLE "new_ShareSecurity" RENAME TO "ShareSecurity";
|
||||
CREATE UNIQUE INDEX "ShareSecurity_shareId_key" ON "ShareSecurity"("shareId");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ShareUserRecipient_userId_shareId_key" ON "ShareUserRecipient"("userId", "shareId");
|
||||
@@ -19,10 +19,11 @@ model User {
|
||||
ldapDN String? @unique
|
||||
shareSizeLimit String?
|
||||
|
||||
shares Share[]
|
||||
refreshTokens RefreshToken[]
|
||||
loginTokens LoginToken[]
|
||||
reverseShares ReverseShare[]
|
||||
shares Share[]
|
||||
refreshTokens RefreshToken[]
|
||||
loginTokens LoginToken[]
|
||||
reverseShares ReverseShare[]
|
||||
shareUserRecipients ShareUserRecipient[]
|
||||
|
||||
totpEnabled Boolean @default(false)
|
||||
totpVerified Boolean @default(false)
|
||||
@@ -98,9 +99,10 @@ model Share {
|
||||
reverseShareId String?
|
||||
reverseShare ReverseShare? @relation(fields: [reverseShareId], references: [id], onDelete: Cascade)
|
||||
|
||||
security ShareSecurity?
|
||||
recipients ShareRecipient[]
|
||||
files File[]
|
||||
security ShareSecurity?
|
||||
recipients ShareRecipient[]
|
||||
userRecipients ShareUserRecipient[]
|
||||
files File[]
|
||||
storageProvider String @default("LOCAL")
|
||||
}
|
||||
|
||||
@@ -145,13 +147,27 @@ model ShareSecurity {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
password String?
|
||||
maxViews Int?
|
||||
password String?
|
||||
maxViews Int?
|
||||
restrictToRecipients Boolean @default(false)
|
||||
|
||||
shareId String? @unique
|
||||
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ShareUserRecipient {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
shareId String
|
||||
share Share @relation(fields: [shareId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, shareId])
|
||||
}
|
||||
|
||||
model Config {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -127,6 +127,11 @@ export const configVariables = {
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
enableUserRecipients: {
|
||||
type: "boolean",
|
||||
defaultValue: "false",
|
||||
secret: false,
|
||||
},
|
||||
fileRetentionPeriod: {
|
||||
type: "timespan",
|
||||
defaultValue: "0 days",
|
||||
|
||||
@@ -48,7 +48,11 @@ export class FileSecurityGuard extends ShareSecurityGuard {
|
||||
|
||||
const share = await this._prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
include: { security: true },
|
||||
include: {
|
||||
security: true,
|
||||
userRecipients: { select: { userId: true } },
|
||||
recipients: { select: { email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// If there is no share token the user requests a file directly
|
||||
@@ -70,6 +74,19 @@ export class FileSecurityGuard extends ShareSecurityGuard {
|
||||
throw new NotFoundException(this._i18n.t("file.notFound"));
|
||||
}
|
||||
|
||||
if (share.security?.restrictToRecipients) {
|
||||
const user = await this.authenticateUser(context);
|
||||
const isCreator = user && share.creatorId === user.id;
|
||||
const isRecipient = await this.isRecipient(share, user);
|
||||
|
||||
if (!isCreator && !isRecipient) {
|
||||
throw new ForbiddenException(
|
||||
this._i18n.t("share.restrictedToRecipients"),
|
||||
"share_restricted_to_recipients",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (share.security?.password)
|
||||
throw new ForbiddenException(this._i18n.t("file.passwordProtected"));
|
||||
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
"wrongPassword": "Wrong password",
|
||||
"tokenRequired": "Share token required",
|
||||
"privateShare": "Only reverse share creator can access this share",
|
||||
"maxViewsExceeded": "Maximum views exceeded"
|
||||
"maxViewsExceeded": "Maximum views exceeded",
|
||||
"restrictedToRecipients": "This share is restricted to specific recipients. Please log in to access it."
|
||||
}
|
||||
|
||||
@@ -6,4 +6,7 @@ export class MyShareSecurityDTO {
|
||||
|
||||
@Expose()
|
||||
maxViews: number;
|
||||
|
||||
@Expose()
|
||||
restrictToRecipients: boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { IsNumber, IsOptional, IsString, Length } from "class-validator";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Length,
|
||||
} from "class-validator";
|
||||
|
||||
export class ShareSecurityDTO {
|
||||
@IsString()
|
||||
@@ -9,4 +15,8 @@ export class ShareSecurityDTO {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
maxViews: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
restrictToRecipients: boolean;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,46 @@ export class ShareSecurityGuard extends JwtGuard {
|
||||
super(configService);
|
||||
}
|
||||
|
||||
protected async authenticateUser(
|
||||
context: ExecutionContext,
|
||||
): Promise<User | undefined> {
|
||||
await super.canActivate(context);
|
||||
const request: Request = context.switchToHttp().getRequest();
|
||||
return request.user as User;
|
||||
}
|
||||
|
||||
protected async isRecipient(
|
||||
share: {
|
||||
id: string;
|
||||
userRecipients: { userId: string }[];
|
||||
recipients: { email: string }[];
|
||||
},
|
||||
user?: User,
|
||||
): Promise<boolean> {
|
||||
if (!user) return false;
|
||||
if (!this.configService.get("share.enableUserRecipients")) return false;
|
||||
|
||||
// Already linked as a recipient of this share.
|
||||
const isLinked = share.userRecipients.some((r) => r.userId === user.id);
|
||||
if (isLinked) return true;
|
||||
|
||||
// Otherwise, if the user's (account-verified) email matches one of the
|
||||
// share's recipients, grant access and link them
|
||||
const userEmail = user.email?.toLowerCase();
|
||||
const isEmailRecipient =
|
||||
!!userEmail &&
|
||||
share.recipients.some((r) => r.email.toLowerCase() === userEmail);
|
||||
if (isEmailRecipient) {
|
||||
await this.prisma.shareUserRecipient.upsert({
|
||||
where: { userId_shareId: { userId: user.id, shareId: share.id } },
|
||||
create: { userId: user.id, shareId: share.id },
|
||||
update: {},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request: Request = context.switchToHttp().getRequest();
|
||||
|
||||
@@ -38,14 +78,17 @@ export class ShareSecurityGuard extends JwtGuard {
|
||||
|
||||
const share = await this.prisma.share.findUnique({
|
||||
where: { id: shareId },
|
||||
include: { security: true, reverseShare: true },
|
||||
include: {
|
||||
security: true,
|
||||
reverseShare: true,
|
||||
userRecipients: { select: { userId: true } },
|
||||
recipients: { select: { email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!share) throw new NotFoundException(this.i18n.t("share.notFound"));
|
||||
|
||||
// Run the JWTGuard to set the user
|
||||
await super.canActivate(context);
|
||||
const user = request.user as User;
|
||||
const user = await this.authenticateUser(context);
|
||||
|
||||
// If admin access is enabled and user is admin, allow access
|
||||
if (
|
||||
@@ -62,6 +105,22 @@ export class ShareSecurityGuard extends JwtGuard {
|
||||
throw new NotFoundException(this.i18n.t("share.notFound"));
|
||||
}
|
||||
|
||||
// If user sharing is enabled, check if the authenticated user is a recipient
|
||||
if (await this.isRecipient(share, user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If share is restricted to named recipients, block everyone else (excluding the creator)
|
||||
if (
|
||||
share.security?.restrictToRecipients &&
|
||||
(!user || share.creatorId !== user.id)
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
this.i18n.t("share.restrictedToRecipients"),
|
||||
"share_restricted_to_recipients",
|
||||
);
|
||||
}
|
||||
|
||||
if (share.security?.password && !shareToken)
|
||||
throw new ForbiddenException(
|
||||
this.i18n.t("file.passwordProtected"),
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
@@ -19,6 +20,7 @@ import * as moment from "moment";
|
||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { AdminShareDTO } from "./dto/adminShare.dto";
|
||||
import { CreateShareDTO } from "./dto/createShare.dto";
|
||||
import { MyShareDTO } from "./dto/myShare.dto";
|
||||
@@ -40,6 +42,7 @@ export class ShareController {
|
||||
constructor(
|
||||
private shareService: ShareService,
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Get("all")
|
||||
@@ -56,6 +59,14 @@ export class ShareController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("received")
|
||||
@UseGuards(JwtGuard)
|
||||
async getReceivedShares(@GetUser() user: User) {
|
||||
if (!this.config.get("share.enableUserRecipients"))
|
||||
throw new ForbiddenException("User recipients are not enabled");
|
||||
return this.shareService.getReceivedShares(user.id);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@UseGuards(IdValidation, ShareSecurityGuard)
|
||||
async get(@Param("id") id: string) {
|
||||
|
||||
@@ -53,10 +53,32 @@ export class ShareService {
|
||||
if (!share.security || Object.keys(share.security).length == 0)
|
||||
share.security = undefined;
|
||||
|
||||
if (share.security?.restrictToRecipients && share.security?.password) {
|
||||
throw new BadRequestException(
|
||||
"Cannot set a password on a share restricted to recipients.",
|
||||
);
|
||||
}
|
||||
|
||||
if (share.security?.password) {
|
||||
share.security.password = await argon.hash(share.security.password);
|
||||
}
|
||||
|
||||
if (
|
||||
this.configService.get("share.enableUserRecipients") &&
|
||||
share.security?.restrictToRecipients
|
||||
) {
|
||||
if (!share.recipients?.length) {
|
||||
throw new BadRequestException(
|
||||
"A share restricted to recipients must have at least one recipient.",
|
||||
);
|
||||
}
|
||||
// Note: we intentionally do NOT check whether the recipient emails belong
|
||||
// to registered accounts. Doing so would leak which emails have an account
|
||||
// (account enumeration). Recipients who aren't registered yet simply sign
|
||||
// up to gain access (see ShareSecurityGuard); if signups are disabled they
|
||||
// can't access it.
|
||||
}
|
||||
|
||||
let expirationDate: Date;
|
||||
|
||||
// If share is created by a reverse share token override the expiration date
|
||||
@@ -171,6 +193,26 @@ export class ShareService {
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-link email recipients who are registered users so the share appears in their dashboard
|
||||
if (this.configService.get("share.enableUserRecipients")) {
|
||||
const emails = share.recipients.map((r) => r.email);
|
||||
if (emails.length > 0) {
|
||||
const matchedUsers = await this.prisma.user.findMany({
|
||||
where: { email: { in: emails } },
|
||||
select: { id: true },
|
||||
});
|
||||
for (const matchedUser of matchedUsers) {
|
||||
await this.prisma.shareUserRecipient.upsert({
|
||||
where: {
|
||||
userId_shareId: { userId: matchedUser.id, shareId: share.id },
|
||||
},
|
||||
create: { userId: matchedUser.id, shareId: share.id },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notifyReverseShareCreator = share.reverseShare
|
||||
? this.config.get("smtp.enabled") &&
|
||||
share.reverseShare.sendEmailNotification
|
||||
@@ -399,6 +441,22 @@ export class ShareService {
|
||||
return (await this.prisma.share.findUnique({ where: { id } })).uploadLocked;
|
||||
}
|
||||
|
||||
async getReceivedShares(userId: string) {
|
||||
return this.prisma.shareUserRecipient.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
share: {
|
||||
include: {
|
||||
creator: true,
|
||||
files: true,
|
||||
security: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
private transformShare(share: any) {
|
||||
return {
|
||||
...share,
|
||||
@@ -408,6 +466,7 @@ export class ShareService {
|
||||
security: {
|
||||
maxViews: share.security?.maxViews,
|
||||
passwordProtected: !!share.security?.password,
|
||||
restrictToRecipients: !!share.security?.restrictToRecipients,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -217,6 +217,14 @@ const Header = () => {
|
||||
link: "/account/reverseShares",
|
||||
label: t("navbar.links.reverse"),
|
||||
},
|
||||
...(config.get("share.enableUserRecipients")
|
||||
? [
|
||||
{
|
||||
link: "/account/received",
|
||||
label: t("navbar.links.received"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const mobileProfileLinks: NavLink[] = [
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import Link from "next/link";
|
||||
import { TbArrowLoopLeft, TbLink } from "react-icons/tb";
|
||||
import { TbArrowLoopLeft, TbLink, TbMailbox } from "react-icons/tb";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HoverTip } from "../../components/core/HoverTip";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import { useState } from "react";
|
||||
|
||||
const NavbarShareMneu = () => {
|
||||
const t = useTranslate();
|
||||
const config = useConfig();
|
||||
const [menuOpened, setMenuOpened] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -32,6 +34,15 @@ const NavbarShareMneu = () => {
|
||||
>
|
||||
<FormattedMessage id="navbar.links.reverse" />
|
||||
</Menu.Item>
|
||||
{config.get("share.enableUserRecipients") && (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href="/account/received"
|
||||
icon={<TbMailbox />}
|
||||
>
|
||||
<FormattedMessage id="navbar.links.received" />
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -138,6 +138,15 @@ const Body = ({
|
||||
{currentShare.description || "-"}
|
||||
</Text>
|
||||
|
||||
{currentShare.recipients && currentShare.recipients.length > 0 && (
|
||||
<Text size="sm">
|
||||
<b>
|
||||
<FormattedMessage id="upload.modal.accordion.email.title" />:{" "}
|
||||
</b>
|
||||
{currentShare.recipients.join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text size="sm">
|
||||
<b>
|
||||
<FormattedMessage id="account.shares.table.createdAt" />:{" "}
|
||||
|
||||
@@ -45,6 +45,7 @@ const showCreateUploadModal = (
|
||||
defaultAppUrl: string;
|
||||
allowUnauthenticatedShares: boolean;
|
||||
enableEmailRecepients: boolean;
|
||||
enableUserRecipients: boolean;
|
||||
maxExpiration: Timespan;
|
||||
defaultExpiration: Timespan;
|
||||
shareIdLength: number;
|
||||
@@ -121,6 +122,7 @@ const CreateUploadModalBody = ({
|
||||
defaultAppUrl: string;
|
||||
allowUnauthenticatedShares: boolean;
|
||||
enableEmailRecepients: boolean;
|
||||
enableUserRecipients: boolean;
|
||||
maxExpiration: Timespan;
|
||||
defaultExpiration: Timespan;
|
||||
shareIdLength: number;
|
||||
@@ -174,10 +176,19 @@ const CreateUploadModalBody = ({
|
||||
expiration_num: defaultTimespan.value,
|
||||
expiration_unit: `-${defaultTimespan.unit}` as string,
|
||||
never_expires: false,
|
||||
restrictToRecipients: false,
|
||||
},
|
||||
validate: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const handleRestrictToggle = (checked: boolean) => {
|
||||
form.setFieldValue("restrictToRecipients", checked);
|
||||
if (checked) {
|
||||
// A share can't be both password-protected and restricted to recipients.
|
||||
form.setFieldValue("password", undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = form.onSubmit(async (values) => {
|
||||
if (!(await shareService.isShareIdAvailable(values.link))) {
|
||||
form.setFieldError("link", t("upload.modal.link.error.taken"));
|
||||
@@ -223,8 +234,11 @@ const CreateUploadModalBody = ({
|
||||
recipients: values.recipients,
|
||||
description: values.description,
|
||||
security: {
|
||||
password: values.password || undefined,
|
||||
password: values.restrictToRecipients
|
||||
? undefined
|
||||
: values.password || undefined,
|
||||
maxViews: values.maxViews || undefined,
|
||||
restrictToRecipients: values.restrictToRecipients || undefined,
|
||||
},
|
||||
},
|
||||
files,
|
||||
@@ -417,9 +431,18 @@ const CreateUploadModalBody = ({
|
||||
return undefined;
|
||||
}
|
||||
form.setFieldError("recipients", null);
|
||||
const newRecipients = form.values.recipients.includes(
|
||||
query,
|
||||
)
|
||||
? form.values.recipients
|
||||
: [...form.values.recipients, query];
|
||||
form.setFieldValue("recipients", newRecipients);
|
||||
return query;
|
||||
}}
|
||||
{...form.getInputProps("recipients")}
|
||||
onChange={(value: string[]) => {
|
||||
form.setFieldValue("recipients", value);
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === "," || e.key === ";") {
|
||||
e.preventDefault();
|
||||
@@ -428,10 +451,11 @@ const CreateUploadModalBody = ({
|
||||
inputValue.match(/^\S+@\S+\.\S+$/) &&
|
||||
!form.values.recipients.includes(inputValue)
|
||||
) {
|
||||
form.setFieldValue("recipients", [
|
||||
const newRecipients = [
|
||||
...form.values.recipients,
|
||||
inputValue,
|
||||
]);
|
||||
];
|
||||
form.setFieldValue("recipients", newRecipients);
|
||||
}
|
||||
setEmailSearch("");
|
||||
} else if (e.key === " ") {
|
||||
@@ -440,6 +464,18 @@ const CreateUploadModalBody = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{options.enableUserRecipients && (
|
||||
<Checkbox
|
||||
mt="sm"
|
||||
label={t(
|
||||
"upload.modal.accordion.email.restrict-to-recipients",
|
||||
)}
|
||||
checked={form.values.restrictToRecipients}
|
||||
onChange={(e) =>
|
||||
handleRestrictToggle(e.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
@@ -450,15 +486,19 @@ const CreateUploadModalBody = ({
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack align="stretch">
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
placeholder={t(
|
||||
"upload.modal.accordion.security.password.placeholder",
|
||||
)}
|
||||
label={t("upload.modal.accordion.security.password.label")}
|
||||
autoComplete="new-password"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
{!form.values.restrictToRecipients && (
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
placeholder={t(
|
||||
"upload.modal.accordion.security.password.placeholder",
|
||||
)}
|
||||
label={t(
|
||||
"upload.modal.accordion.security.password.label",
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
)}
|
||||
<NumberInput
|
||||
min={1}
|
||||
type="number"
|
||||
|
||||
@@ -7,6 +7,7 @@ export default {
|
||||
|
||||
"navbar.links.shares": "My shares",
|
||||
"navbar.links.reverse": "Reverse shares",
|
||||
"navbar.links.received": "Received shares",
|
||||
|
||||
"navbar.avatar.account": "My account",
|
||||
"navbar.avatar.admin": "Administration",
|
||||
@@ -174,6 +175,9 @@ export default {
|
||||
"account.shares.table.createdAt": "Created on",
|
||||
"account.shares.table.size": "Size",
|
||||
"account.shares.table.password-protected": "Password protected",
|
||||
"account.shares.table.recipients": "Recipients",
|
||||
"account.shares.table.restricted-to-recipients": "Restricted to recipients only",
|
||||
"account.shares.table.shared-with-recipients": "Shared with recipients",
|
||||
"account.shares.table.visitor-count": "{count} of {max}",
|
||||
"account.shares.table.expiry-never": "Never",
|
||||
|
||||
@@ -189,6 +193,15 @@ export default {
|
||||
|
||||
// END /account/shares
|
||||
|
||||
// /account/received
|
||||
"account.received-shares.title": "Received shares",
|
||||
"account.received-shares.title.empty": "No shares received yet",
|
||||
"account.received-shares.description.empty":
|
||||
"Shares sent to your email address will appear here.",
|
||||
"account.received-shares.table.from": "From",
|
||||
"account.received-shares.button.open": "Open",
|
||||
// END /account/received
|
||||
|
||||
// /account/reverseShares
|
||||
"account.reverseShares.title": "Reverse shares",
|
||||
"account.reverseShares.description":
|
||||
@@ -379,6 +392,8 @@ export default {
|
||||
"upload.modal.accordion.email.title": "Email recipients",
|
||||
"upload.modal.accordion.email.placeholder": "Enter email recipients",
|
||||
"upload.modal.accordion.email.invalid-email": "Invalid email address",
|
||||
"upload.modal.accordion.email.restrict-to-recipients":
|
||||
"Restrict access to these recipients only (they must sign in to access it)",
|
||||
|
||||
"upload.modal.accordion.security.title": "Security options",
|
||||
"upload.modal.accordion.security.password.label": "Password protection",
|
||||
@@ -412,6 +427,10 @@ export default {
|
||||
"share.error.access-denied.title": "Private share",
|
||||
"share.error.access-denied.description":
|
||||
"The current account does not have permission to access this share",
|
||||
"share.error.restricted.title": "Restricted share",
|
||||
"share.error.restricted.description":
|
||||
"This share is restricted to specific recipients. Please log in to access it.",
|
||||
"share.error.restricted.button": "Log in",
|
||||
|
||||
"share.modal.password.title": "Password required",
|
||||
"share.modal.password.description":
|
||||
@@ -628,6 +647,9 @@ export default {
|
||||
"Force reverse shares to be created in simple mode. If disabled, the creator of the reverse share can choose between simple and advanced mode.",
|
||||
"admin.config.share.allow-admin-access-all-shares.description":
|
||||
"Allow administrators to access all shares, even if they are password protected, expired or deleted.",
|
||||
"admin.config.share.enable-user-recipients": "Enable sharing with registered users",
|
||||
"admin.config.share.enable-user-recipients.description":
|
||||
"When enabled, shares sent to a registered user's email address will automatically appear in their account. Users can also restrict share access to named recipients only.",
|
||||
"admin.config.share.file-retention-period": "File retention period",
|
||||
"admin.config.share.file-retention-period.description":
|
||||
"How long files are kept after a share expires or gets deleted. Only useful if the 'Allow admin access to all shares' is also enabled. Set to -1 to keep files forever.",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Space,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import moment from "moment";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import Meta from "../../components/Meta";
|
||||
import CenterLoader from "../../components/core/CenterLoader";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import shareService from "../../services/share.service";
|
||||
|
||||
const ReceivedShares = () => {
|
||||
const t = useTranslate();
|
||||
const router = useRouter();
|
||||
const config = useConfig();
|
||||
const [receivedShares, setReceivedShares] = useState<any[]>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.get("share.enableUserRecipients")) {
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
shareService.getReceivedShares().then((data) => setReceivedShares(data));
|
||||
}, []);
|
||||
|
||||
if (!receivedShares) return <CenterLoader />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("account.received-shares.title")} />
|
||||
<Title mb={30} order={3}>
|
||||
<FormattedMessage id="account.received-shares.title" />
|
||||
</Title>
|
||||
{receivedShares.length === 0 ? (
|
||||
<Center style={{ height: "70vh" }}>
|
||||
<Stack align="center" spacing={10}>
|
||||
<Title order={3}>
|
||||
<FormattedMessage id="account.received-shares.title.empty" />
|
||||
</Title>
|
||||
<Text>
|
||||
<FormattedMessage id="account.received-shares.description.empty" />
|
||||
</Text>
|
||||
<Space h={5} />
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<Box sx={{ display: "block", overflowX: "auto" }}>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<FormattedMessage id="account.shares.table.id" />
|
||||
</th>
|
||||
<th>
|
||||
<FormattedMessage id="account.shares.table.name" />
|
||||
</th>
|
||||
<th>
|
||||
<FormattedMessage id="account.received-shares.table.from" />
|
||||
</th>
|
||||
<th>
|
||||
<FormattedMessage id="account.shares.table.expiresAt" />
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{receivedShares.map(({ share }) => (
|
||||
<tr key={share.id}>
|
||||
<td>{share.id}</td>
|
||||
<td>{share.name}</td>
|
||||
<td>{share.creator?.username ?? "—"}</td>
|
||||
<td>
|
||||
{moment(share.expiration).unix() === 0 ? (
|
||||
<FormattedMessage id="account.shares.table.expiry-never" />
|
||||
) : (
|
||||
moment(share.expiration).format("LLL")
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Group position="right">
|
||||
<Button
|
||||
component={Link}
|
||||
href={`/share/${share.id}`}
|
||||
variant="light"
|
||||
size="xs"
|
||||
color="victoria"
|
||||
>
|
||||
<FormattedMessage id="account.received-shares.button.open" />
|
||||
</Button>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReceivedShares;
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
useMantineTheme,
|
||||
Group,
|
||||
Space,
|
||||
Stack,
|
||||
@@ -21,7 +22,9 @@ import {
|
||||
TbLink,
|
||||
TbLock,
|
||||
TbTrash,
|
||||
TbUsers,
|
||||
} from "react-icons/tb";
|
||||
import { FaUserLock } from "react-icons/fa";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import Meta from "../../components/Meta";
|
||||
import showShareInformationsModal from "../../components/share/showShareInformationsModal";
|
||||
@@ -41,6 +44,7 @@ const MyShares = () => {
|
||||
const config = useConfig();
|
||||
const { user } = useUser();
|
||||
const t = useTranslate();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const [shares, setShares] = useState<MyShare[]>();
|
||||
|
||||
@@ -82,6 +86,7 @@ const MyShares = () => {
|
||||
<th>
|
||||
<FormattedMessage id="account.shares.table.name" />
|
||||
</th>
|
||||
|
||||
<th>
|
||||
<FormattedMessage id="account.shares.table.visitors" />
|
||||
</th>
|
||||
@@ -92,16 +97,46 @@ const MyShares = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shares.map((share) => (
|
||||
{shares.map((share) => {
|
||||
return (
|
||||
<tr key={share.id}>
|
||||
<td>
|
||||
<Group spacing="xs">
|
||||
{share.id}{" "}
|
||||
{share.security?.passwordProtected && (
|
||||
<TbLock
|
||||
color="orange"
|
||||
title={t("account.shares.table.password-protected")}
|
||||
/>
|
||||
<HoverTip label="Password Protected">
|
||||
<span style={{ display: "inline-flex" }}>
|
||||
<TbLock
|
||||
color="orange"
|
||||
title={t("account.shares.table.password-protected")}
|
||||
/>
|
||||
</span>
|
||||
</HoverTip>
|
||||
)}
|
||||
{config.get("share.enableUserRecipients") && (
|
||||
share.security?.restrictToRecipients ? (
|
||||
<HoverTip label="Recipients Only">
|
||||
<span style={{ display: "inline-flex" }}>
|
||||
<FaUserLock
|
||||
color={theme.colors.gray[6]}
|
||||
title={t(
|
||||
"account.shares.table.restricted-to-recipients",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</HoverTip>
|
||||
) : share.recipients?.length ? (
|
||||
<HoverTip label="Sent to Recipients">
|
||||
<span style={{ display: "inline-flex" }}>
|
||||
<TbUsers
|
||||
color={theme.colors.gray[6]}
|
||||
title={t(
|
||||
"account.shares.table.shared-with-recipients",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</HoverTip>
|
||||
) : null
|
||||
)}
|
||||
</Group>
|
||||
</td>
|
||||
@@ -225,7 +260,8 @@ const MyShares = () => {
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { ActionIcon, Box, Group, Text, Title } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
@@ -32,6 +41,7 @@ const Share = ({ shareId }: { shareId: string }) => {
|
||||
const modals = useModals();
|
||||
const router = useRouter();
|
||||
const [share, setShare] = useState<ShareType>();
|
||||
const [isRestricted, setIsRestricted] = useState(false);
|
||||
const { user } = useUser();
|
||||
const config = useConfig();
|
||||
const t = useTranslate();
|
||||
@@ -126,6 +136,11 @@ const Share = ({ shareId }: { shareId: string }) => {
|
||||
"go-home",
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
e.response.status == 403 &&
|
||||
error == "share_restricted_to_recipients"
|
||||
) {
|
||||
setIsRestricted(true);
|
||||
} else if (e.response.status == 403 && error == "private_share") {
|
||||
showErrorModal(
|
||||
modals,
|
||||
@@ -151,6 +166,27 @@ const Share = ({ shareId }: { shareId: string }) => {
|
||||
getFiles();
|
||||
}, []);
|
||||
|
||||
if (isRestricted) {
|
||||
return (
|
||||
<Center style={{ height: "70vh" }}>
|
||||
<Stack align="center" spacing="md">
|
||||
<Title order={3}>
|
||||
<FormattedMessage id="share.error.restricted.title" />
|
||||
</Title>
|
||||
<Text color="dimmed" align="center">
|
||||
<FormattedMessage id="share.error.restricted.description" />
|
||||
</Text>
|
||||
<Button
|
||||
component={Link}
|
||||
href={`/auth/signIn?redirect=/share/${shareId}`}
|
||||
>
|
||||
<FormattedMessage id="share.error.restricted.button" />
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta
|
||||
|
||||
@@ -172,6 +172,7 @@ const Upload = ({
|
||||
"share.allowUnauthenticatedShares",
|
||||
),
|
||||
enableEmailRecepients: config.get("email.enableShareEmailRecipients"),
|
||||
enableUserRecipients: config.get("share.enableUserRecipients"),
|
||||
maxExpiration: user?.isAdmin
|
||||
? { value: 0, unit: "days" }
|
||||
: config.get("share.maxExpiration"),
|
||||
|
||||
@@ -73,6 +73,10 @@ const getMyShares = async (): Promise<MyShare[]> => {
|
||||
return (await api.get("shares")).data;
|
||||
};
|
||||
|
||||
const getReceivedShares = async (): Promise<any[]> => {
|
||||
return (await api.get("shares/received")).data;
|
||||
};
|
||||
|
||||
const getShareToken = async (id: string, password?: string) => {
|
||||
if (!isValidId(id)) throw new Error("Invalid ID");
|
||||
await api.post(`/shares/${id}/token`, { password });
|
||||
@@ -201,6 +205,7 @@ export default {
|
||||
doesFileSupportPreview,
|
||||
isShareTextFile,
|
||||
getMyShares,
|
||||
getReceivedShares,
|
||||
isShareIdAvailable,
|
||||
downloadFile,
|
||||
removeFile,
|
||||
|
||||
@@ -49,7 +49,8 @@ export type ShareMetaData = {
|
||||
export type MyShare = Omit<Share, "hasPassword"> & {
|
||||
views: number;
|
||||
createdAt: Date;
|
||||
security?: MyShareSecurity;
|
||||
recipients: string[];
|
||||
security: MyShareSecurity;
|
||||
};
|
||||
|
||||
export type MyReverseShare = {
|
||||
@@ -64,9 +65,11 @@ export type MyReverseShare = {
|
||||
export type ShareSecurity = {
|
||||
maxViews?: number;
|
||||
password?: string;
|
||||
restrictToRecipients?: boolean;
|
||||
};
|
||||
|
||||
export type MyShareSecurity = {
|
||||
passwordProtected: boolean;
|
||||
maxViews?: number;
|
||||
restrictToRecipients: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user