1
0
mirror of https://github.com/stonith404/pingvin-share.git synced 2024-07-06 17:30:10 +02:00

Merge pull request #15 from stautonico/main

New Feature: Granular Control of Expiration Date + 12/24 Hour Modes
This commit is contained in:
Elias Schneider 2022-10-16 10:18:51 +02:00 committed by GitHub
commit 8435eaed77
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 193 additions and 55 deletions

3
.gitignore vendored
View File

@ -37,3 +37,6 @@ yarn-error.log*
# project specific # project specific
/backend/data/ /backend/data/
/data/ /data/
# Jetbrains specific (webstorm)
.idea/**/**

View File

@ -29,7 +29,9 @@
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"rimraf": "^3.0.2" "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.5.7"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^9.1.4", "@nestjs/cli": "^9.1.4",

View File

@ -2,7 +2,7 @@ import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config"; import { ConfigModule } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule"; import { ScheduleModule } from "@nestjs/schedule";
import { AuthModule } from "./auth/auth.module"; import { AuthModule } from "./auth/auth.module";
import { JobsService } from "./auth/jobs/jobs.service"; import { JobsService } from "./jobs/jobs.service";
import { FileController } from "./file/file.controller"; import { FileController } from "./file/file.controller";
import { FileModule } from "./file/file.module"; import { FileModule } from "./file/file.module";

View File

