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

Merge pull request #18 from stonith404/feat/allow-unauthenticated-shares

feat: allow unauthenticated shares
This commit is contained in:
Elias Schneider 2022-10-24 09:15:28 +02:00 committed by GitHub
commit e4019612f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 366 additions and 256 deletions

View File

@ -4,6 +4,7 @@
APP_URL=http://localhost:3000
SHOW_HOME_PAGE=true
ALLOW_REGISTRATION=true
ALLOW_UNAUTHENTICATED_SHARES=false
MAX_FILE_SIZE=1000000000
# SECURITY

View File

@ -28,13 +28,14 @@ The website is now listening available on `http://localhost:3000`, have fun with
### Environment variables
| Variable | Description | Possible values |
| -------------------- | ------------------------------------------------------------------------------------------- | --------------- |
| `APP_URL` | On which URL Pingvin Share is available. E.g http://localhost or https://pingvin-share.com. | URL |
| `SHOW_HOME_PAGE` | Whether the Pingvin Share home page should be shown. | true/false |
| `ALLOW_REGISTRATION` | Whether a new user can create a new account. | true/false |
| `MAX_FILE_SIZE` | Maximum allowed size per file in bytes. | Number |
| `JWT_SECRET` | Long random string to sign the JWT's. | Random string |
| Variable | Description | Possible values |
| ------------------------------ | ------------------------------------------------------------------------------------------- | --------------- |
| `APP_URL` | On which URL Pingvin Share is available. E.g http://localhost or https://pingvin-share.com. | URL |
| `SHOW_HOME_PAGE` | Whether the Pingvin Share home page should be shown. | true/false |
| `ALLOW_REGISTRATION` | Whether a new user can create a new account. | true/false |
| `ALLOW_UNAUTHENTICATED_SHARES` | Whether a user can create a share without being signed in. | true/false |
| `MAX_FILE_SIZE` | Maximum allowed size per file in bytes. | Number |
| `JWT_SECRET` | Long random string to sign the JWT's. | Random string |
### Upgrade to a new version

View File

@ -2,6 +2,7 @@
APP_URL=http://localhost:3000
ALLOW_REGISTRATION=true
MAX_FILE_SIZE=5000000000
ALLOW_UNAUTHENTICATED_SHARES=false
# SECURITY
JWT_SECRET=random-string

View File

@ -0,0 +1,17 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
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,
"creatorId" TEXT,
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Share" ("createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views") SELECT "createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views" FROM "Share";
DROP TABLE "Share";
ALTER TABLE "new_Share" RENAME TO "Share";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@ -40,8 +40,8 @@ model Share {
views Int @default(0)
expiration DateTime
creatorId String
creator User @relation(fields: [creatorId], references: [id])
creatorId String?
creator User? @relation(fields: [creatorId], references: [id])
security ShareSecurity?
files File[]
}

View File

@ -1,7 +1,16 @@
import { ExecutionContext } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { Observable } from "rxjs";
export class JwtGuard extends AuthGuard("jwt") {
constructor() {
super();
}
canActivate(
context: ExecutionContext
): boolean | Promise<boolean> | Observable<boolean> {
return process.env.ALLOW_UNAUTHENTICATED_SHARES == "true"
? true
: super.canActivate(context);
}
}

View File

@ -28,6 +28,8 @@ export class ShareOwnerGuard implements CanActivate {
if (!share) throw new NotFoundException("Share not found");
if (!share.creatorId) return true;
return share.creatorId == (request.user as User).id;
}
}

View File

@ -24,7 +24,7 @@ export class ShareService {
private jwtService: JwtService
) {}
async create(share: CreateShareDTO, user: User) {
async create(share: CreateShareDTO, user?: User) {
if (!(await this.isShareIdAvailable(share.id)).isAvailable)
throw new BadRequestException("Share id already in use");
@ -58,7 +58,7 @@ export class ShareService {
data: {
...share,
expiration: expirationDate,
creator: { connect: { id: user.id } },
creator: { connect: user ? { id: user.id } : undefined },
security: { create: share.security },
},
});
@ -154,6 +154,8 @@ export class ShareService {
});
if (!share) throw new NotFoundException("Share not found");
if (!share.creatorId)
throw new ForbiddenException("Anonymous shares can't be deleted");
await this.fileService.deleteAllFiles(shareId);
await this.prisma.share.delete({ where: { id: shareId } });

