1
0
mirror of https://github.com/stonith404/pingvin-share.git synced 2024-06-30 06:30:11 +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[]
reverseShares ReverseShare[]
totpEnabled Boolean @default(false)
totpVerified Boolean @default(false)
totpEnabled Boolean @default(false)
totpVerified Boolean @default(false)
totpSecret String?
resetPasswordToken ResetPasswordToken?
}
@ -57,7 +57,7 @@ model ResetPasswordToken {
expiresAt DateTime
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Share {
@ -74,7 +74,8 @@ model Share {
creatorId String?
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
reverseShare ReverseShare?
reverseShareId String?
reverseShare ReverseShare? @relation(fields: [reverseShareId], references: [id], onDelete: Cascade)
security ShareSecurity?
recipients ShareRecipient[]
@ -89,13 +90,12 @@ model ReverseShare {
shareExpiration DateTime
maxShareSize String
sendEmailNotification Boolean
used Boolean @default(false)
remainingUses Int
creatorId String
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
shareId String? @unique
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
shares Share[]
}
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 {
@IsBoolean()
@ -9,4 +9,8 @@ export class CreateReverseShareDTO {
@IsString()
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 { ReverseShareDTO } from "./reverseShare.dto";
export class ReverseShareTokenWithShare extends OmitType(ReverseShareDTO, [
export class ReverseShareTokenWithShares extends OmitType(ReverseShareDTO, [
"shareExpiration",
] as const) {
@Expose()
@ -11,14 +11,17 @@ export class ReverseShareTokenWithShare extends OmitType(ReverseShareDTO, [
@Expose()
@Type(() => OmitType(MyShareDTO, ["recipients", "hasPassword"] as const))
share: Omit<
shares: Omit<
MyShareDTO,
"recipients" | "files" | "from" | "fromList" | "hasPassword"
>;
>[];
fromList(partial: Partial<ReverseShareTokenWithShare>[]) {
@Expose()
remainingUses: number;
fromList(partial: Partial<ReverseShareTokenWithShares>[]) {
return partial.map((part) =>
plainToClass(ReverseShareTokenWithShare, part, {
plainToClass(ReverseShareTokenWithShares, part, {
excludeExtraneousValues: true,
})
);

View File

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

View File

@ -34,6 +34,7 @@ export class ReverseShareService {
const reverseShare = await this.prisma.reverseShare.create({
data: {
shareExpiration: expirationDate,
remainingUses: data.maxUseCount,
maxShareSize: data.maxShareSize,
sendEmailNotification: data.sendEmailNotification,
creatorId,
@ -60,7 +61,7 @@ export class ReverseShareService {
orderBy: {
shareExpiration: "desc",
},
include: { share: { include: { creator: true } } },
include: { shares: { include: { creator: true } } },
});
return reverseShares;
@ -74,9 +75,9 @@ export class ReverseShareService {
if (!reverseShare) return false;
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) {

View File

@ -47,6 +47,7 @@ const Body = ({
const form = useForm({
initialValues: {
maxShareSize: 104857600,
maxUseCount: 1,
sendEmailNotification: false,
expiration_num: 1,
expiration_unit: "-days",
@ -60,6 +61,7 @@ const Body = ({
.createReverseShare(
values.expiration_num + values.expiration_unit,
values.maxShareSize,
values.maxUseCount,
values.sendEmailNotification
)
.then(({ link }) => {
@ -132,6 +134,15 @@ const Body = ({
value={form.values.maxShareSize}
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 && (
<Switch
mt="xs"

View File

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

View File

@ -1,4 +1,5 @@
import {
Accordion,
ActionIcon,
Box,
Button,
@ -54,7 +55,7 @@ const MyShares = () => {
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."
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 }}
>
<ActionIcon>
@ -87,8 +88,8 @@ const MyShares = () => {
<Table>
<thead>
<tr>
<th>Name</th>
<th>Visitors</th>
<th>Shares</th>
<th>Remaining uses</th>
<th>Max share size</th>
<th>Expires at</th>
<th></th>
@ -97,14 +98,63 @@ const MyShares = () => {
<tbody>
{reverseShares.map((reverseShare) => (
<tr key={reverseShare.id}>
<td>
{reverseShare.share ? (
reverseShare.share?.id
<td style={{ width: 220 }}>
{reverseShare.shares.length == 0 ? (
<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>{reverseShare.share?.views ?? "0"}</td>
<td>{reverseShare.remainingUses}</td>
<td>
{byteToHumanSizeString(parseInt(reverseShare.maxShareSize))}
</td>
@ -115,33 +165,6 @@ const MyShares = () => {
</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"
@ -152,13 +175,14 @@ const MyShares = () => {
children: (
<Text size="sm">
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>
),
confirmProps: {
color: "red",
},
labels: { confirm: "Confirm", cancel: "Cancel" },
labels: { confirm: "Delete", cancel: "Cancel" },
onConfirm: () => {
shareService.removeReverseShare(reverseShare.id);
setReverseShares(

View File

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

View File

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