@ -2,6 +2,7 @@ import { Injectable } from "@nestjs/common";
import { Cron } from "@nestjs/schedule"; import { Cron } from "@nestjs/schedule";
import { FileService } from "src/file/file.service"; import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import * as moment from "moment";
@Injectable() @Injectable()
export class JobsService { export class JobsService {
@ -13,7 +14,13 @@ export class JobsService {
@Cron("0 * * * *") @Cron("0 * * * *")
async deleteExpiredShares() { async deleteExpiredShares() {
const expiredShares = await this.prisma.share.findMany({ const expiredShares = await this.prisma.share.findMany({
where: { expiration: { lt: new Date() } }, where: {
// We want to remove only shares that have an expiration date less than the current date, but not 0
AND: [
{ expiration: { lt: new Date() } },
{ expiration: { not: moment(0).toDate() } },
],
},
}); });
for (const expiredShare of expiredShares) { for (const expiredShare of expiredShares) {

View File

@ -34,7 +34,11 @@ export class ShareSecurityGuard implements CanActivate {
include: { security: true }, include: { security: true },
}); });
if (!share || moment().isAfter(share.expiration)) if (
!share ||
(moment().isAfter(share.expiration) &&
moment(share.expiration).unix() !== 0)
)
throw new NotFoundException("Share not found"); throw new NotFoundException("Share not found");
if (share.security?.password && !shareToken) if (share.security?.password && !shareToken)

View File

@ -5,19 +5,13 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express"; import { Request } from "express";
import * as moment from "moment"; import * as moment from "moment";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { ShareService } from "src/share/share.service";
@Injectable() @Injectable()
export class ShareTokenSecurity implements CanActivate { export class ShareTokenSecurity implements CanActivate {
constructor( constructor(private prisma: PrismaService) {}
private reflector: Reflector,
private shareService: ShareService,
private prisma: PrismaService
) {}
async canActivate(context: ExecutionContext) { async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest(); const request: Request = context.switchToHttp().getRequest();
@ -33,7 +27,11 @@ export class ShareTokenSecurity implements CanActivate {
include: { security: true }, include: { security: true },
}); });
if (!share || moment().isAfter(share.expiration)) if (
!share ||
(moment().isAfter(share.expiration) &&
!moment(share.expiration).isSame(0))
)
throw new NotFoundException("Share not found"); throw new NotFoundException("Share not found");
if (share.security?.maxViews && share.security.maxViews <= share.views) if (share.security?.maxViews && share.security.maxViews <= share.views)

View File

@ -35,16 +35,24 @@ export class ShareService {
share.security.password = await argon.hash(share.security.password); share.security.password = await argon.hash(share.security.password);
} }
const expirationDate = moment() // We have to add an exception for "never" (since moment won't like that)
.add( let expirationDate;
share.expiration.split("-")[0], if (share.expiration !== "never") {
share.expiration.split("-")[1] as moment.unitOfTime.DurationConstructor expirationDate = moment()
) .add(
.toDate(); share.expiration.split("-")[0],
share.expiration.split(
"-"
)[1] as moment.unitOfTime.DurationConstructor
)
.toDate();
// Throw error if expiration date is now // Throw error if expiration date is now
if (expirationDate.setMilliseconds(0) == new Date().setMilliseconds(0)) if (expirationDate.setMilliseconds(0) == new Date().setMilliseconds(0))
throw new BadRequestException("Invalid expiration date"); throw new BadRequestException("Invalid expiration date");
} else {
expirationDate = moment(0).toDate();
}
return await this.prisma.share.create({ return await this.prisma.share.create({
data: { data: {
@ -101,8 +109,12 @@ export class ShareService {
return await this.prisma.share.findMany({ return await this.prisma.share.findMany({
where: { where: {
creator: { id: userId }, creator: { id: userId },
expiration: { gt: new Date() },
uploadLocked: true, uploadLocked: true,
// We want to grab any shares that are not expired or have their expiration date set to "never" (unix 0)
OR: [
{ expiration: { gt: new Date() } },
{ expiration: { equals: moment(0).toDate() } },
],
}, },
orderBy: { orderBy: {
expiration: "desc", expiration: "desc",
@ -186,7 +198,6 @@ export class ShareService {
const { expiration } = await this.prisma.share.findUnique({ const { expiration } = await this.prisma.share.findUnique({
where: { id: shareId }, where: { id: shareId },
}); });
console.log(moment(expiration).diff(new Date(), "seconds"));
return this.jwtService.sign( return this.jwtService.sign(
{ {
shareId, shareId,
@ -198,10 +209,16 @@ export class ShareService {
); );
} }
verifyShareToken(shareId: string, token: string) { async verifyShareToken(shareId: string, token: string) {
const { expiration } = await this.prisma.share.findUnique({
where: { id: shareId },
});
try { try {
const claims = this.jwtService.verify(token, { const claims = this.jwtService.verify(token, {
secret: this.config.get("JWT_SECRET"), secret: this.config.get("JWT_SECRET"),
// Ignore expiration if expiration is 0
ignoreExpiration: moment(expiration).isSame(0),
}); });
return claims.shareId == shareId; return claims.shareId == shareId;

View File

@ -1,3 +1,4 @@
SHOW_HOME_PAGE=true SHOW_HOME_PAGE=true
ALLOW_REGISTRATION=true ALLOW_REGISTRATION=true
MAX_FILE_SIZE=1000000000 MAX_FILE_SIZE=1000000000
TWELVE_HOUR_TIME=false

View File

@ -5,7 +5,8 @@ const nextConfig = {
ALLOW_REGISTRATION: process.env.ALLOW_REGISTRATION, ALLOW_REGISTRATION: process.env.ALLOW_REGISTRATION,
SHOW_HOME_PAGE: process.env.SHOW_HOME_PAGE, SHOW_HOME_PAGE: process.env.SHOW_HOME_PAGE,
MAX_FILE_SIZE: process.env.MAX_FILE_SIZE, MAX_FILE_SIZE: process.env.MAX_FILE_SIZE,
BACKEND_URL: process.env.BACKEND_URL BACKEND_URL: process.env.BACKEND_URL,
TWELVE_HOUR_TIME: process.env.TWELVE_HOUR_TIME
} }
} }

View File

@ -6,9 +6,11 @@
"build": "next build", "build": "next build",
"start": "dotenv next start", "start": "dotenv next start",
"lint": "next lint", "lint": "next lint",
"format": "prettier --write \"src/**/*.ts\"" "format": "prettier --write \"src/**/*.ts*\""
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.10.4",
"@emotion/server": "^11.10.0",
"@mantine/core": "^5.5.2", "@mantine/core": "^5.5.2",
"@mantine/dropzone": "^5.5.2", "@mantine/dropzone": "^5.5.2",
"@mantine/form": "^5.5.2", "@mantine/form": "^5.5.2",

View File

@ -5,7 +5,10 @@ const Footer = () => {
<MFooter height="auto" p={10}> <MFooter height="auto" p={10}>
<Center> <Center>
<Text size="xs" color="dimmed"> <Text size="xs" color="dimmed">
Made with 🖤 by <Anchor size="xs" href="https://eliasschneider.com" target="_blank">Elias Schneider</Anchor> Made with 🖤 by{" "}
<Anchor size="xs" href="https://eliasschneider.com" target="_blank">
Elias Schneider
</Anchor>
</Text> </Text>
</Center> </Center>
</MFooter> </MFooter>

View File

@ -1,6 +1,6 @@
import { ActionIcon, Avatar, Menu } from "@mantine/core"; import { ActionIcon, Avatar, Menu } from "@mantine/core";
import { NextLink } from "@mantine/next"; import { NextLink } from "@mantine/next";
import { TbDoorExit, TbLink } from "react-icons/tb";; import { TbDoorExit, TbLink } from "react-icons/tb";
import authService from "../../services/auth.service"; import authService from "../../services/auth.service";
const ActionAvatar = () => { const ActionAvatar = () => {

View File

@ -2,6 +2,7 @@ import {
Accordion, Accordion,
Button, Button,
Col, Col,
Checkbox,
Grid, Grid,
NumberInput, NumberInput,
PasswordInput, PasswordInput,
@ -15,6 +16,33 @@ import { useModals } from "@mantine/modals";
import * as yup from "yup"; import * as yup from "yup";
import shareService from "../../services/share.service"; import shareService from "../../services/share.service";
import { ShareSecurity } from "../../types/share.type"; import { ShareSecurity } from "../../types/share.type";
import moment from "moment";
import getConfig from "next/config";
const { publicRuntimeConfig } = getConfig();
const PreviewExpiration = ({ form }: { form: any }) => {
const value = form.values.never_expires
? "never"
: form.values.expiration_num + form.values.expiration_unit;
if (value === "never") return "This share will never expire.";
const expirationDate = moment()
.add(
value.split("-")[0],
value.split("-")[1] as moment.unitOfTime.DurationConstructor
)
.toDate();
if (publicRuntimeConfig.TWELVE_HOUR_TIME === "true")
return `This share will expire on ${moment(expirationDate).format(
"MMMM Do YYYY, h:mm a"
)}`;
else
return `This share will expire on ${moment(expirationDate).format(
"MMMM DD YYYY, HH:mm"
)}`;
};
const CreateUploadModalBody = ({ const CreateUploadModalBody = ({
uploadCallback, uploadCallback,
@ -44,7 +72,9 @@ const CreateUploadModalBody = ({
password: undefined, password: undefined,
maxViews: undefined, maxViews: undefined,
expiration: "1-day", expiration_num: 1,
expiration_unit: "-days",
never_expires: false,
}, },
validate: yupResolver(validationSchema), validate: yupResolver(validationSchema),
}); });
@ -55,7 +85,10 @@ const CreateUploadModalBody = ({
if (!(await shareService.isShareIdAvailable(values.link))) { if (!(await shareService.isShareIdAvailable(values.link))) {
form.setFieldError("link", "This link is already in use"); form.setFieldError("link", "This link is already in use");
} else { } else {
uploadCallback(values.link, values.expiration, { const expiration = form.values.never_expires
? "never"
: form.values.expiration_num + form.values.expiration_unit;
uploadCallback(values.link, expiration, {
password: values.password, password: values.password,
maxViews: values.maxViews, maxViews: values.maxViews,
}); });
@ -91,6 +124,7 @@ const CreateUploadModalBody = ({
</Grid> </Grid>
<Text <Text
italic
size="xs" size="xs"
sx={(theme) => ({ sx={(theme) => ({
color: theme.colors.gray[6], color: theme.colors.gray[6],
@ -99,20 +133,70 @@ const CreateUploadModalBody = ({
{window.location.origin}/share/ {window.location.origin}/share/
{form.values.link == "" ? "myAwesomeShare" : form.values.link} {form.values.link == "" ? "myAwesomeShare" : form.values.link}
</Text> </Text>
<Select <Grid align={form.errors.link ? "center" : "flex-end"}>
label="Expiration" <Col xs={6}>
{...form.getInputProps("expiration")} <NumberInput
data={[ min={1}
{ max={99999}
value: "10-minutes", precision={0}
label: "10 Minutes", variant="filled"
}, label="Expiration"
{ value: "1-hour", label: "1 Hour" }, placeholder="n"
{ value: "1-day", label: "1 Day" }, disabled={form.values.never_expires}
{ value: "1-week".toString(), label: "1 Week" }, {...form.getInputProps("expiration_num")}
{ value: "1-month", label: "1 Month" }, />
]} </Col>
<Col xs={6}>
<Select
disabled={form.values.never_expires}
{...form.getInputProps("expiration_unit")}
data={[
// Set the label to singular if the number is 1, else plural
{
value: "-minutes",
label:
"Minute" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-hours",
label: "Hour" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-days",
label: "Day" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-weeks",
label: "Week" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-months",
label: "Month" + (form.values.expiration_num == 1 ? "" : "s"),
},
{
value: "-years",
label: "Year" + (form.values.expiration_num == 1 ? "" : "s"),
},
]}
/>
</Col>
</Grid>
<Checkbox
label="Never Expires"
{...form.getInputProps("never_expires")}
/> />
{/* Preview expiration date text */}
<Text
italic
size="xs"
sx={(theme) => ({
color: theme.colors.gray[6],
})}
>
{PreviewExpiration({ form })}
</Text>
<Accordion> <Accordion>
<Accordion.Item value="security" sx={{ borderBottom: "none" }}> <Accordion.Item value="security" sx={{ borderBottom: "none" }}>
<Accordion.Control>Security options</Accordion.Control> <Accordion.Control>Security options</Accordion.Control>

View File

@ -1,5 +1,5 @@
import { ActionIcon, Loader, Skeleton, Table } from "@mantine/core"; import { ActionIcon, Loader, Skeleton, Table } from "@mantine/core";
import { TbCircleCheck, TbDownload } from "react-icons/tb";; import { TbCircleCheck, TbDownload } from "react-icons/tb";
import shareService from "../../services/share.service"; import shareService from "../../services/share.service";
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util"; import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";

View File

@ -1,4 +1,4 @@
import { Button, Group, PasswordInput, Stack, Text, Title } from "@mantine/core"; import { Button, PasswordInput, Stack, Text, Title } from "@mantine/core";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import { useState } from "react"; import { useState } from "react";

View File

@ -1,4 +1,4 @@
import { Button, Group, Stack, Text, Title } from "@mantine/core"; import { Button, Stack, Text, Title } from "@mantine/core";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import { useRouter } from "next/router"; import { useRouter } from "next/router";

View File

@ -2,7 +2,7 @@ import { Button, Center, createStyles, Group, Text } from "@mantine/core";
import { Dropzone as MantineDropzone } from "@mantine/dropzone"; import { Dropzone as MantineDropzone } from "@mantine/dropzone";
import getConfig from "next/config"; import getConfig from "next/config";
import { Dispatch, ForwardedRef, SetStateAction, useRef } from "react"; import { Dispatch, ForwardedRef, SetStateAction, useRef } from "react";
import { TbCloudUpload, TbUpload } from "react-icons/tb";;; import { TbCloudUpload, TbUpload } from "react-icons/tb";
import { FileUpload } from "../../types/File.type"; import { FileUpload } from "../../types/File.type";
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util"; import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";

View File

@ -1,6 +1,6 @@
import { ActionIcon, Table } from "@mantine/core"; import { ActionIcon, Table } from "@mantine/core";
import { Dispatch, SetStateAction } from "react"; import { Dispatch, SetStateAction } from "react";
import { TbTrash } from "react-icons/tb";; import { TbTrash } from "react-icons/tb";
import { FileUpload } from "../../types/File.type"; import { FileUpload } from "../../types/File.type";
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util"; import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";
import UploadProgressIndicator from "./UploadProgressIndicator"; import UploadProgressIndicator from "./UploadProgressIndicator";

View File

@ -10,10 +10,14 @@ import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import moment from "moment"; import moment from "moment";
import getConfig from "next/config";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { TbCopy } from "react-icons/tb"; import { TbCopy } from "react-icons/tb";
import { Share } from "../../types/share.type"; import { Share } from "../../types/share.type";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
const { publicRuntimeConfig } = getConfig();
const showCompletedUploadModal = (modals: ModalsContextProps, share: Share) => { const showCompletedUploadModal = (modals: ModalsContextProps, share: Share) => {
return modals.openModal({ return modals.openModal({
closeOnClickOutside: false, closeOnClickOutside: false,
@ -55,7 +59,14 @@ const Body = ({ share }: { share: Share }) => {
color: theme.colors.gray[6], color: theme.colors.gray[6],
})} })}
> >
Your share expires at {moment(share.expiration).format("LLL")} {/* If our share.expiration is timestamp 0, show a different message */}
{moment(share.expiration).unix() === 0
? "This share will never expire."
: `This share will expire on ${
publicRuntimeConfig.TWELVE_HOUR_TIME === "true"
? moment(share.expiration).format("MMMM Do YYYY, h:mm a")
: moment(share.expiration).format("MMMM DD YYYY, HH:mm")
}`}
</Text> </Text>
<Button <Button

View File

@ -8,14 +8,12 @@ const showCreateUploadModal = (
uploadCallback: ( uploadCallback: (
id: string, id: string,
expiration: string, expiration: string,
security: ShareSecurity, security: ShareSecurity
) => void ) => void
) => { ) => {
return modals.openModal({ return modals.openModal({
title: <Title order={4}>Share</Title>, title: <Title order={4}>Share</Title>,
children: ( children: <CreateUploadModalBody uploadCallback={uploadCallback} />,
<CreateUploadModalBody uploadCallback={uploadCallback} />
),
}); });
}; };

View File

@ -16,12 +16,15 @@ import { NextLink } from "@mantine/next";
import moment from "moment"; import moment from "moment";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { TbLink, TbTrash } from "react-icons/tb";; import { TbLink, TbTrash } from "react-icons/tb";
import Meta from "../../components/Meta"; import Meta from "../../components/Meta";
import useUser from "../../hooks/user.hook"; import useUser from "../../hooks/user.hook";
import shareService from "../../services/share.service"; import shareService from "../../services/share.service";
import { MyShare } from "../../types/share.type"; import { MyShare } from "../../types/share.type";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
import getConfig from "next/config";
const { publicRuntimeConfig } = getConfig();
const MyShares = () => { const MyShares = () => {
const modals = useModals(); const modals = useModals();
@ -72,7 +75,11 @@ const MyShares = () => {
<td>{share.id}</td> <td>{share.id}</td>
<td>{share.views}</td> <td>{share.views}</td>
<td> <td>
{moment(share.expiration).format("MMMM DD YYYY, HH:mm")} {moment(share.expiration).unix() === 0
? "Never"
: publicRuntimeConfig.TWELVE_HOUR_TIME === "true"
? moment(share.expiration).format("MMMM Do YYYY, h:mm a")
: moment(share.expiration).format("MMMM DD YYYY, HH:mm")}
</td> </td>
<td> <td>
<Group position="right"> <Group position="right">