View File

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

View File

@ -5,6 +5,7 @@ const nextConfig = {
ALLOW_REGISTRATION: process.env.ALLOW_REGISTRATION,
SHOW_HOME_PAGE: process.env.SHOW_HOME_PAGE,
MAX_FILE_SIZE: process.env.MAX_FILE_SIZE,
ALLOW_UNAUTHENTICATED_SHARES: process.env.ALLOW_UNAUTHENTICATED_SHARES
}
}

View File

@ -1,219 +0,0 @@
import {
Accordion,
Button,
Checkbox,
Col,
Grid,
NumberInput,
PasswordInput,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { useModals } from "@mantine/modals";
import moment from "moment";
import * as yup from "yup";
import shareService from "../../services/share.service";
import { ShareSecurity } from "../../types/share.type";
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();
return `This share will expire on ${moment(expirationDate).format("LLL")}`;
};
const CreateUploadModalBody = ({
uploadCallback,
}: {
uploadCallback: (
id: string,
expiration: string,
security: ShareSecurity
) => void;
}) => {
const modals = useModals();
const validationSchema = yup.object().shape({
link: yup
.string()
.required()
.min(3)
.max(50)
.matches(new RegExp("^[a-zA-Z0-9_-]*$"), {
message: "Can only contain letters, numbers, underscores and hyphens",
}),
password: yup.string().min(3).max(30),
maxViews: yup.number().min(1),
});
const form = useForm({
initialValues: {
link: "",
password: undefined,
maxViews: undefined,
expiration_num: 1,
expiration_unit: "-days",
never_expires: false,
},
validate: yupResolver(validationSchema),
});
return (
<form
onSubmit={form.onSubmit(async (values) => {
if (!(await shareService.isShareIdAvailable(values.link))) {
form.setFieldError("link", "This link is already in use");
} else {
const expiration = form.values.never_expires
? "never"
: form.values.expiration_num + form.values.expiration_unit;
uploadCallback(values.link, expiration, {
password: values.password,
maxViews: values.maxViews,
});
modals.closeAll();
}
})}
>
<Stack align="stretch">
<Grid align={form.errors.link ? "center" : "flex-end"}>
<Col xs={9}>
<TextInput
variant="filled"
label="Link"
placeholder="myAwesomeShare"
{...form.getInputProps("link")}
/>
</Col>
<Col xs={3}>
<Button
variant="outline"
onClick={() =>
form.setFieldValue(
"link",
Buffer.from(Math.random().toString(), "utf8")
.toString("base64")
.substr(10, 7)
)
}
>
Generate
</Button>
</Col>
</Grid>
<Text
italic
size="xs"
sx={(theme) => ({
color: theme.colors.gray[6],
})}
>
{window.location.origin}/share/
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
</Text>
<Grid align={form.errors.link ? "center" : "flex-end"}>
<Col xs={6}>
<NumberInput
min={1}
max={99999}
precision={0}
variant="filled"
label="Expiration"
placeholder="n"
disabled={form.values.never_expires}
{...form.getInputProps("expiration_num")}
/>
</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.Item value="security" sx={{ borderBottom: "none" }}>
<Accordion.Control>Security options</Accordion.Control>
<Accordion.Panel>
<Stack align="stretch">
<PasswordInput
variant="filled"
placeholder="No password"
label="Password protection"
{...form.getInputProps("password")}
/>
<NumberInput
min={1}
type="number"
variant="filled"
placeholder="No limit"
label="Maximal views"
{...form.getInputProps("maxViews")}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Button type="submit">Share</Button>
</Stack>
</form>
);
};
export default CreateUploadModalBody;

