diff --git a/.env.example b/.env.example deleted file mode 100644 index 325aeb4..0000000 --- a/.env.example +++ /dev/null @@ -1,18 +0,0 @@ -# Read what every environment variable does: https://github.com/stonith404/pingvin-share#environment-variables - -# General -APP_URL=http://localhost:3000 -SHOW_HOME_PAGE=true -ALLOW_REGISTRATION=true -ALLOW_UNAUTHENTICATED_SHARES=false -MAX_FILE_SIZE=1000000000 - -# Security -JWT_SECRET=long-random-string - -# Email -EMAIL_RECIPIENTS_ENABLED=false -SMTP_HOST=smtp.example.com -SMTP_PORT=587 -SMTP_EMAIL=pingvin-share@example.com -SMTP_PASSWORD=example \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index 25ac4b9..0000000 --- a/backend/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# General -APP_URL=http://localhost:3000 -ALLOW_REGISTRATION=true -MAX_FILE_SIZE=5000000000 -ALLOW_UNAUTHENTICATED_SHARES=false - -# Security -JWT_SECRET=random-string - -# Email -EMAIL_RECIPIENTS_ENABLED=false -SMTP_HOST=smtp.example.com -SMTP_PORT=587 -SMTP_EMAIL=pingvin-share@example.com -SMTP_PASSWORD=example \ No newline at end of file diff --git a/backend/src/auth/guard/isAdmin.guard.ts b/backend/src/auth/guard/isAdmin.guard.ts index b3bf251..16bd84b 100644 --- a/backend/src/auth/guard/isAdmin.guard.ts +++ b/backend/src/auth/guard/isAdmin.guard.ts @@ -5,6 +5,7 @@ import { User } from "@prisma/client"; export class AdministratorGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const { user }: { user: User } = context.switchToHttp().getRequest(); + if (!user) return false; return user.isAdministrator; } } diff --git a/backend/src/config/config.controller.ts b/backend/src/config/config.controller.ts index 27291e8..9e49900 100644 --- a/backend/src/config/config.controller.ts +++ b/backend/src/config/config.controller.ts @@ -1,6 +1,9 @@ -import { Controller, Get } from "@nestjs/common"; +import { Body, Controller, Get, Param, Patch, UseGuards } from "@nestjs/common"; +import { AdministratorGuard } from "src/auth/guard/isAdmin.guard"; import { ConfigService } from "./config.service"; +import { AdminConfigDTO } from "./dto/adminConfig.dto"; import { ConfigDTO } from "./dto/config.dto"; +import UpdateConfigDTO from "./dto/updateConfig.dto"; @Controller("configs") export class ConfigController { @@ -8,11 +11,22 @@ export class ConfigController { @Get() async list() { - return new ConfigDTO().fromList(await this.configService.list()) + return new ConfigDTO().fromList(await this.configService.list()); } @Get("admin") + @UseGuards(AdministratorGuard) async listForAdmin() { - return await this.configService.listForAdmin(); + return new AdminConfigDTO().fromList( + await this.configService.listForAdmin() + ); + } + + @Patch("admin/:key") + @UseGuards(AdministratorGuard) + async update(@Param("key") key: string, @Body() data: UpdateConfigDTO) { + return new AdminConfigDTO().from( + await this.configService.update(key, data.value) + ); } } diff --git a/backend/src/config/config.service.ts b/backend/src/config/config.service.ts index 9e94f4e..a844bd0 100644 --- a/backend/src/config/config.service.ts +++ b/backend/src/config/config.service.ts @@ -1,4 +1,9 @@ -import { Inject, Injectable } from "@nestjs/common"; +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, +} from "@nestjs/common"; import { Config } from "@prisma/client"; import { PrismaService } from "src/prisma/prisma.service"; @@ -38,4 +43,23 @@ export class ConfigService { return configVariable; }); } + + async update(key: string, value: string | number | boolean) { + const configVariable = await this.prisma.config.findUnique({ + where: { key }, + }); + + if (!configVariable || configVariable.locked) + throw new NotFoundException("Config variable not found"); + + if (typeof value != configVariable.type) + throw new BadRequestException( + `Config variable must be of type ${configVariable.type}` + ); + + return await this.prisma.config.update({ + where: { key }, + data: { value: value.toString() }, + }); + } } diff --git a/backend/src/config/dto/adminConfig.dto.ts b/backend/src/config/dto/adminConfig.dto.ts new file mode 100644 index 0000000..2cd135d --- /dev/null +++ b/backend/src/config/dto/adminConfig.dto.ts @@ -0,0 +1,23 @@ +import { Expose, plainToClass } from "class-transformer"; +import { ConfigDTO } from "./config.dto"; + +export class AdminConfigDTO extends ConfigDTO { + @Expose() + default: string; + + @Expose() + secret: boolean; + + @Expose() + updatedAt: Date; + + from(partial: Partial) { + return plainToClass(AdminConfigDTO, partial, { excludeExtraneousValues: true }); + } + + fromList(partial: Partial[]) { + return partial.map((part) => + plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true }) + ); + } +} diff --git a/backend/src/config/dto/updateConfig.dto.ts b/backend/src/config/dto/updateConfig.dto.ts new file mode 100644 index 0000000..3460bd2 --- /dev/null +++ b/backend/src/config/dto/updateConfig.dto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty } from "class-validator"; + +class UpdateConfigDTO { + @IsNotEmpty() + value: string | number | boolean; +} + +export default UpdateConfigDTO; diff --git a/frontend/.env.example b/frontend/.env.example deleted file mode 100644 index 8050237..0000000 --- a/frontend/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -SHOW_HOME_PAGE=true -ALLOW_REGISTRATION=true -MAX_FILE_SIZE=1000000000 -ALLOW_UNAUTHENTICATED_SHARES=false -EMAIL_RECIPIENTS_ENABLED=false \ No newline at end of file diff --git a/frontend/next.config.js b/frontend/next.config.js index d0c3fc5..caaec0d 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,14 +1,5 @@ /** @type {import('next').NextConfig} */ -const nextConfig = { - publicRuntimeConfig: { - 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, - EMAIL_RECIPIENTS_ENABLED: process.env.EMAIL_RECIPIENTS_ENABLED - } -} const withPWA = require("next-pwa")({ dest: "public", @@ -16,4 +7,4 @@ const withPWA = require("next-pwa")({ }); -module.exports = withPWA(nextConfig); +module.exports = withPWA(); diff --git a/frontend/src/components/auth/AuthForm.tsx b/frontend/src/components/auth/AuthForm.tsx index 2642b56..f8d5068 100644 --- a/frontend/src/components/auth/AuthForm.tsx +++ b/frontend/src/components/auth/AuthForm.tsx @@ -9,15 +9,15 @@ import { Title, } from "@mantine/core"; import { useForm, yupResolver } from "@mantine/form"; -import getConfig from "next/config"; import Link from "next/link"; import * as yup from "yup"; +import useConfig from "../../hooks/config.hook"; import authService from "../../services/auth.service"; import toast from "../../utils/toast.util"; -const { publicRuntimeConfig } = getConfig(); - const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => { + const config = useConfig(); + const validationSchema = yup.object().shape({ email: yup.string().email().required(), password: yup.string().min(8).required(), @@ -55,7 +55,7 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => { > {mode == "signUp" ? "Sign up" : "Welcome back"} - {publicRuntimeConfig.ALLOW_REGISTRATION == "true" && ( + {config.get("allowRegistration") && ( {mode == "signUp" ? "You have an account already?" diff --git a/frontend/src/components/navBar/NavBar.tsx b/frontend/src/components/navBar/NavBar.tsx index 6ce0218..619434d 100644 --- a/frontend/src/components/navBar/NavBar.tsx +++ b/frontend/src/components/navBar/NavBar.tsx @@ -11,15 +11,13 @@ import { Transition, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import getConfig from "next/config"; import Link from "next/link"; import { ReactNode, useEffect, useState } from "react"; +import useConfig from "../../hooks/config.hook"; import useUser from "../../hooks/user.hook"; import Logo from "../Logo"; import ActionAvatar from "./ActionAvatar"; -const { publicRuntimeConfig } = getConfig(); - const HEADER_HEIGHT = 60; type NavLink = { @@ -110,6 +108,8 @@ const useStyles = createStyles((theme) => ({ const NavBar = () => { const user = useUser(); + const config = useConfig(); + const [opened, toggleOpened] = useDisclosure(false); const authenticatedLinks = [ @@ -130,7 +130,7 @@ const NavBar = () => { ]); useEffect(() => { - if (publicRuntimeConfig.SHOW_HOME_PAGE == "true") + if (config.get("showHomePage")) setUnauthenticatedLinks((array) => [ { link: "/", @@ -139,7 +139,7 @@ const NavBar = () => { ...array, ]); - if (publicRuntimeConfig.ALLOW_REGISTRATION == "true") + if (config.get("allowRegistration")) setUnauthenticatedLinks((array) => [ ...array, { diff --git a/frontend/src/components/upload/Dropzone.tsx b/frontend/src/components/upload/Dropzone.tsx index 4556798..7aba326 100644 --- a/frontend/src/components/upload/Dropzone.tsx +++ b/frontend/src/components/upload/Dropzone.tsx @@ -1,14 +1,12 @@ import { Button, Center, createStyles, Group, Text } from "@mantine/core"; import { Dropzone as MantineDropzone } from "@mantine/dropzone"; -import getConfig from "next/config"; import { Dispatch, ForwardedRef, SetStateAction, useRef } from "react"; import { TbCloudUpload, TbUpload } from "react-icons/tb"; +import useConfig from "../../hooks/config.hook"; import { FileUpload } from "../../types/File.type"; import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util"; import toast from "../../utils/toast.util"; -const { publicRuntimeConfig } = getConfig(); - const useStyles = createStyles((theme) => ({ wrapper: { position: "relative", @@ -40,12 +38,14 @@ const Dropzone = ({ isUploading: boolean; setFiles: Dispatch>; }) => { + const config = useConfig(); + const { classes } = useStyles(); const openRef = useRef<() => void>(); return (
{ toast.error(e[0].errors[0].message); }} @@ -75,8 +75,7 @@ const Dropzone = ({ Drag'n'drop files here to start your share. We can accept only files that are less than{" "} - {byteStringToHumanSizeString(publicRuntimeConfig.MAX_FILE_SIZE)} in - size. + {byteStringToHumanSizeString(config.get("maxFileSize"))} in size.
diff --git a/frontend/src/components/upload/modals/showCreateUploadModal.tsx b/frontend/src/components/upload/modals/showCreateUploadModal.tsx index d660b4a..1cc93d5 100644 --- a/frontend/src/components/upload/modals/showCreateUploadModal.tsx +++ b/frontend/src/components/upload/modals/showCreateUploadModal.tsx @@ -18,7 +18,6 @@ import { 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"; @@ -26,11 +25,13 @@ import shareService from "../../../services/share.service"; import { ShareSecurity } from "../../../types/share.type"; import ExpirationPreview from "../ExpirationPreview"; -const { publicRuntimeConfig } = getConfig(); - const showCreateUploadModal = ( modals: ModalsContextProps, - isSignedIn: boolean, + options: { + isUserSignedIn: boolean; + allowUnauthenticatedShares: boolean; + emailRecipientsEnabled: boolean; + }, uploadCallback: ( id: string, expiration: string, @@ -42,7 +43,7 @@ const showCreateUploadModal = ( title: Share, children: ( ), @@ -51,7 +52,7 @@ const showCreateUploadModal = ( const CreateUploadModalBody = ({ uploadCallback, - isSignedIn, + options, }: { uploadCallback: ( id: string, @@ -59,12 +60,16 @@ const CreateUploadModalBody = ({ recipients: string[], security: ShareSecurity ) => void; - isSignedIn: boolean; + options: { + isUserSignedIn: boolean; + allowUnauthenticatedShares: boolean; + emailRecipientsEnabled: boolean; + }; }) => { const modals = useModals(); const [showNotSignedInAlert, setShowNotSignedInAlert] = useState( - publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "true" + options.emailRecipientsEnabled ); const validationSchema = yup.object().shape({ @@ -93,7 +98,7 @@ const CreateUploadModalBody = ({ }); return ( - {showNotSignedInAlert && !isSignedIn && ( + {showNotSignedInAlert && !options.isUserSignedIn && ( setShowNotSignedInAlert(false)} @@ -225,7 +230,7 @@ const CreateUploadModalBody = ({ {ExpirationPreview({ form })}
- {publicRuntimeConfig.EMAIL_RECIPIENTS_ENABLED == "true" && ( + {options.emailRecipientsEnabled && ( Email recipients diff --git a/frontend/src/hooks/config.hook.ts b/frontend/src/hooks/config.hook.ts new file mode 100644 index 0000000..8f3c271 --- /dev/null +++ b/frontend/src/hooks/config.hook.ts @@ -0,0 +1,14 @@ +import { createContext, useContext } from "react"; +import configService from "../services/config.service"; +import Config from "../types/config.type"; + +export const ConfigContext = createContext(null); + +const useConfig = () => { + const configVariables = useContext(ConfigContext) as Config[]; + return { + get: (key: string) => configService.get(key, configVariables), + }; +}; + +export default useConfig; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 19815af..b7cf4b8 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -10,11 +10,14 @@ import { NotificationsProvider } from "@mantine/notifications"; import type { AppProps } from "next/app"; import { useEffect, useState } from "react"; import Header from "../components/navBar/NavBar"; +import { ConfigContext } from "../hooks/config.hook"; import { UserContext } from "../hooks/user.hook"; import authService from "../services/auth.service"; +import configService from "../services/config.service"; import userService from "../services/user.service"; import GlobalStyle from "../styles/global.style"; import globalStyle from "../styles/mantine.style"; +import Config from "../types/config.type"; import { CurrentUser } from "../types/user.type"; import { GlobalLoadingContext } from "../utils/loading.util"; @@ -24,9 +27,11 @@ function App({ Component, pageProps }: AppProps) { const [colorScheme, setColorScheme] = useState(); const [isLoading, setIsLoading] = useState(true); const [user, setUser] = useState(null); + const [config, setConfig] = useState(null); const getInitalData = async () => { setIsLoading(true); + setConfig(await configService.getAll()); await authService.refreshAccessToken(); setUser(await userService.getCurrentUser()); setIsLoading(false); @@ -54,13 +59,15 @@ function App({ Component, pageProps }: AppProps) { {isLoading ? ( ) : ( - - -
- - - - + + + +
+ + + + {" "} + )} @@ -69,9 +76,4 @@ function App({ Component, pageProps }: AppProps) { ); } -// Opts out of static site generation to use publicRuntimeConfig -App.getInitialProps = () => { - return {}; -}; - export default App; diff --git a/frontend/src/pages/auth/signUp.tsx b/frontend/src/pages/auth/signUp.tsx index b4e4464..afeb013 100644 --- a/frontend/src/pages/auth/signUp.tsx +++ b/frontend/src/pages/auth/signUp.tsx @@ -1,17 +1,16 @@ -import getConfig from "next/config"; import { useRouter } from "next/router"; import AuthForm from "../../components/auth/AuthForm"; import Meta from "../../components/Meta"; +import useConfig from "../../hooks/config.hook"; import useUser from "../../hooks/user.hook"; -const { publicRuntimeConfig } = getConfig(); - const SignUp = () => { + const config = useConfig(); const user = useUser(); const router = useRouter(); if (user) { router.replace("/"); - } else if (publicRuntimeConfig.ALLOW_REGISTRATION == "false") { + } else if (config.get("allowRegistration") == "false") { router.replace("/auth/signIn"); } else { return ( diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 62dfecf..b122ba4 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -8,16 +8,14 @@ import { ThemeIcon, Title, } from "@mantine/core"; -import getConfig from "next/config"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/router"; import { TbCheck } from "react-icons/tb"; import Meta from "../components/Meta"; +import useConfig from "../hooks/config.hook"; import useUser from "../hooks/user.hook"; -const { publicRuntimeConfig } = getConfig(); - const useStyles = createStyles((theme) => ({ inner: { display: "flex", @@ -71,13 +69,14 @@ const useStyles = createStyles((theme) => ({ })); export default function Home() { + const config = useConfig(); const user = useUser(); const { classes } = useStyles(); const router = useRouter(); - if (user || publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "true") { + if (user || config.get("allowUnauthenticatedShares")) { router.replace("/upload"); - } else if (publicRuntimeConfig.SHOW_HOME_PAGE == "false") { + } else if (!config.get("showHomePage")) { router.replace("/auth/signIn"); } else { return ( diff --git a/frontend/src/pages/upload.tsx b/frontend/src/pages/upload.tsx index 9a2664e..06aff04 100644 --- a/frontend/src/pages/upload.tsx +++ b/frontend/src/pages/upload.tsx @@ -1,7 +1,6 @@ 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 { useEffect, useState } from "react"; import Meta from "../components/Meta"; @@ -9,13 +8,13 @@ import Dropzone from "../components/upload/Dropzone"; import FileList from "../components/upload/FileList"; import showCompletedUploadModal from "../components/upload/modals/showCompletedUploadModal"; import showCreateUploadModal from "../components/upload/modals/showCreateUploadModal"; +import useConfig from "../hooks/config.hook"; 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(); let share: any; const Upload = () => { @@ -23,6 +22,7 @@ const Upload = () => { const modals = useModals(); const user = useUser(); + const config = useConfig(); const [files, setFiles] = useState([]); const [isUploading, setisUploading] = useState(false); @@ -95,7 +95,7 @@ const Upload = () => { } } }, [files]); - if (!user && publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "false") { + if (!user && !config.get("allowUnauthenticatedShares")) { router.replace("/"); } else { return ( @@ -106,7 +106,17 @@ const Upload = () => { loading={isUploading} disabled={files.length <= 0} onClick={() => - showCreateUploadModal(modals, user ? true : false, uploadFiles) + showCreateUploadModal( + modals, + { + isUserSignedIn: user ? true : false, + allowUnauthenticatedShares: config.get( + "allowUnauthenticatedShares" + ), + emailRecipientsEnabled: config.get("emailRecipientsEnabled"), + }, + uploadFiles + ) } > Share diff --git a/frontend/src/services/config.service.ts b/frontend/src/services/config.service.ts new file mode 100644 index 0000000..141706f --- /dev/null +++ b/frontend/src/services/config.service.ts @@ -0,0 +1,23 @@ +import Config from "../types/config.type"; +import api from "./api.service"; + +const getAll = async (): Promise => { + return (await api.get("/configs")).data; +}; + +const get = (key: string, configVariables: Config[]): any => { + const configVariable = configVariables.filter( + (variable) => variable.key == key + )[0]; + + if (!configVariable) throw new Error(`Config variable ${key} not found`); + + if (configVariable.type == "number") return parseInt(configVariable.value); + if (configVariable.type == "boolean") return configVariable.value == "true"; + if (configVariable.type == "string") return configVariable.value; +}; + +export default { + getAll, + get, +}; diff --git a/frontend/src/types/config.type.ts b/frontend/src/types/config.type.ts new file mode 100644 index 0000000..6ba71ab --- /dev/null +++ b/frontend/src/types/config.type.ts @@ -0,0 +1,7 @@ +type Config = { + key: string; + value: string; + type: string; +}; + +export default Config;