1
0
mirror of https://github.com/stonith404/pingvin-share.git synced 2024-07-02 07:20:38 +02:00

feat: allow multiple shares with one reverse share link

This commit is contained in:
Elias Schneider 2023-02-10 11:10:07 +01:00
parent edc10b72b7
commit ccdf8ea3ae
No known key found for this signature in database
GPG Key ID: 07E623B294202B6C
12 changed files with 171 additions and 75 deletions

View File

@ -1,14 +0,0 @@
-- CreateTable
CREATE TABLE "ResetPasswordToken" (
"token" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "ResetPasswordToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- Disable TOTP as secret isn't encrypted anymore
UPDATE User SET totpEnabled=false, totpSecret=null, totpVerified=false WHERE totpSecret IS NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "ResetPasswordToken_userId_key" ON "ResetPasswordToken"("userId");

View File

@ -0,0 +1,64 @@
/*
Warnings:
- You are about to drop the column `shareId` on the `ReverseShare` table. All the data in the column will be lost.
- You are about to drop the column `used` on the `ReverseShare` table. All the data in the column will be lost.
- Added the required column `remainingUses` to the `ReverseShare` table without a default value. This is not possible if the table is not empty.
*/
-- CreateTable
PRAGMA foreign_keys=OFF;
CREATE TABLE "ResetPasswordToken" (
"token" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "ResetPasswordToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- Disable TOTP as secret isn't encrypted anymore
UPDATE User SET totpEnabled=false, totpSecret=null, totpVerified=false WHERE totpSecret IS NOT NULL;
-- RedefineTables
CREATE TABLE "new_Share" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"uploadLocked" BOOLEAN NOT NULL DEFAULT false,
"isZipReady" BOOLEAN NOT NULL DEFAULT false,
"views" INTEGER NOT NULL DEFAULT 0,
"expiration" DATETIME NOT NULL,
"description" TEXT,
"removedReason" TEXT,
"creatorId" TEXT,
"reverseShareId" TEXT,
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Share_reverseShareId_fkey" FOREIGN KEY ("reverseShareId") REFERENCES "ReverseShare" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Share" ("createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "removedReason", "uploadLocked", "views", "reverseShareId")
SELECT "createdAt", "creatorId", "description", "expiration", "id", "isZipReady", "removedReason", "uploadLocked", "views", (SELECT id FROM ReverseShare WHERE shareId=Share.id)
FROM "Share";
DROP TABLE "Share";
ALTER TABLE "new_Share" RENAME TO "Share";
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,
"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", "sendEmailNotification", "shareExpiration", "token", "remainingUses") SELECT "createdAt", "creatorId", "id", "maxShareSize", "sendEmailNotification", "shareExpiration", "token", iif("ReverseShare".used, 0, 1) 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;
-- CreateIndex
CREATE UNIQUE INDEX "ResetPasswordToken_userId_key" ON "ResetPasswordToken"("userId");

View File

@ -22,8 +22,8 @@ model User {
loginTokens LoginToken[] loginTokens LoginToken[]
reverseShares ReverseShare[] reverseShares ReverseShare[]
totpEnabled Boolean @default(false) totpEnabled Boolean @default(false)
totpVerified Boolean @default(false) totpVerified Boolean @default(false)
totpSecret String? totpSecret String?
resetPasswordToken ResetPasswordToken? resetPasswordToken ResetPasswordToken?
} }
@ -57,7 +57,7 @@ model ResetPasswordToken {
expiresAt DateTime expiresAt DateTime
userId String @unique userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
model Share { model Share {
@ -74,7 +74,8 @@ model Share {
creatorId String? creatorId String?
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade) creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
reverseShare ReverseShare? reverseShareId String?
reverseShare ReverseShare? @relation(fields: [reverseShareId], references: [id], onDelete: Cascade)
security ShareSecurity? security ShareSecurity?
recipients ShareRecipient[] recipients ShareRecipient[]
@ -89,13 +90,12 @@ model ReverseShare {
shareExpiration DateTime shareExpiration DateTime
maxShareSize String maxShareSize String
sendEmailNotification Boolean sendEmailNotification Boolean
used Boolean @default(false) remainingUses Int
creatorId String creatorId String
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade) creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
shareId String? @unique shares Share[]
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
} }
model ShareRecipient { model ShareRecipient {

View File

@ -1,4 +1,4 @@
import { IsBoolean, IsString } from "class-validator"; import { IsBoolean, IsString, Max, Min } from "class-validator";
export class CreateReverseShareDTO { export class CreateReverseShareDTO {
@IsBoolean() @IsBoolean()
@ -9,4 +9,8 @@ export class CreateReverseShareDTO {
@IsString() @IsString()
shareExpiration: string; shareExpiration: string;
@Min(1)
@Max(1000)
maxUseCount: number;
} }

View File

@ -3,7 +3,7 @@ import { Expose, plainToClass, Type } from "class-transformer";
import { MyShareDTO } from "src/share/dto/myShare.dto"; import { MyShareDTO } from "src/share/dto/myShare.dto";
import { ReverseShareDTO } from "./reverseShare.dto"; import { ReverseShareDTO } from "./reverseShare.dto";
export class ReverseShareTokenWithShare extends OmitType(ReverseShareDTO, [ export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
"shareExpiration", "shareExpiration",
] as const) { ] as const) {
@Expose() @Expose()
@ -11,14 +11,17 @@ export class ReverseShareTokenWithShare extends OmitType(ReverseShareDTO, [
@Expose() @Expose()
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const)) @Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
share: Omit< shares: Omit<
MyShareDTO, MyShareDTO,
"recipients" | "files" | "from" | "fromList" | "hasPassword" "recipients" | "files" | "from" | "fromList" | "hasPassword"
>; >[];
fromList(partial: Partial<ReverseShareTokenWithShare>[]) { @Expose()
remainingUses: number;
fromList(partial: Partial<ReverseShareTokenWithShares>[]) {
return partial.map((part) => return partial.map((part) =>
plainToClass(ReverseShareTokenWithShare, part, { plainToClass(ReverseShareTokenWithShares, part, {
excludeExtraneousValues: true, excludeExtraneousValues: true,
}) })
); );

View File

@ -15,7 +15,7 @@ import { JwtGuard } from "src/auth/guard/jwt.guard";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
import { CreateReverseShareDTO } from "./dto/createReverseShare.dto"; import { CreateReverseShareDTO } from "./dto/createReverseShare.dto";
import { ReverseShareDTO } from "./dto/reverseShare.dto"; import { ReverseShareDTO } from "./dto/reverseShare.dto";
import { ReverseShareTokenWithShare } from "./dto/reverseShareTokenWithShare"; import { ReverseShareTokenWithShares } from "./dto/reverseShareTokenWithShares";
import { ReverseShareOwnerGuard } from "./guards/reverseShareOwner.guard"; import { ReverseShareOwnerGuard } from "./guards/reverseShareOwner.guard";
import { ReverseShareService } from "./reverseShare.service"; import { ReverseShareService } from "./reverseShare.service";
@ -51,7 +51,7 @@ export class ReverseShareController {
@Get() @Get()
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
async getAllByUser(@GetUser() user: User) { async getAllByUser(@GetUser() user: User) {
return new ReverseShareTokenWithShare().fromList( return new ReverseShareTokenWithShares().fromList(
await this.reverseShareService.getAllByUser(user.id) await this.reverseShareService.getAllByUser(user.id)
); );
} }

View File

@ -34,6 +34,7 @@ export class ReverseShareService {
const reverseShare = await this.prisma.reverseShare.create({ const reverseShare = await this.prisma.reverseShare.create({
data: { data: {
shareExpiration: expirationDate, shareExpiration: expirationDate,
remainingUses: data.maxUseCount,
maxShareSize: data.maxShareSize, maxShareSize: data.maxShareSize,
sendEmailNotification: data.sendEmailNotification, sendEmailNotification: data.sendEmailNotification,
creatorId, creatorId,
@ -60,7 +61,7 @@ export class ReverseShareService {
orderBy: { orderBy: {
shareExpiration: "desc", shareExpiration: "desc",
}, },
include: { share: { include: { creator: true } } }, include: { shares: { include: { creator: true } } },
}); });
return reverseShares; return reverseShares;
@ -74,9 +75,9 @@ export class ReverseShareService {
if (!reverseShare) return false; if (!reverseShare) return false;
const isExpired = new Date() > reverseShare.shareExpiration; const isExpired = new Date() > reverseShare.shareExpiration;
const isUsed = reverseShare.used; const remainingUsesExceeded = reverseShare.remainingUses <= 0;
return !(isExpired || isUsed); return !(isExpired || remainingUsesExceeded);
} }
async remove(id: string) { async remove(id: string) {

View File

@ -47,6 +47,7 @@ const Body = ({
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
maxShareSize: 104857600, maxShareSize: 104857600,
maxUseCount: 1,
sendEmailNotification: false, sendEmailNotification: false,
expiration_num: 1, expiration_num: 1,
expiration_unit: "-days", expiration_unit: "-days",
@ -60,6 +61,7 @@ const Body = ({
.createReverseShare( .createReverseShare(
values.expiration_num + values.expiration_unit, values.expiration_num + values.expiration_unit,
values.maxShareSize, values.maxShareSize,
values.maxUseCount,
values.sendEmailNotification values.sendEmailNotification
) )
.then(({ link }) => { .then(({ link }) => {
@ -132,6 +134,15 @@ const Body = ({
value={form.values.maxShareSize} value={form.values.maxShareSize}
onChange={(number) => form.setFieldValue("maxShareSize", number)} onChange={(number) => form.setFieldValue("maxShareSize", number)}
/> />
<NumberInput
min={1}
max={1000}
precision={0}
variant="filled"
label="Max use count"
description="The maximum number of times this reverse share link can be used"
{...form.getInputProps("maxUseCount")}
/>
{showSendEmailNotificationOption && ( {showSendEmailNotificationOption && (
<Switch <Switch
mt="xs" mt="xs"

View File

@ -13,12 +13,12 @@ export const config = {
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
const routes = { const routes = {
unauthenticated: new Routes(["/auth/signIn", "/auth/resetPassword*", "/"]), unauthenticated: new Routes(["/auth/*", "/"]),
public: new Routes(["/share/*", "/upload/*"]), public: new Routes(["/share/*", "/upload/*"]),
setupStatusRegistered: new Routes(["/auth/*", "/admin/setup"]), setupStatusRegistered: new Routes(["/auth/*", "/admin/setup"]),
admin: new Routes(["/admin/*"]), admin: new Routes(["/admin/*"]),
account: new Routes(["/account/*"]), account: new Routes(["/account/*"]),
disabledRoutes: new Routes([]), disabled: new Routes([]),
}; };
// Get config from backend // Get config from backend
@ -46,7 +46,7 @@ export async function middleware(request: NextRequest) {
} }
if (!getConfig("ALLOW_REGISTRATION")) { if (!getConfig("ALLOW_REGISTRATION")) {
routes.disabledRoutes.routes.push("/auth/signUp"); routes.disabled.routes.push("/auth/signUp");
} }
if (getConfig("ALLOW_UNAUTHENTICATED_SHARES")) { if (getConfig("ALLOW_UNAUTHENTICATED_SHARES")) {
@ -54,14 +54,14 @@ export async function middleware(request: NextRequest) {
} }
if (!getConfig("SMTP_ENABLED")) { if (!getConfig("SMTP_ENABLED")) {
routes.disabledRoutes.routes.push("/auth/resetPassword*"); routes.disabled.routes.push("/auth/resetPassword*");
} }
// prettier-ignore // prettier-ignore
const rules = [ const rules = [
// Disabled routes // Disabled routes
{ {
condition: routes.disabledRoutes.contains(route), condition: routes.disabled.contains(route),
path: "/", path: "/",
}, },
// Setup status // Setup status

View File

@ -1,4 +1,5 @@
import { import {
Accordion,
ActionIcon, ActionIcon,
Box, Box,
Button, Button,
@ -54,7 +55,7 @@ const MyShares = () => {
position="bottom" position="bottom"
multiline multiline
width={220} width={220}
label="A reverse share allows you to generate a unique URL for a single-use share for an external user." label="A reverse share allows you to generate a unique URL that allows external users to create a share."
events={{ hover: true, focus: false, touch: true }} events={{ hover: true, focus: false, touch: true }}
> >
<ActionIcon> <ActionIcon>
@ -87,8 +88,8 @@ const MyShares = () => {
<Table> <Table>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Shares</th>
<th>Visitors</th> <th>Remaining uses</th>
<th>Max share size</th> <th>Max share size</th>
<th>Expires at</th> <th>Expires at</th>
<th></th> <th></th>
@ -97,14 +98,63 @@ const MyShares = () => {
<tbody> <tbody>
{reverseShares.map((reverseShare) => ( {reverseShares.map((reverseShare) => (
<tr key={reverseShare.id}> <tr key={reverseShare.id}>
<td> <td style={{ width: 220 }}>
{reverseShare.share ? ( {reverseShare.shares.length == 0 ? (
reverseShare.share?.id <Text color="dimmed" size="sm">
No shares created yet
</Text>
) : ( ) : (
<Text color="dimmed">No share created yet</Text> <Accordion>
<Accordion.Item
value="customization"
sx={{ borderBottom: "none" }}
>
<Accordion.Control p={0}>
<Text size="sm">
{`${reverseShare.shares.length} share${
reverseShare.shares.length > 1 ? "s" : ""
}`}
</Text>
</Accordion.Control>
<Accordion.Panel>
{reverseShare.shares.map((share) => (
<Group key={share.id} mb={4}>
<Text maw={120} truncate>
{share.id}
</Text>
<ActionIcon
color="victoria"
variant="light"
size={25}
onClick={() => {
if (window.isSecureContext) {
clipboard.copy(
`${config.get("APP_URL")}/share/${
share.id
}`
);
toast.success(
"The share link was copied to the keyboard."
);
} else {
showShareLinkModal(
modals,
share.id,
config.get("APP_URL")
);
}
}}
>
<TbLink />
</ActionIcon>
</Group>
))}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
)} )}
</td> </td>
<td>{reverseShare.share?.views ?? "0"}</td> <td>{reverseShare.remainingUses}</td>
<td> <td>
{byteToHumanSizeString(parseInt(reverseShare.maxShareSize))} {byteToHumanSizeString(parseInt(reverseShare.maxShareSize))}
</td> </td>
@ -115,33 +165,6 @@ const MyShares = () => {
</td> </td>
<td> <td>
<Group position="right"> <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 <ActionIcon
color="red" color="red"
variant="light" variant="light"
@ -152,13 +175,14 @@ const MyShares = () => {
children: ( children: (
<Text size="sm"> <Text size="sm">
Do you really want to delete this reverse share? Do you really want to delete this reverse share?
If you do, the share will be deleted as well. If you do, the associated shares will be deleted
as well.
</Text> </Text>
), ),
confirmProps: { confirmProps: {
color: "red", color: "red",
}, },
labels: { confirm: "Confirm", cancel: "Cancel" }, labels: { confirm: "Delete", cancel: "Cancel" },
onConfirm: () => { onConfirm: () => {
shareService.removeReverseShare(reverseShare.id); shareService.removeReverseShare(reverseShare.id);
setReverseShares( setReverseShares(

View File

@ -99,12 +99,14 @@ const uploadFile = async (
const createReverseShare = async ( const createReverseShare = async (
shareExpiration: string, shareExpiration: string,
maxShareSize: number, maxShareSize: number,
maxUseCount: number,
sendEmailNotification: boolean sendEmailNotification: boolean
) => { ) => {
return ( return (
await api.post("reverseShares", { await api.post("reverseShares", {
shareExpiration, shareExpiration,
maxShareSize: maxShareSize.toString(), maxShareSize: maxShareSize.toString(),
maxUseCount,
sendEmailNotification, sendEmailNotification,
}) })
).data; ).data;

View File

@ -31,7 +31,8 @@ export type MyReverseShare = {
id: string; id: string;
maxShareSize: string; maxShareSize: string;
shareExpiration: Date; shareExpiration: Date;
share?: MyShare; remainingUses: number;
shares: MyShare[];
}; };
export type ShareSecurity = { export type ShareSecurity = {