View File

@ -0,0 +1,19 @@
import moment from "moment";
const ExpirationPreview = ({ 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();
return `This share will expire on ${moment(expirationDate).format("LLL")}`;
};
export default ExpirationPreview;

View File

@ -13,8 +13,8 @@ import moment from "moment";
import getConfig from "next/config";
import { useRouter } from "next/router";
import { TbCopy } from "react-icons/tb";
import { Share } from "../../types/share.type";
import toast from "../../utils/toast.util";
import { Share } from "../../../types/share.type";
import toast from "../../../utils/toast.util";
const { publicRuntimeConfig } = getConfig();

View File

@ -0,0 +1,249 @@
import {
Accordion,
Alert,
Button,
Checkbox,
Col,
Grid,
Group,
NumberInput,
PasswordInput,
Select,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import getConfig from "next/config";
import { useState } from "react";
import { TbAlertCircle } from "react-icons/tb";
import * as yup from "yup";
import shareService from "../../../services/share.service";
import { ShareSecurity } from "../../../types/share.type";
import ExpirationPreview from "../ExpirationPreview";
const { publicRuntimeConfig } = getConfig();
const showCreateUploadModal = (
modals: ModalsContextProps,
uploadCallback: (
id: string,
expiration: string,
security: ShareSecurity
) => void
) => {
return modals.openModal({
title: <Title order={4}>Share</Title>,
children: <CreateUploadModalBody uploadCallback={uploadCallback} />,
});
};
const CreateUploadModalBody = ({
uploadCallback,
}: {
uploadCallback: (
id: string,
expiration: string,
security: ShareSecurity
) => void;
}) => {
const modals = useModals();
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(
publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "true"
);
const validationSchema = yup.object().shape({
link: yup
.string()
.required()
.min(3)
.max(50)
.matches(new RegExp("^[a-zA-Z0-9_-]*$"), {
message: "Can only contain letters, numbers, underscores and hyphens",
}),
password: yup.string().min(3).max(30),
maxViews: yup.number().min(1),
});
const form = useForm({
initialValues: {
link: "",
password: undefined,
maxViews: undefined,
expiration_num: 1,
expiration_unit: "-days",
never_expires: false,
},
validate: yupResolver(validationSchema),
});
return (
<Group>
{showNotSignedInAlert && (
<Alert
withCloseButton
onClose={() => setShowNotSignedInAlert(false)}
icon={<TbAlertCircle size={16} />}
title="You're not signed in"
color="yellow"
>
You will be unable to delete your share manually and view the visitor
count.
</Alert>
)}
<form
onSubmit={form.onSubmit(async (values) => {
if (!(await shareService.isShareIdAvailable(values.link))) {
form.setFieldError("link", "This link is already in use");
} else {
const expiration = form.values.never_expires
? "never"
: form.values.expiration_num + form.values.expiration_unit;
uploadCallback(values.link, expiration, {
password: values.password,
maxViews: values.maxViews,
});
modals.closeAll();
}
})}
>
<Stack align="stretch">
<Grid align={form.errors.link ? "center" : "flex-end"}>
<Col xs={9}>
<TextInput
variant="filled"
label="Link"
placeholder="myAwesomeShare"
{...form.getInputProps("link")}
/>
</Col>
<Col xs={3}>
<Button
variant="outline"
onClick={() =>
form.setFieldValue(
"link",
Buffer.from(Math.random().toString(), "utf8")
.toString("base64")
.substr(10, 7)
)
}
>
Generate
</Button>
</Col>
</Grid>
<Text
italic
size="xs"
sx={(theme) => ({
color: theme.colors.gray[6],
})}
>
{window.location.origin}/share/
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
</Text>
<Grid align={form.errors.link ? "center" : "flex-end"}>
<Col xs={6}>
<NumberInput
min={1}
max={99999}
precision={0}
variant="filled"
label="Expiration"
placeholder="n"
disabled={form.values.never_expires}
{...form.getInputProps("expiration_num")}
/>
</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],
})}
>
{ExpirationPreview({ form })}
</Text>
<Accordion>
<Accordion.Item value="security" sx={{ borderBottom: "none" }}>
<Accordion.Control>Security options</Accordion.Control>
<Accordion.Panel>
<Stack align="stretch">
<PasswordInput
variant="filled"
placeholder="No password"
label="Password protection"
{...form.getInputProps("password")}
/>
<NumberInput
min={1}
type="number"
variant="filled"
placeholder="No limit"
label="Maximal views"
{...form.getInputProps("maxViews")}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Button type="submit">Share</Button>
</Stack>
</form>
</Group>
);
};
export default showCreateUploadModal;

