mirror of
https://github.com/stonith404/pingvin-share.git
synced 2025-01-29 01:28:59 -05:00
feat: allow multiple shares with one reverse share link
This commit is contained in:
parent
edc10b72b7
commit
ccdf8ea3ae
12 changed files with 171 additions and 75 deletions
|
@ -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");
|
|
@ -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");
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -31,7 +31,8 @@ export type MyReverseShare = {
|
|||
id: string;
|
||||
maxShareSize: string;
|
||||
shareExpiration: Date;
|
||||
share?: MyShare;
|
||||
remainingUses: number;
|
||||
shares: MyShare[];
|
||||
};
|
||||
|
||||
export type ShareSecurity = {
|
||||
|
|
Loading…
Add table
Reference in a new issue