View File

@ -0,0 +1,42 @@
import { Alert, Button, Stack } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import { useRouter } from "next/router";
import { TbAlertCircle } from "react-icons/tb";
const showNotAuthenticatedWarningModal = (
modals: ModalsContextProps,
onConfirm: (...any: any) => any
) => {
return modals.openConfirmModal({
closeOnClickOutside: false,
withCloseButton: false,
closeOnEscape: false,
labels: { confirm: "Continue", cancel: "Sign in" },
onConfirm: onConfirm,
onCancel: () => {},
children: <Body />,
});
};
const Body = () => {
const modals = useModals();
const router = useRouter();
return (
<>
<Stack align="stretch">
<Alert
icon={<TbAlertCircle size={16} />}
title="You're not signed in"
color="yellow"
>
You will be unable to delete your share manually and view the visitor
count if you're not signed in.
</Alert>
</Stack>
</>
);
};
export default showNotAuthenticatedWarningModal;

View File

@ -1,20 +0,0 @@
import { Title } from "@mantine/core";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import { ShareSecurity } from "../../types/share.type";
import CreateUploadModalBody from "../share/CreateUploadModalBody";
const showCreateUploadModal = (
modals: ModalsContextProps,
uploadCallback: (
id: string,
expiration: string,
security: ShareSecurity
) => void
) => {
return modals.openModal({
title: <Title order={4}>Share</Title>,
children: <CreateUploadModalBody uploadCallback={uploadCallback} />,
});
};
export default showCreateUploadModal;

View File

@ -15,6 +15,7 @@ import { useRouter } from "next/router";
import { TbCheck } from "react-icons/tb";
import Meta from "../components/Meta";
import useUser from "../hooks/user.hook";
const { publicRuntimeConfig } = getConfig();
const useStyles = createStyles((theme) => ({
@ -74,7 +75,7 @@ export default function Home() {
const { classes } = useStyles();
const router = useRouter();
if (user) {
if (user || publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "true") {
router.replace("/upload");
} else if (publicRuntimeConfig.SHOW_HOME_PAGE == "false") {
router.replace("/auth/signIn");

View File

@ -1,19 +1,22 @@
import { Button, Group } from "@mantine/core";
import { useModals } from "@mantine/modals";
import axios from "axios";
import getConfig from "next/config";
import { useRouter } from "next/router";
import { useState } from "react";
import Meta from "../components/Meta";
import Dropzone from "../components/upload/Dropzone";
import FileList from "../components/upload/FileList";
import showCompletedUploadModal from "../components/upload/showCompletedUploadModal";
import showCreateUploadModal from "../components/upload/showCreateUploadModal";
import showCompletedUploadModal from "../components/upload/modals/showCompletedUploadModal";
import showCreateUploadModal from "../components/upload/modals/showCreateUploadModal";
import useUser from "../hooks/user.hook";
import shareService from "../services/share.service";
import { FileUpload } from "../types/File.type";
import { ShareSecurity } from "../types/share.type";
import toast from "../utils/toast.util";
const { publicRuntimeConfig } = getConfig();
const Upload = () => {
const router = useRouter();
const modals = useModals();
@ -86,7 +89,7 @@ const Upload = () => {
setisUploading(false);
}
};
if (!user) {
if (!user && publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "false") {
router.replace("/");
} else {
return (