Compare commits

...

7 Commits

Author SHA1 Message Date
Elias Schneider
155c743197 release: 0.11.1 2023-03-05 10:50:32 +01:00
Elias Schneider
8b77e81d4c fix: old config variable prevents to create a share 2023-03-05 10:48:01 +01:00
Elias Schneider
22d81b2220 release: 0.11.0 2023-03-04 23:41:11 +01:00
Elias Schneider
0317f3a508 fix: frontend error when user deleted 2023-03-04 23:40:02 +01:00
Elias Schneider
fddad3ef70 feat: custom branding (#112)
* add first concept

* remove setup status

* split config page in multiple components

* add custom branding docs

* add test email button

* fix invalid email from header

* add migration

* mount images to host

* update docs

* remove unused endpoint

* run formatter
2023-03-04 23:29:00 +01:00
Elias Schneider
f9840505b8 feat: invite new user with email 2023-02-21 08:51:04 +01:00
Elias Schneider
759c55f625 docs: fix remove app before upgrading 2023-02-13 10:09:53 +01:00
78 changed files with 1029 additions and 627 deletions

View File

@@ -1,3 +1,23 @@
### [0.11.1](https://github.com/stonith404/pingvin-share/compare/v0.11.0...v0.11.1) (2023-03-05)
### Bug Fixes
* old config variable prevents to create a share ([8b77e81](https://github.com/stonith404/pingvin-share/commit/8b77e81d4c1b8a2bf798595f5a66079c40734e09))
## [0.11.0](https://github.com/stonith404/pingvin-share/compare/v0.10.2...v0.11.0) (2023-03-04)
### Features
* custom branding ([#112](https://github.com/stonith404/pingvin-share/issues/112)) ([fddad3e](https://github.com/stonith404/pingvin-share/commit/fddad3ef708c27052a8bf46f3076286d102f6d7e))
* invite new user with email ([f984050](https://github.com/stonith404/pingvin-share/commit/f9840505b82fcb04364a79576f186b76cc75f5c0))
### Bug Fixes
* frontend error when user deleted ([0317f3a](https://github.com/stonith404/pingvin-share/commit/0317f3a508dc88ffe2c33413704f7df03a2372ea))
### [0.10.2](https://github.com/stonith404/pingvin-share/compare/v0.10.1...v0.10.2) (2023-02-13) ### [0.10.2](https://github.com/stonith404/pingvin-share/compare/v0.10.1...v0.10.2) (2023-02-13)

View File

@@ -35,10 +35,9 @@ RUN apt-get update && apt-get install -y openssl
WORKDIR /opt/app/frontend WORKDIR /opt/app/frontend
COPY --from=frontend-builder /opt/app/public ./public COPY --from=frontend-builder /opt/app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=frontend-builder /opt/app/.next/standalone ./ COPY --from=frontend-builder /opt/app/.next/standalone ./
COPY --from=frontend-builder /opt/app/.next/static ./.next/static COPY --from=frontend-builder /opt/app/.next/static ./.next/static
COPY --from=frontend-builder /opt/app/public/img /tmp/img
WORKDIR /opt/app/backend WORKDIR /opt/app/backend
COPY --from=backend-builder /opt/app/node_modules ./node_modules COPY --from=backend-builder /opt/app/node_modules ./node_modules
@@ -48,4 +47,4 @@ COPY --from=backend-builder /opt/app/package.json ./
WORKDIR /opt/app WORKDIR /opt/app
EXPOSE 3000 EXPOSE 3000
CMD node frontend/server.js & cd backend && npm run prod CMD cp -rn /tmp/img /opt/app/frontend/public && node frontend/server.js & cd backend && npm run prod

View File

@@ -27,7 +27,7 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran
1. Download the `docker-compose.yml` file 1. Download the `docker-compose.yml` file
2. Run `docker-compose up -d` 2. Run `docker-compose up -d`
The website is now listening available on `http://localhost:3000`, have fun with Pingvin Share 🐧! The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
### Stand-alone Installation ### Stand-alone Installation
@@ -45,19 +45,19 @@ cd pingvin-share
git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`) git fetch --tags && git checkout $(git describe --tags `git rev-list --tags --max-count=1`)
# Start the backend # Start the backend
cd ../backend cd backend
npm install npm install
npm run build npm run build
pm2 start --name="pingvin-share-backend" npm -- run prod pm2 start --name="pingvin-share-backend" npm -- run prod
# Start the frontend # Start the frontend
cd frontend cd ../frontend
npm install npm install
npm run build npm run build
pm2 start --name="pingvin-share-frontend" npm -- run start pm2 start --name="pingvin-share-frontend" npm -- run start
``` ```
The website is now listening available on `http://localhost:3000`, have fun with Pingvin Share 🐧! The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧!
### Integrations ### Integrations
@@ -88,7 +88,26 @@ docker compose up -d
#### Stand-alone #### Stand-alone
Repeat the steps from the [installation guide](#stand-alone-installation) except the `git clone` step. 1. Remove the running app
```
pm2 delete pingvin-share-backend pingvin-share-frontend
```
2. Repeat the steps from the [installation guide](#stand-alone-installation) except the `git clone` step.
### Custom branding
#### Name
You can change the name of the app by visiting the admin configuration page and changing the `App Name`.
#### Logo
You can change the logo of the app by replacing the images in the `/data/images` (or with the standalone installation `/frontend/public/img`) folder with your own logo. The folder contains the following images:
- `logo.png` - The logo in the header and home page
- `favicon.png` - The favicon
- `opengraph.png` - The image used for sharing on social media
- `icons/*` - The icons used for the PWA
## 🖤 Contribute ## 🖤 Contribute

View File

@@ -1,12 +1,12 @@
{ {
"name": "pingvin-share-backend", "name": "pingvin-share-backend",
"version": "0.10.2", "version": "0.11.1",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pingvin-share-backend", "name": "pingvin-share-backend",
"version": "0.10.2", "version": "0.11.1",
"dependencies": { "dependencies": {
"@nestjs/common": "^9.2.1", "@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0", "@nestjs/config": "^2.2.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "pingvin-share-backend", "name": "pingvin-share-backend",
"version": "0.10.2", "version": "0.11.1",
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"dev": "cross-env NODE_ENV=development nest start --watch", "dev": "cross-env NODE_ENV=development nest start --watch",

View File

@@ -0,0 +1,94 @@
/*
Warnings:
- The primary key for the `Config` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `key` on the `Config` table. All the data in the column will be lost.
- Added the required column `name` to the `Config` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Config" (
"updatedAt" DATETIME NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
"type" TEXT NOT NULL,
"value" TEXT NOT NULL,
"description" TEXT NOT NULL,
"obscured" BOOLEAN NOT NULL DEFAULT false,
"secret" BOOLEAN NOT NULL DEFAULT true,
"locked" BOOLEAN NOT NULL DEFAULT false,
"order" INTEGER NOT NULL,
PRIMARY KEY ("name", "category")
);
-- INSERT INTO "new_Config" ("category", "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") SELECT "category", "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value" FROM "Config";
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'internal', 'jwtSecret', "description", "locked", "obscured", 0, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'JWT_SECRET';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'general', 'appUrl', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'APP_URL';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'general', 'showHomePage', "description", "locked", "obscured", 2, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHOW_HOME_PAGE';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'share', 'allowRegistration', "description", "locked", "obscured", 0, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ALLOW_REGISTRATION';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'share', 'allowUnauthenticatedShares', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ALLOW_UNAUTHENTICATED_SHARES';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'share', 'maxSize', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'MAX_SHARE_SIZE';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'enableShareEmailRecipients', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ENABLE_SHARE_EMAIL_RECIPIENTS';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'shareRecipientsSubject', "description", "locked", "obscured", 2, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHARE_RECEPIENTS_EMAIL_SUBJECT';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'shareRecipientsMessage', "description", "locked", "obscured", 3, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHARE_RECEPIENTS_EMAIL_MESSAGE';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'reverseShareSubject', "description", "locked", "obscured", 4, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'REVERSE_SHARE_EMAIL_SUBJECT';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'reverseShareMessage', "description", "locked", "obscured", 5, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'REVERSE_SHARE_EMAIL_MESSAGE';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'resetPasswordSubject', "description", "locked", "obscured", 6, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'RESET_PASSWORD_EMAIL_SUBJECT';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'email', 'resetPasswordMessage', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'RESET_PASSWORD_EMAIL_MESSAGE';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'enabled', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_ENABLED';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'host', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_HOST';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'port', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_PORT';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'email', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_EMAIL';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'username', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_USERNAME';
INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value")
SELECT 'smtp', 'password', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_PASSWORD';
DROP TABLE "Config";
ALTER TABLE "new_Config" RENAME TO "Config";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@@ -131,13 +131,15 @@ model ShareSecurity {
model Config { model Config {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
key String @id name String
category String
type String type String
value String value String
description String description String
category String
obscured Boolean @default(false) obscured Boolean @default(false)
secret Boolean @default(true) secret Boolean @default(true)
locked Boolean @default(false) locked Boolean @default(false)
order Int order Int
@@id([name, category])
} }

View File

@@ -1,242 +1,244 @@
import { Prisma, PrismaClient } from "@prisma/client"; import { Prisma, PrismaClient } from "@prisma/client";
import * as crypto from "crypto"; import * as crypto from "crypto";
const configVariables: Prisma.ConfigCreateInput[] = [ const configVariables: ConfigVariables = {
{ internal: {
order: 0, jwtSecret: {
key: "SETUP_STATUS", description: "Long random string used to sign JWT tokens",
description: "Status of the setup wizard", type: "string",
type: "string", value: crypto.randomBytes(256).toString("base64"),
value: "STARTED", // STARTED, REGISTERED, FINISHED locked: true,
category: "internal", },
secret: false,
locked: true,
}, },
{ general: {
order: 0, appName: {
key: "JWT_SECRET", description: "Name of the application",
description: "Long random string used to sign JWT tokens", type: "string",
type: "string", value: "Pingvin Share",
value: crypto.randomBytes(256).toString("base64"), secret: false,
category: "internal", },
locked: true, appUrl: {
}, description: "On which URL Pingvin Share is available",
{ type: "string",
order: 1, value: "http://localhost:3000",
key: "APP_URL",
description: "On which URL Pingvin Share is available",
type: "string",
value: "http://localhost:3000",
category: "general",
secret: false,
},
{
order: 2,
key: "SHOW_HOME_PAGE",
description: "Whether to show the home page",
type: "boolean",
value: "true",
category: "general",
secret: false,
},
{
order: 3,
key: "ALLOW_REGISTRATION",
description: "Whether registration is allowed",
type: "boolean",
value: "true",
category: "share",
secret: false,
},
{
order: 4,
key: "ALLOW_UNAUTHENTICATED_SHARES",
description: "Whether unauthorized users can create shares",
type: "boolean",
value: "false",
category: "share",
secret: false,
},
{
order: 5,
key: "MAX_SHARE_SIZE", secret: false,
description: "Maximum share size in bytes", },
type: "number", showHomePage: {
value: "1073741824", description: "Whether to show the home page",
category: "share", type: "boolean",
secret: false, value: "true",
secret: false,
},
}, },
share: {
allowRegistration: {
description: "Whether registration is allowed",
type: "boolean",
value: "true",
{ secret: false,
order: 6, },
key: "ENABLE_SHARE_EMAIL_RECIPIENTS", allowUnauthenticatedShares: {
description: description: "Whether unauthorized users can create shares",
"Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.", type: "boolean",
type: "boolean", value: "false",
value: "false",
category: "email",
secret: false,
},
{
order: 7,
key: "SHARE_RECEPIENTS_EMAIL_SUBJECT",
description:
"Subject of the email which gets sent to the share recipients.",
type: "string",
value: "Files shared with you",
category: "email",
},
{
order: 8,
key: "SHARE_RECEPIENTS_EMAIL_MESSAGE",
description:
"Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
type: "text",
value:
"Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧",
category: "email",
},
{
order: 9,
key: "REVERSE_SHARE_EMAIL_SUBJECT",
description:
"Subject of the email which gets sent when someone created a share with your reverse share link.",
type: "string",
value: "Reverse share link used",
category: "email",
},
{
order: 10,
key: "REVERSE_SHARE_EMAIL_MESSAGE",
description:
"Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.",
type: "text",
value:
"Hey!\nA share was just created with your reverse share link: {shareUrl}\nShared securely with Pingvin Share 🐧",
category: "email",
},
{
order: 11,
key: "RESET_PASSWORD_EMAIL_SUBJECT",
description:
"Subject of the email which gets sent when a user requests a password reset.",
type: "string",
value: "Pingvin Share password reset",
category: "email",
},
{
order: 12,
key: "RESET_PASSWORD_EMAIL_MESSAGE",
description:
"Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.",
type: "text",
value:
"Hey!\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\nPingvin Share 🐧",
category: "email",
},
{ secret: false,
order: 13, },
key: "SMTP_ENABLED", maxSize: {
description: description: "Maximum share size in bytes",
"Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", type: "number",
type: "boolean", value: "1073741824",
value: "false",
category: "smtp", secret: false,
secret: false, },
}, },
{ email: {
order: 14, enableShareEmailRecipients: {
key: "SMTP_HOST", description:
description: "Host of the SMTP server", "Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.",
type: "string", type: "boolean",
value: "", value: "false",
category: "smtp",
secret: false,
},
shareRecipientsSubject: {
description:
"Subject of the email which gets sent to the share recipients.",
type: "string",
value: "Files shared with you",
},
shareRecipientsMessage: {
description:
"Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
type: "text",
value:
"Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧",
},
reverseShareSubject: {
description:
"Subject of the email which gets sent when someone created a share with your reverse share link.",
type: "string",
value: "Reverse share link used",
},
reverseShareMessage: {
description:
"Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.",
type: "text",
value:
"Hey!\nA share was just created with your reverse share link: {shareUrl}\nShared securely with Pingvin Share 🐧",
},
resetPasswordSubject: {
description:
"Subject of the email which gets sent when a user requests a password reset.",
type: "string",
value: "Pingvin Share password reset",
},
resetPasswordMessage: {
description:
"Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.",
type: "text",
value:
"Hey!\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\nPingvin Share 🐧",
},
inviteSubject: {
description:
"Subject of the email which gets sent when an admin invites an user.",
type: "string",
value: "Pingvin Share invite",
},
inviteMessage: {
description:
"Message which gets sent when an admin invites an user. {url} will be replaced with the invite URL and {password} with the password.",
type: "text",
value:
"Hey!\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\nYour password is: {password}\nPingvin Share 🐧",
},
}, },
{ smtp: {
order: 15, enabled: {
key: "SMTP_PORT", description:
description: "Port of the SMTP server", "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
type: "number", type: "boolean",
value: "0", value: "false",
category: "smtp", secret: false,
},
host: {
description: "Host of the SMTP server",
type: "string",
value: "",
},
port: {
description: "Port of the SMTP server",
type: "number",
value: "0",
},
email: {
description: "Email address which the emails get sent from",
type: "string",
value: "",
},
username: {
description: "Username of the SMTP server",
type: "string",
value: "",
},
password: {
description: "Password of the SMTP server",
type: "string",
value: "",
obscured: true,
},
}, },
{ };
order: 16,
key: "SMTP_EMAIL", type ConfigVariables = {
description: "Email address which the emails get sent from", [category: string]: {
type: "string", [variable: string]: Omit<
value: "", Prisma.ConfigCreateInput,
category: "smtp", "name" | "category" | "order"
}, >;
{ };
order: 17, };
key: "SMTP_USERNAME",
description: "Username of the SMTP server",
type: "string",
value: "",
category: "smtp",
},
{
order: 18,
key: "SMTP_PASSWORD",
description: "Password of the SMTP server",
type: "string",
value: "",
obscured: true,
category: "smtp",
},
];
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function seedConfigVariables() {
for (const variable of configVariables) { for (const [category, configVariablesOfCategory] of Object.entries(
const existingConfigVariable = await prisma.config.findUnique({ configVariables
where: { key: variable.key }, )) {
}); let order = 0;
for (const [name, properties] of Object.entries(
// Create a new config variable if it doesn't exist configVariablesOfCategory
if (!existingConfigVariable) { )) {
await prisma.config.create({ const existingConfigVariable = await prisma.config.findUnique({
data: variable, where: { name_category: { name, category } },
}); });
// Create a new config variable if it doesn't exist
if (!existingConfigVariable) {
await prisma.config.create({
data: {
order,
name,
...properties,
category,
},
});
}
order++;
} }
} }
}
const configVariablesFromDatabase = await prisma.config.findMany(); async function migrateConfigVariables() {
const existingConfigVariables = await prisma.config.findMany();
// Delete the config variable if it doesn't exist anymore for (const existingConfigVariable of existingConfigVariables) {
for (const configVariableFromDatabase of configVariablesFromDatabase) { const configVariable =
const configVariable = configVariables.find( configVariables[existingConfigVariable.category]?.[
(v) => v.key == configVariableFromDatabase.key existingConfigVariable.name
); ];
if (!configVariable) { if (!configVariable) {
await prisma.config.delete({ await prisma.config.delete({
where: { key: configVariableFromDatabase.key }, where: {
name_category: {
name: existingConfigVariable.name,
category: existingConfigVariable.category,
},
},
}); });
// Update the config variable if the metadata changed // Update the config variable if the metadata changed
} else if ( } else if (
JSON.stringify({ JSON.stringify({
...configVariable, ...configVariable,
key: configVariableFromDatabase.key, name: existingConfigVariable.name,
value: configVariableFromDatabase.value, category: existingConfigVariable.category,
}) != JSON.stringify(configVariableFromDatabase) value: existingConfigVariable.value,
}) != JSON.stringify(existingConfigVariable)
) { ) {
await prisma.config.update({ await prisma.config.update({
where: { key: configVariableFromDatabase.key }, where: {
name_category: {
name: existingConfigVariable.name,
category: existingConfigVariable.category,
},
},
data: { data: {
...configVariable, ...configVariable,
key: configVariableFromDatabase.key, name: existingConfigVariable.name,
value: configVariableFromDatabase.value, category: existingConfigVariable.category,
value: existingConfigVariable.value,
}, },
}); });
} }
} }
} }
main()
seedConfigVariables()
.then(() => migrateConfigVariables())
.then(async () => { .then(async () => {
await prisma.$disconnect(); await prisma.$disconnect();
}) })

View File

@@ -42,7 +42,7 @@ export class AuthController {
@Body() dto: AuthRegisterDTO, @Body() dto: AuthRegisterDTO,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response
) { ) {
if (!this.config.get("ALLOW_REGISTRATION")) if (!this.config.get("share.allowRegistration"))
throw new ForbiddenException("Registration is not allowed"); throw new ForbiddenException("Registration is not allowed");
const result = await this.authService.signUp(dto); const result = await this.authService.signUp(dto);

View File

@@ -25,7 +25,7 @@ export class AuthService {
) {} ) {}
async signUp(dto: AuthRegisterDTO) { async signUp(dto: AuthRegisterDTO) {
const isFirstUser = this.config.get("SETUP_STATUS") == "STARTED"; const isFirstUser = (await this.prisma.user.count()) == 0;
const hash = await argon.hash(dto.password); const hash = await argon.hash(dto.password);
try { try {
@@ -38,10 +38,6 @@ export class AuthService {
}, },
}); });
if (isFirstUser) {
await this.config.changeSetupStatus("REGISTERED");
}
const { refreshToken, refreshTokenId } = await this.createRefreshToken( const { refreshToken, refreshTokenId } = await this.createRefreshToken(
user.id user.id
); );
@@ -161,7 +157,7 @@ export class AuthService {
}, },
{ {
expiresIn: "15min", expiresIn: "15min",
secret: this.config.get("JWT_SECRET"), secret: this.config.get("internal.jwtSecret"),
} }
); );
} }

View File

@@ -11,7 +11,7 @@ export class JwtGuard extends AuthGuard("jwt") {
try { try {
return (await super.canActivate(context)) as boolean; return (await super.canActivate(context)) as boolean;
} catch { } catch {
return this.config.get("ALLOW_UNAUTHENTICATED_SHARES"); return this.config.get("share.allowUnauthenticatedShares");
} }
} }
} }

View File

@@ -9,10 +9,10 @@ import { PrismaService } from "src/prisma/prisma.service";
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService, private prisma: PrismaService) { constructor(config: ConfigService, private prisma: PrismaService) {
config.get("JWT_SECRET"); config.get("internal.jwtSecret");
super({ super({
jwtFromRequest: JwtStrategy.extractJWT, jwtFromRequest: JwtStrategy.extractJWT,
secretOrKey: config.get("JWT_SECRET"), secretOrKey: config.get("internal.jwtSecret"),
}); });
} }

View File

@@ -1,4 +1,12 @@
import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common"; import {
Body,
Controller,
Get,
Param,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { SkipThrottle } from "@nestjs/throttler"; import { SkipThrottle } from "@nestjs/throttler";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard"; import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard"; import { JwtGuard } from "src/auth/guard/jwt.guard";
@@ -22,24 +30,20 @@ export class ConfigController {
return new ConfigDTO().fromList(await this.configService.list()); return new ConfigDTO().fromList(await this.configService.list());
} }
@Get("admin") @Get("admin/:category")
@UseGuards(JwtGuard, AdministratorGuard) @UseGuards(JwtGuard, AdministratorGuard)
async listForAdmin() { async getByCategory(@Param("category") category: string) {
return new AdminConfigDTO().fromList( return new AdminConfigDTO().fromList(
await this.configService.listForAdmin() await this.configService.getByCategory(category)
); );
} }
@Patch("admin") @Patch("admin")
@UseGuards(JwtGuard, AdministratorGuard) @UseGuards(JwtGuard, AdministratorGuard)
async updateMany(@Body() data: UpdateConfigDTO[]) { async updateMany(@Body() data: UpdateConfigDTO[]) {
await this.configService.updateMany(data); return new AdminConfigDTO().fromList(
} await this.configService.updateMany(data)
);
@Post("admin/finishSetup")
@UseGuards(JwtGuard, AdministratorGuard)
async finishSetup() {
return await this.configService.changeSetupStatus("FINISHED");
} }
@Post("admin/testEmail") @Post("admin/testEmail")

View File

@@ -14,9 +14,9 @@ export class ConfigService {
private prisma: PrismaService private prisma: PrismaService
) {} ) {}
get(key: string): any { get(key: `${string}.${string}`): any {
const configVariable = this.configVariables.filter( const configVariable = this.configVariables.filter(
(variable) => variable.key == key (variable) => `${variable.category}.${variable.name}` == key
)[0]; )[0];
if (!configVariable) throw new Error(`Config variable ${key} not found`); if (!configVariable) throw new Error(`Config variable ${key} not found`);
@@ -27,30 +27,51 @@ export class ConfigService {
return configVariable.value; return configVariable.value;
} }
async listForAdmin() { async getByCategory(category: string) {
return await this.prisma.config.findMany({ const configVariables = await this.prisma.config.findMany({
orderBy: { order: "asc" }, orderBy: { order: "asc" },
where: { locked: { equals: false } }, where: { category, locked: { equals: false } },
});
return configVariables.map((variable) => {
return {
key: `${variable.category}.${variable.name}`,
...variable,
};
}); });
} }
async list() { async list() {
return await this.prisma.config.findMany({ const configVariables = await this.prisma.config.findMany({
where: { secret: { equals: false } }, where: { secret: { equals: false } },
}); });
return configVariables.map((variable) => {
return {
key: `${variable.category}.${variable.name}`,
...variable,
};
});
} }
async updateMany(data: { key: string; value: string | number | boolean }[]) { async updateMany(data: { key: string; value: string | number | boolean }[]) {
const response: Config[] = [];
for (const variable of data) { for (const variable of data) {
await this.update(variable.key, variable.value); response.push(await this.update(variable.key, variable.value));
} }
return data; return response;
} }
async update(key: string, value: string | number | boolean) { async update(key: string, value: string | number | boolean) {
const configVariable = await this.prisma.config.findUnique({ const configVariable = await this.prisma.config.findUnique({
where: { key }, where: {
name_category: {
category: key.split(".")[0],
name: key.split(".")[1],
},
},
}); });
if (!configVariable || configVariable.locked) if (!configVariable || configVariable.locked)
@@ -67,7 +88,12 @@ export class ConfigService {
} }
const updatedVariable = await this.prisma.config.update({ const updatedVariable = await this.prisma.config.update({
where: { key }, where: {
name_category: {
category: key.split(".")[0],
name: key.split(".")[1],
},
},
data: { value: value.toString() }, data: { value: value.toString() },
}); });
@@ -75,15 +101,4 @@ export class ConfigService {
return updatedVariable; return updatedVariable;
} }
async changeSetupStatus(status: "STARTED" | "REGISTERED" | "FINISHED") {
const updatedVariable = await this.prisma.config.update({
where: { key: "SETUP_STATUS" },
data: { value: status },
});
this.configVariables = await this.prisma.config.findMany();
return updatedVariable;
}
} }

View File

@@ -2,6 +2,9 @@ import { Expose, plainToClass } from "class-transformer";
import { ConfigDTO } from "./config.dto"; import { ConfigDTO } from "./config.dto";
export class AdminConfigDTO extends ConfigDTO { export class AdminConfigDTO extends ConfigDTO {
@Expose()
name: string;
@Expose() @Expose()
secret: boolean; secret: boolean;
@@ -14,9 +17,6 @@ export class AdminConfigDTO extends ConfigDTO {
@Expose() @Expose()
obscured: boolean; obscured: boolean;
@Expose()
category: string;
from(partial: Partial<AdminConfigDTO>) { from(partial: Partial<AdminConfigDTO>) {
return plainToClass(AdminConfigDTO, partial, { return plainToClass(AdminConfigDTO, partial, {
excludeExtraneousValues: true, excludeExtraneousValues: true,

View File

@@ -8,16 +8,16 @@ export class EmailService {
constructor(private config: ConfigService) {} constructor(private config: ConfigService) {}
getTransporter() { getTransporter() {
if (!this.config.get("SMTP_ENABLED")) if (!this.config.get("smtp.enabled"))
throw new InternalServerErrorException("SMTP is disabled"); throw new InternalServerErrorException("SMTP is disabled");
return nodemailer.createTransport({ return nodemailer.createTransport({
host: this.config.get("SMTP_HOST"), host: this.config.get("smtp.host"),
port: parseInt(this.config.get("SMTP_PORT")), port: this.config.get("smtp.port"),
secure: parseInt(this.config.get("SMTP_PORT")) == 465, secure: this.config.get("smtp.port") == 465,
auth: { auth: {
user: this.config.get("SMTP_USERNAME"), user: this.config.get("smtp.username"),
pass: this.config.get("SMTP_PASSWORD"), pass: this.config.get("smtp.password"),
}, },
}); });
} }
@@ -27,17 +27,19 @@ export class EmailService {
shareId: string, shareId: string,
creator?: User creator?: User
) { ) {
if (!this.config.get("ENABLE_SHARE_EMAIL_RECIPIENTS")) if (!this.config.get("email.enableShareEmailRecipients"))
throw new InternalServerErrorException("Email service disabled"); throw new InternalServerErrorException("Email service disabled");
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`; const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
await this.getTransporter().sendMail({ await this.getTransporter().sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
)}>`,
to: recipientEmail, to: recipientEmail,
subject: this.config.get("SHARE_RECEPIENTS_EMAIL_SUBJECT"), subject: this.config.get("email.shareRecipientsSubject"),
text: this.config text: this.config
.get("SHARE_RECEPIENTS_EMAIL_MESSAGE") .get("email.shareRecipientsMessage")
.replaceAll("\\n", "\n") .replaceAll("\\n", "\n")
.replaceAll("{creator}", creator?.username ?? "Someone") .replaceAll("{creator}", creator?.username ?? "Someone")
.replaceAll("{shareUrl}", shareUrl), .replaceAll("{shareUrl}", shareUrl),
@@ -45,14 +47,16 @@ export class EmailService {
} }
async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) { async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) {
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`; const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`;
await this.getTransporter().sendMail({ await this.getTransporter().sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
)}>`,
to: recipientEmail, to: recipientEmail,
subject: this.config.get("REVERSE_SHARE_EMAIL_SUBJECT"), subject: this.config.get("email.reverseShareSubject"),
text: this.config text: this.config
.get("REVERSE_SHARE_EMAIL_MESSAGE") .get("email.reverseShareMessage")
.replaceAll("\\n", "\n") .replaceAll("\\n", "\n")
.replaceAll("{shareUrl}", shareUrl), .replaceAll("{shareUrl}", shareUrl),
}); });
@@ -60,23 +64,43 @@ export class EmailService {
async sendResetPasswordEmail(recipientEmail: string, token: string) { async sendResetPasswordEmail(recipientEmail: string, token: string) {
const resetPasswordUrl = `${this.config.get( const resetPasswordUrl = `${this.config.get(
"APP_URL" "general.appUrl"
)}/auth/resetPassword/${token}`; )}/auth/resetPassword/${token}`;
await this.getTransporter().sendMail({ await this.getTransporter().sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
)}>`,
to: recipientEmail, to: recipientEmail,
subject: this.config.get("RESET_PASSWORD_EMAIL_SUBJECT"), subject: this.config.get("email.resetPasswordSubject"),
text: this.config text: this.config
.get("RESET_PASSWORD_EMAIL_MESSAGE") .get("email.resetPasswordMessage")
.replaceAll("{url}", resetPasswordUrl), .replaceAll("{url}", resetPasswordUrl),
}); });
} }
async sendInviteEmail(recipientEmail: string, password: string) {
const loginUrl = `${this.config.get("general.appUrl")}/auth/signIn`;
await this.getTransporter().sendMail({
from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
)}>`,
to: recipientEmail,
subject: this.config.get("email.inviteSubject"),
text: this.config
.get("email.inviteMessage")
.replaceAll("{url}", loginUrl)
.replaceAll("{password}", password),
});
}
async sendTestMail(recipientEmail: string) { async sendTestMail(recipientEmail: string) {
try { try {
await this.getTransporter().sendMail({ await this.getTransporter().sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, from: `"${this.config.get("general.appName")}" <${this.config.get(
"smtp.email"
)}>`,
to: recipientEmail, to: recipientEmail,
subject: "Test email", subject: "Test email",
text: "This is a test email", text: "This is a test email",

View File

@@ -67,7 +67,7 @@ export class FileService {
const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength; const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength;
if ( if (
shareSizeSum > this.config.get("MAX_SHARE_SIZE") || shareSizeSum > this.config.get("share.maxSize") ||
(share.reverseShare?.maxShareSize && (share.reverseShare?.maxShareSize &&
shareSizeSum > parseInt(share.reverseShare.maxShareSize)) shareSizeSum > parseInt(share.reverseShare.maxShareSize))
) { ) {

View File

@@ -31,7 +31,7 @@ export class ReverseShareController {
async create(@Body() body: CreateReverseShareDTO, @GetUser() user: User) { async create(@Body() body: CreateReverseShareDTO, @GetUser() user: User) {
const token = await this.reverseShareService.create(body, user.id); const token = await this.reverseShareService.create(body, user.id);
const link = `${this.config.get("APP_URL")}/upload/${token}`; const link = `${this.config.get("general.appUrl")}/upload/${token}`;
return { token, link }; return { token, link };
} }

View File

@@ -24,7 +24,7 @@ export class ReverseShareService {
) )
.toDate(); .toDate();
const globalMaxShareSize = this.config.get("MAX_SHARE_SIZE"); const globalMaxShareSize = this.config.get("share.maxSize");
if (globalMaxShareSize < data.maxShareSize) if (globalMaxShareSize < data.maxShareSize)
throw new BadRequestException( throw new BadRequestException(

View File

@@ -153,7 +153,7 @@ export class ShareService {
if ( if (
share.reverseShare && share.reverseShare &&
this.config.get("SMTP_ENABLED") && this.config.get("smtp.enabled") &&
share.reverseShare.sendEmailNotification share.reverseShare.sendEmailNotification
) { ) {
await this.emailService.sendMailToReverseShareCreator( await this.emailService.sendMailToReverseShareCreator(
@@ -303,7 +303,7 @@ export class ShareService {
}, },
{ {
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s", expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
secret: this.config.get("JWT_SECRET"), secret: this.config.get("internal.jwtSecret"),
} }
); );
} }
@@ -315,7 +315,7 @@ export class ShareService {
try { try {
const claims = this.jwtService.verify(token, { const claims = this.jwtService.verify(token, {
secret: this.config.get("JWT_SECRET"), secret: this.config.get("internal.jwtSecret"),
// Ignore expiration if expiration is 0 // Ignore expiration if expiration is 0
ignoreExpiration: moment(expiration).isSame(0), ignoreExpiration: moment(expiration).isSame(0),
}); });

View File

@@ -1,12 +1,15 @@
import { Expose, plainToClass } from "class-transformer"; import { plainToClass } from "class-transformer";
import { Allow } from "class-validator"; import { Allow, IsOptional, MinLength } from "class-validator";
import { UserDTO } from "./user.dto"; import { UserDTO } from "./user.dto";
export class CreateUserDTO extends UserDTO { export class CreateUserDTO extends UserDTO {
@Expose()
@Allow() @Allow()
isAdmin: boolean; isAdmin: boolean;
@MinLength(8)
@IsOptional()
password: string;
from(partial: Partial<CreateUserDTO>) { from(partial: Partial<CreateUserDTO>) {
return plainToClass(CreateUserDTO, partial, { return plainToClass(CreateUserDTO, partial, {
excludeExtraneousValues: true, excludeExtraneousValues: true,

View File

@@ -6,9 +6,11 @@ import {
Param, Param,
Patch, Patch,
Post, Post,
Res,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { Response } from "express";
import { GetUser } from "src/auth/decorator/getUser.decorator"; import { GetUser } from "src/auth/decorator/getUser.decorator";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard"; import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard"; import { JwtGuard } from "src/auth/guard/jwt.guard";
@@ -40,7 +42,16 @@ export class UserController {
@Delete("me") @Delete("me")
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
async deleteCurrentUser(@GetUser() user: User) { async deleteCurrentUser(
@GetUser() user: User,
@Res({ passthrough: true }) response: Response
) {
response.cookie("access_token", "accessToken", { maxAge: -1 });
response.cookie("refresh_token", "", {
path: "/api/auth/token",
httpOnly: true,
maxAge: -1,
});
return new UserDTO().from(await this.userService.delete(user.id)); return new UserDTO().from(await this.userService.delete(user.id));
} }

View File

@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { EmailModule } from "src/email/email.module";
import { UserController } from "./user.controller"; import { UserController } from "./user.controller";
import { UserSevice } from "./user.service"; import { UserSevice } from "./user.service";
@Module({ @Module({
imports: [EmailModule],
providers: [UserSevice], providers: [UserSevice],
controllers: [UserController], controllers: [UserController],
}) })

View File

@@ -1,13 +1,17 @@
import { BadRequestException, Injectable } from "@nestjs/common"; import { BadRequestException, Injectable } from "@nestjs/common";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import * as argon from "argon2"; import * as argon from "argon2";
import { EmailService } from "src/email/email.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { CreateUserDTO } from "./dto/createUser.dto"; import { CreateUserDTO } from "./dto/createUser.dto";
import { UpdateUserDto } from "./dto/updateUser.dto"; import { UpdateUserDto } from "./dto/updateUser.dto";
@Injectable() @Injectable()
export class UserSevice { export class UserSevice {
constructor(private prisma: PrismaService) {} constructor(
private prisma: PrismaService,
private emailService: EmailService
) {}
async list() { async list() {
return await this.prisma.user.findMany(); return await this.prisma.user.findMany();
@@ -18,7 +22,17 @@ export class UserSevice {
} }
async create(dto: CreateUserDTO) { async create(dto: CreateUserDTO) {
const hash = await argon.hash(dto.password); let hash: string;
// The password can be undefined if the user is invited by an admin
if (!dto.password) {
const randomPassword = crypto.randomUUID();
hash = await argon.hash(randomPassword);
this.emailService.sendInviteEmail(dto.email, randomPassword);
} else {
hash = await argon.hash(dto.password);
}
try { try {
return await this.prisma.user.create({ return await this.prisma.user.create({
data: { data: {

View File

@@ -7,6 +7,7 @@ services:
- 3000:3000 - 3000:3000
volumes: volumes:
- "./data:/opt/app/backend/data" - "./data:/opt/app/backend/data"
- "./data/images:/opt/app/frontend/public/img"
# Optional: If you add ClamAV, uncomment the following to have ClamAV start first. # Optional: If you add ClamAV, uncomment the following to have ClamAV start first.
# depends_on: # depends_on:
# clamav: # clamav:

View File

@@ -1,12 +1,12 @@
{ {
"name": "pingvin-share-frontend", "name": "pingvin-share-frontend",
"version": "0.10.2", "version": "0.11.1",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pingvin-share-frontend", "name": "pingvin-share-frontend",
"version": "0.10.2", "version": "0.11.1",
"dependencies": { "dependencies": {
"@emotion/react": "^11.10.5", "@emotion/react": "^11.10.5",
"@emotion/server": "^11.10.0", "@emotion/server": "^11.10.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "pingvin-share-frontend", "name": "pingvin-share-frontend",
"version": "0.10.2", "version": "0.11.1",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",

View File

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 944 B

After

Width:  |  Height:  |  Size: 944 B

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -1 +0,0 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 943.11 911.62"><ellipse cx="471.56" cy="454.28" rx="471.56" ry="454.28" fill="#46509e"/><ellipse cx="471.56" cy="390.28" rx="233.66" ry="207" fill="#37474f"/><path d="M705.22,849c-36.69,21.14-123.09,64.32-240.64,62.57A469.81,469.81,0,0,1,237.89,849V394.76H705.22Z" fill="#37474f"/><path d="M658.81,397.7V873.49a478.12,478.12,0,0,1-374.19,0V397.7c0-95.55,83.78-173,187.1-173S658.81,302.15,658.81,397.7Z" fill="#fff"/><polygon points="565.02 431.68 471.56 514.49 378.09 431.68 565.02 431.68" fill="#46509e"/><ellipse cx="378.09" cy="369.58" rx="23.37" ry="20.7" fill="#37474f"/><ellipse cx="565.02" cy="369.58" rx="23.37" ry="20.7" fill="#37474f"/><path d="M658.49,400.63c0-40-36.6-72.45-81.79-72.45s-81.78,32.41-81.78,72.45a64.79,64.79,0,0,0,7.9,31.05H440.29a64.79,64.79,0,0,0,7.9-31.05c0-40-36.59-72.45-81.78-72.45s-81.79,32.41-81.79,72.45l-46.73-10.35c0-114.31,104.64-207,233.67-207s233.66,92.69,233.66,207Z" fill="#37474f"/></svg>

Before

Width:  |  Height:  |  Size: 1018 B

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -8,55 +8,55 @@
"start_url": "/", "start_url": "/",
"icons": [ "icons": [
{ {
"src": "icons/icon-72x72.png", "src": "img/icons/icon-72x72.png",
"sizes": "72x72", "sizes": "72x72",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"
}, },
{ {
"src": "icons/icon-96x96.png", "src": "img/icons/icon-96x96.png",
"sizes": "96x96", "sizes": "96x96",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"
}, },
{ {
"src": "icons/icon-96x96.png", "src": "img/icons/icon-96x96.png",
"sizes": "96x96", "sizes": "96x96",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"
}, },
{ {
"src": "icons/icon-128x128.png", "src": "img/icons/icon-128x128.png",
"sizes": "128x128", "sizes": "128x128",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"
}, },
{ {
"src": "icons/icon-144x144.png", "src": "img/icons/icon-144x144.png",
"sizes": "144x144", "sizes": "144x144",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"
}, },
{ {
"src": "icons/icon-152x152.png", "src": "img/icons/icon-152x152.png",
"sizes": "152x152", "sizes": "152x152",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"
}, },
{ {
"src": "icons/icon-192x192.png", "src": "img/icons/icon-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"
}, },
{ {
"src": "icons/icon-384x384.png", "src": "img/icons/icon-384x384.png",
"sizes": "384x384", "sizes": "384x384",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"
}, },
{ {
"src": "icons/icon-512x512.png", "src": "img/icons/icon-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"

View File

@@ -1,34 +1,6 @@
import Image from "next/image";
const Logo = ({ height, width }: { height: number; width: number }) => { const Logo = ({ height, width }: { height: number; width: number }) => {
return ( return <Image src="/img/logo.png" alt="logo" height={height} width={width} />;
<svg
id="Layer_1"
data-name="Layer 1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 943.11 911.62"
height={height}
width={width}
>
<ellipse cx="471.56" cy="454.28" rx="471.56" ry="454.28" fill="#46509e" />
<ellipse cx="471.56" cy="390.28" rx="233.66" ry="207" fill="#37474f" />
<path
d="M705.22,849c-36.69,21.14-123.09,64.32-240.64,62.57A469.81,469.81,0,0,1,237.89,849V394.76H705.22Z"
fill="#37474f"
/>
<path
d="M658.81,397.7V873.49a478.12,478.12,0,0,1-374.19,0V397.7c0-95.55,83.78-173,187.1-173S658.81,302.15,658.81,397.7Z"
fill="#fff"
/>
<polygon
points="565.02 431.68 471.56 514.49 378.09 431.68 565.02 431.68"
fill="#46509e"
/>
<ellipse cx="378.09" cy="369.58" rx="23.37" ry="20.7" fill="#37474f" />
<ellipse cx="565.02" cy="369.58" rx="23.37" ry="20.7" fill="#37474f" />
<path
d="M658.49,400.63c0-40-36.6-72.45-81.79-72.45s-81.78,32.41-81.78,72.45a64.79,64.79,0,0,0,7.9,31.05H440.29a64.79,64.79,0,0,0,7.9-31.05c0-40-36.59-72.45-81.78-72.45s-81.79,32.41-81.79,72.45l-46.73-10.35c0-114.31,104.64-207,233.67-207s233.66,92.69,233.66,207Z"
fill="#37474f"
/>
</svg>
);
}; };
export default Logo; export default Logo;

View File

@@ -1,4 +1,5 @@
import Head from "next/head"; import Head from "next/head";
import useConfig from "../hooks/config.hook";
const Meta = ({ const Meta = ({
title, title,
@@ -7,7 +8,9 @@ const Meta = ({
title: string; title: string;
description?: string; description?: string;
}) => { }) => {
const metaTitle = `${title} - Pingvin Share`; const config = useConfig();
const metaTitle = `${title} - ${config.get("general.appName")}`;
return ( return (
<Head> <Head>
@@ -19,7 +22,6 @@ const Meta = ({
description ?? "An open-source and self-hosted sharing platform." description ?? "An open-source and self-hosted sharing platform."
} }
/> />
<meta property="og:image" content="/img/opengraph-default.png" />
<meta name="twitter:title" content={metaTitle} /> <meta name="twitter:title" content={metaTitle} />
<meta name="twitter:description" content={description} /> <meta name="twitter:description" content={description} />
</Head> </Head>

View File

@@ -1,153 +0,0 @@
import {
Box,
Button,
Group,
Paper,
Space,
Stack,
Text,
Title,
} from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import useConfig from "../../../hooks/config.hook";
import configService from "../../../services/config.service";
import {
AdminConfigGroupedByCategory,
UpdateConfig,
} from "../../../types/config.type";
import {
capitalizeFirstLetter,
configVariableToFriendlyName,
} from "../../../utils/string.util";
import toast from "../../../utils/toast.util";
import AdminConfigInput from "./AdminConfigInput";
import TestEmailButton from "./TestEmailButton";
const AdminConfigTable = () => {
const config = useConfig();
const router = useRouter();
const isMobile = useMediaQuery("(max-width: 560px)");
const [updatedConfigVariables, setUpdatedConfigVariables] = useState<
UpdateConfig[]
>([]);
useEffect(() => {
if (config.get("SETUP_STATUS") != "FINISHED") {
config.refresh();
}
}, []);
const updateConfigVariable = (configVariable: UpdateConfig) => {
const index = updatedConfigVariables.findIndex(
(item) => item.key === configVariable.key
);
if (index > -1) {
updatedConfigVariables[index] = configVariable;
} else {
setUpdatedConfigVariables([...updatedConfigVariables, configVariable]);
}
};
const [configVariablesByCategory, setCofigVariablesByCategory] =
useState<AdminConfigGroupedByCategory>({});
const getConfigVariables = async () => {
await configService.listForAdmin().then((configVariables) => {
const configVariablesByCategory = configVariables.reduce(
(categories: any, item) => {
const category = categories[item.category] || [];
category.push(item);
categories[item.category] = category;
return categories;
},
{}
);
setCofigVariablesByCategory(configVariablesByCategory);
});
};
const saveConfigVariables = async () => {
if (config.get("SETUP_STATUS") == "REGISTERED") {
await configService
.updateMany(updatedConfigVariables)
.then(async () => {
await configService.finishSetup();
router.reload();
})
.catch(toast.axiosError);
} else {
await configService
.updateMany(updatedConfigVariables)
.then(() => {
setUpdatedConfigVariables([]);
toast.success("Configurations updated successfully");
})
.catch(toast.axiosError);
}
config.refresh();
};
useEffect(() => {
getConfigVariables();
}, []);
return (
<Box mb="lg">
{Object.entries(configVariablesByCategory).map(
([category, configVariables]) => {
return (
<Paper key={category} withBorder p="lg" mb="xl">
<Title mb="xs" order={3}>
{capitalizeFirstLetter(category)}
</Title>
{configVariables.map((configVariable) => (
<>
<Group position="apart">
<Stack
style={{ maxWidth: isMobile ? "100%" : "40%" }}
spacing={0}
>
<Title order={6}>
{configVariableToFriendlyName(configVariable.key)}
</Title>
<Text color="dimmed" size="sm" mb="xs">
{configVariable.description}
</Text>
</Stack>
<Stack></Stack>
<Box style={{ width: isMobile ? "100%" : "50%" }}>
<AdminConfigInput
key={configVariable.key}
updateConfigVariable={updateConfigVariable}
configVariable={configVariable}
/>
</Box>
</Group>
<Space h="lg" />
</>
))}
{category == "smtp" && (
<Group position="right">
<TestEmailButton
configVariablesChanged={updatedConfigVariables.length != 0}
saveConfigVariables={saveConfigVariables}
/>
</Group>
)}
</Paper>
);
}
)}
<Group position="right">
<Button onClick={saveConfigVariables}>Save</Button>
</Group>
</Box>
);
};
export default AdminConfigTable;

View File

@@ -0,0 +1,54 @@
import {
Burger,
Button,
Group,
Header,
MediaQuery,
Text,
useMantineTheme,
} from "@mantine/core";
import Link from "next/link";
import { Dispatch, SetStateAction } from "react";
import useConfig from "../../../hooks/config.hook";
import Logo from "../../Logo";
const ConfigurationHeader = ({
isMobileNavBarOpened,
setIsMobileNavBarOpened,
}: {
isMobileNavBarOpened: boolean;
setIsMobileNavBarOpened: Dispatch<SetStateAction<boolean>>;
}) => {
const config = useConfig();
const theme = useMantineTheme();
return (
<Header height={60} p="md">
<div style={{ display: "flex", alignItems: "center", height: "100%" }}>
<MediaQuery largerThan="sm" styles={{ display: "none" }}>
<Burger
opened={isMobileNavBarOpened}
onClick={() => setIsMobileNavBarOpened((o) => !o)}
size="sm"
color={theme.colors.gray[6]}
mr="xl"
/>
</MediaQuery>
<Group position="apart" w="100%">
<Link href="/" passHref>
<Group>
<Logo height={35} width={35} />
<Text weight={600}>{config.get("general.appName")}</Text>
</Group>
</Link>
<MediaQuery smallerThan="sm" styles={{ display: "none" }}>
<Button variant="light" component={Link} href="/admin">
Go back
</Button>
</MediaQuery>
</Group>
</div>
</Header>
);
};
export default ConfigurationHeader;

View File

@@ -0,0 +1,97 @@
import {
Box,
Button,
createStyles,
Group,
MediaQuery,
Navbar,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import Link from "next/link";
import { Dispatch, SetStateAction } from "react";
import { TbAt, TbMail, TbShare, TbSquare } from "react-icons/tb";
const categories = [
{ name: "General", icon: <TbSquare /> },
{ name: "Email", icon: <TbMail /> },
{ name: "Share", icon: <TbShare /> },
{ name: "SMTP", icon: <TbAt /> },
];
const useStyles = createStyles((theme) => ({
activeLink: {
backgroundColor: theme.fn.variant({
variant: "light",
color: theme.primaryColor,
}).background,
color: theme.fn.variant({ variant: "light", color: theme.primaryColor })
.color,
borderRadius: theme.radius.sm,
fontWeight: 600,
},
}));
const ConfigurationNavBar = ({
categoryId,
isMobileNavBarOpened,
setIsMobileNavBarOpened,
}: {
categoryId: string;
isMobileNavBarOpened: boolean;
setIsMobileNavBarOpened: Dispatch<SetStateAction<boolean>>;
}) => {
const { classes } = useStyles();
return (
<Navbar
p="md"
hiddenBreakpoint="sm"
hidden={!isMobileNavBarOpened}
width={{ sm: 200, lg: 300 }}
>
<Navbar.Section>
<Text size="xs" color="dimmed" mb="sm">
Configuration
</Text>
<Stack spacing="xs">
{categories.map((category) => (
<Box
p="xs"
component={Link}
onClick={() => setIsMobileNavBarOpened(false)}
className={
categoryId == category.name.toLowerCase()
? classes.activeLink
: undefined
}
key={category.name}
href={`/admin/config/${category.name.toLowerCase()}`}
>
<Group>
<ThemeIcon
variant={
categoryId == category.name.toLowerCase()
? "filled"
: "light"
}
>
{category.icon}
</ThemeIcon>
<Text size="sm">{category.name}</Text>
</Group>
</Box>
))}
</Stack>
</Navbar.Section>
<MediaQuery largerThan="sm" styles={{ display: "none" }}>
<Button mt="xl" variant="light" component={Link} href="/admin">
Go back
</Button>
</MediaQuery>
</Navbar>
);
};
export default ConfigurationNavBar;

View File

@@ -1,7 +1,7 @@
import { ActionIcon, Box, Group, Skeleton, Table } from "@mantine/core"; import { ActionIcon, Box, Group, Skeleton, Table } from "@mantine/core";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { TbCheck, TbEdit, TbTrash } from "react-icons/tb"; import { TbCheck, TbEdit, TbTrash } from "react-icons/tb";
import User from "../../types/user.type"; import User from "../../../types/user.type";
import showUpdateUserModal from "./showUpdateUserModal"; import showUpdateUserModal from "./showUpdateUserModal";
const ManageUserTable = ({ const ManageUserTable = ({

View File

@@ -10,38 +10,44 @@ import {
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import * as yup from "yup"; import * as yup from "yup";
import userService from "../../services/user.service"; import userService from "../../../services/user.service";
import toast from "../../utils/toast.util"; import toast from "../../../utils/toast.util";
const showCreateUserModal = ( const showCreateUserModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
smtpEnabled: boolean,
getUsers: () => void getUsers: () => void
) => { ) => {
return modals.openModal({ return modals.openModal({
title: <Title order={5}>Create user</Title>, title: <Title order={5}>Create user</Title>,
children: <Body modals={modals} getUsers={getUsers} />, children: (
<Body modals={modals} smtpEnabled={smtpEnabled} getUsers={getUsers} />
),
}); });
}; };
const Body = ({ const Body = ({
modals, modals,
smtpEnabled,
getUsers, getUsers,
}: { }: {
modals: ModalsContextProps; modals: ModalsContextProps;
smtpEnabled: boolean;
getUsers: () => void; getUsers: () => void;
}) => { }) => {
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
username: "", username: "",
email: "", email: "",
password: "", password: undefined,
isAdmin: false, isAdmin: false,
setPasswordManually: false,
}, },
validate: yupResolver( validate: yupResolver(
yup.object().shape({ yup.object().shape({
email: yup.string().email(), email: yup.string().email(),
username: yup.string().min(3), username: yup.string().min(3),
password: yup.string().min(8), password: yup.string().min(8).optional(),
}) })
), ),
}); });
@@ -62,14 +68,35 @@ const Body = ({
<Stack> <Stack>
<TextInput label="Username" {...form.getInputProps("username")} /> <TextInput label="Username" {...form.getInputProps("username")} />
<TextInput label="Email" {...form.getInputProps("email")} /> <TextInput label="Email" {...form.getInputProps("email")} />
<PasswordInput {smtpEnabled && (
label="New password" <Switch
{...form.getInputProps("password")} mt="xs"
/> labelPosition="left"
label="Set password manually"
description="If not checked, the user will receive an email with a link to set their password."
{...form.getInputProps("setPasswordManually", {
type: "checkbox",
})}
/>
)}
{form.values.setPasswordManually ||
(!smtpEnabled && (
<PasswordInput
label="Password"
{...form.getInputProps("password")}
/>
))}
<Switch <Switch
styles={{
body: {
display: "flex",
justifyContent: "space-between",
},
}}
mt="xs" mt="xs"
labelPosition="left" labelPosition="left"
label="Admin privileges" label="Admin privileges"
description="If checked, the user will be able to access the admin panel."
{...form.getInputProps("isAdmin", { type: "checkbox" })} {...form.getInputProps("isAdmin", { type: "checkbox" })}
/> />
<Group position="right"> <Group position="right">

View File

@@ -11,9 +11,9 @@ import {
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import * as yup from "yup"; import * as yup from "yup";
import userService from "../../services/user.service"; import userService from "../../../services/user.service";
import User from "../../types/user.type"; import User from "../../../types/user.type";
import toast from "../../utils/toast.util"; import toast from "../../../utils/toast.util";
const showUpdateUserModal = ( const showUpdateUserModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
@@ -90,7 +90,7 @@ const Body = ({
</form> </form>
<Accordion> <Accordion>
<Accordion.Item sx={{ borderBottom: "none" }} value="changePassword"> <Accordion.Item sx={{ borderBottom: "none" }} value="changePassword">
<Accordion.Control>Change password</Accordion.Control> <Accordion.Control px={0}>Change password</Accordion.Control>
<Accordion.Panel> <Accordion.Panel>
<form <form
onSubmit={passwordForm.onSubmit(async (values) => { onSubmit={passwordForm.onSubmit(async (values) => {

View File

@@ -95,7 +95,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
<Title order={2} align="center" weight={900}> <Title order={2} align="center" weight={900}>
Welcome back Welcome back
</Title> </Title>
{config.get("ALLOW_REGISTRATION") && ( {config.get("share.allowRegistration") && (
<Text color="dimmed" size="sm" align="center" mt={5}> <Text color="dimmed" size="sm" align="center" mt={5}>
You don't have an account yet?{" "} You don't have an account yet?{" "}
<Anchor component={Link} href={"signUp"} size="sm"> <Anchor component={Link} href={"signUp"} size="sm">
@@ -131,7 +131,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
{...form.getInputProps("totp")} {...form.getInputProps("totp")}
/> />
)} )}
{config.get("SMTP_ENABLED") && ( {config.get("smtp.enabled") && (
<Group position="right" mt="xs"> <Group position="right" mt="xs">
<Anchor component={Link} href="/auth/resetPassword" size="xs"> <Anchor component={Link} href="/auth/resetPassword" size="xs">
Forgot password? Forgot password?

View File

@@ -41,8 +41,12 @@ const SignUpForm = () => {
await authService await authService
.signUp(email, username, password) .signUp(email, username, password)
.then(async () => { .then(async () => {
await refreshUser(); const user = await refreshUser();
router.replace("/upload"); if (user?.isAdmin) {
router.replace("/admin/intro");
} else {
router.replace("/upload");
}
}) })
.catch(toast.axiosError); .catch(toast.axiosError);
}; };
@@ -52,7 +56,7 @@ const SignUpForm = () => {
<Title order={2} align="center" weight={900}> <Title order={2} align="center" weight={900}>
Sign up Sign up
</Title> </Title>
{config.get("ALLOW_REGISTRATION") && ( {config.get("share.allowRegistration") && (
<Text color="dimmed" size="sm" align="center" mt={5}> <Text color="dimmed" size="sm" align="center" mt={5}>
You have an account already?{" "} You have an account already?{" "}
<Anchor component={Link} href={"signIn"} size="sm"> <Anchor component={Link} href={"signIn"} size="sm">

View File

@@ -4,7 +4,7 @@ import {
Container, Container,
createStyles, createStyles,
Group, Group,
Header, Header as MantineHeader,
Paper, Paper,
Stack, Stack,
Text, Text,
@@ -108,7 +108,7 @@ const useStyles = createStyles((theme) => ({
}, },
})); }));
const NavBar = () => { const Header = () => {
const { user } = useUser(); const { user } = useUser();
const router = useRouter(); const router = useRouter();
const config = useConfig(); const config = useConfig();
@@ -141,20 +141,20 @@ const NavBar = () => {
}, },
]; ];
if (config.get("ALLOW_UNAUTHENTICATED_SHARES")) { if (config.get("share.allowUnauthenticatedShares")) {
unauthenticatedLinks.unshift({ unauthenticatedLinks.unshift({
link: "/upload", link: "/upload",
label: "Upload", label: "Upload",
}); });
} }
if (config.get("SHOW_HOME_PAGE")) if (config.get("general.showHomePage"))
unauthenticatedLinks.unshift({ unauthenticatedLinks.unshift({
link: "/", link: "/",
label: "Home", label: "Home",
}); });
if (config.get("ALLOW_REGISTRATION")) if (config.get("share.allowRegistration"))
unauthenticatedLinks.push({ unauthenticatedLinks.push({
link: "/auth/signUp", link: "/auth/signUp",
label: "Sign up", label: "Sign up",
@@ -187,12 +187,12 @@ const NavBar = () => {
</> </>
); );
return ( return (
<Header height={HEADER_HEIGHT} mb={40} className={classes.root}> <MantineHeader height={HEADER_HEIGHT} mb={40} className={classes.root}>
<Container className={classes.header}> <Container className={classes.header}>
<Link href="/" passHref> <Link href="/" passHref>
<Group> <Group>
<Logo height={35} width={35} /> <Logo height={35} width={35} />
<Text weight={600}>Pingvin Share</Text> <Text weight={600}>{config.get("general.appName")}</Text>
</Group> </Group>
</Link> </Link>
<Group spacing={5} className={classes.links}> <Group spacing={5} className={classes.links}>
@@ -212,8 +212,8 @@ const NavBar = () => {
)} )}
</Transition> </Transition>
</Container> </Container>
</Header> </MantineHeader>
); );
}; };
export default NavBar; export default Header;

View File

@@ -33,9 +33,9 @@ const FileList = ({
const modals = useModals(); const modals = useModals();
const copyFileLink = (file: FileMetaData) => { const copyFileLink = (file: FileMetaData) => {
const link = `${config.get("APP_URL")}/api/shares/${share.id}/files/${ const link = `${config.get("general.appUrl")}/api/shares/${
file.id share.id
}`; }/files/${file.id}`;
if (window.isSecureContext) { if (window.isSecureContext) {
clipboard.copy(link); clipboard.copy(link);

View File

@@ -15,9 +15,8 @@ export async function middleware(request: NextRequest) {
const routes = { const routes = {
unauthenticated: new Routes(["/auth/*", "/"]), unauthenticated: new Routes(["/auth/*", "/"]),
public: new Routes(["/share/*", "/upload/*"]), public: new Routes(["/share/*", "/upload/*"]),
setupStatusRegistered: new Routes(["/auth/*", "/admin/setup"]),
admin: new Routes(["/admin/*"]), admin: new Routes(["/admin/*"]),
account: new Routes(["/account/*"]), account: new Routes(["/account*"]),
disabled: new Routes([]), disabled: new Routes([]),
}; };
@@ -45,41 +44,28 @@ export async function middleware(request: NextRequest) {
user = null; user = null;
} }
if (!getConfig("ALLOW_REGISTRATION")) { if (!getConfig("share.allowRegistration")) {
routes.disabled.routes.push("/auth/signUp"); routes.disabled.routes.push("/auth/signUp");
} }
if (getConfig("ALLOW_UNAUTHENTICATED_SHARES")) { if (getConfig("share.allowUnauthenticatedShares")) {
routes.public.routes = ["*"]; routes.public.routes = ["*"];
} }
if (!getConfig("SMTP_ENABLED")) { if (!getConfig("smtp.enabled")) {
routes.disabled.routes.push("/auth/resetPassword*"); routes.disabled.routes.push("/auth/resetPassword*");
} }
if (getConfig("SETUP_STATUS") == "FINISHED") {
routes.disabled.routes.push("/admin/setup");
}
// prettier-ignore // prettier-ignore
const rules = [ const rules = [
// Disabled routes // Disabled routes
{ {
condition: routes.disabled.contains(route), condition: routes.disabled.contains(route),
path: "/", path: "/",
},
// Setup status
{
condition: getConfig("SETUP_STATUS") == "STARTED" && route != "/auth/signUp",
path: "/auth/signUp",
},
{
condition: getConfig("SETUP_STATUS") == "REGISTERED" && !routes.setupStatusRegistered.contains(route) && user?.isAdmin,
path: "/admin/setup",
}, },
// Authenticated state // Authenticated state
{ {
condition: user && routes.unauthenticated.contains(route) && !getConfig("ALLOW_UNAUTHENTICATED_SHARES"), condition: user && routes.unauthenticated.contains(route) && !getConfig("share.allowUnauthenticatedShares"),
path: "/upload", path: "/upload",
}, },
// Unauthenticated state // Unauthenticated state
@@ -98,7 +84,7 @@ export async function middleware(request: NextRequest) {
}, },
// Home page // Home page
{ {
condition: (!getConfig("SHOW_HOME_PAGE") || user) && route == "/", condition: (!getConfig("general.showHomePage") || user) && route == "/",
path: "/upload", path: "/upload",
}, },
]; ];

View File

@@ -11,8 +11,9 @@ import axios from "axios";
import { getCookie, setCookie } from "cookies-next"; import { getCookie, setCookie } from "cookies-next";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";
import type { AppProps } from "next/app"; import type { AppProps } from "next/app";
import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Header from "../components/navBar/NavBar"; import Header from "../components/header/Header";
import { ConfigContext } from "../hooks/config.hook"; import { ConfigContext } from "../hooks/config.hook";
import usePreferences from "../hooks/usePreferences"; import usePreferences from "../hooks/usePreferences";
import { UserContext } from "../hooks/user.hook"; import { UserContext } from "../hooks/user.hook";
@@ -24,17 +25,26 @@ import globalStyle from "../styles/mantine.style";
import Config from "../types/config.type"; import Config from "../types/config.type";
import { CurrentUser } from "../types/user.type"; import { CurrentUser } from "../types/user.type";
const excludeDefaultLayoutRoutes = ["/admin/config/[category]"];
function App({ Component, pageProps }: AppProps) { function App({ Component, pageProps }: AppProps) {
const systemTheme = useColorScheme(pageProps.colorScheme); const systemTheme = useColorScheme(pageProps.colorScheme);
const router = useRouter();
const [colorScheme, setColorScheme] = useState<ColorScheme>(systemTheme); const [colorScheme, setColorScheme] = useState<ColorScheme>(systemTheme);
const preferences = usePreferences(); const preferences = usePreferences();
const [user, setUser] = useState<CurrentUser | null>(pageProps.user); const [user, setUser] = useState<CurrentUser | null>(pageProps.user);
const [route, setRoute] = useState<string>(pageProps.route);
const [configVariables, setConfigVariables] = useState<Config[]>( const [configVariables, setConfigVariables] = useState<Config[]>(
pageProps.configVariables pageProps.configVariables
); );
useEffect(() => {
setRoute(router.pathname);
}, [router.pathname]);
useEffect(() => { useEffect(() => {
setInterval(async () => await authService.refreshAccessToken(), 30 * 1000); setInterval(async () => await authService.refreshAccessToken(), 30 * 1000);
}, []); }, []);
@@ -86,10 +96,16 @@ function App({ Component, pageProps }: AppProps) {
}, },
}} }}
> >
<Header /> {excludeDefaultLayoutRoutes.includes(route) ? (
<Container>
<Component {...pageProps} /> <Component {...pageProps} />
</Container> ) : (
<>
<Header />
<Container>
<Component {...pageProps} />
</Container>
</>
)}
</UserContext.Provider> </UserContext.Provider>
</ConfigContext.Provider> </ConfigContext.Provider>
</ModalsProvider> </ModalsProvider>
@@ -105,12 +121,13 @@ App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
let pageProps: { let pageProps: {
user?: CurrentUser; user?: CurrentUser;
configVariables?: Config[]; configVariables?: Config[];
route?: string;
colorScheme: ColorScheme; colorScheme: ColorScheme;
} = { } = {
route: ctx.resolvedUrl,
colorScheme: colorScheme:
(getCookie("mantine-color-scheme", ctx) as ColorScheme) ?? "light", (getCookie("mantine-color-scheme", ctx) as ColorScheme) ?? "light",
}; };
if (ctx.req) { if (ctx.req) {
const cookieHeader = ctx.req.headers.cookie; const cookieHeader = ctx.req.headers.cookie;
@@ -123,6 +140,8 @@ App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
pageProps.configVariables = ( pageProps.configVariables = (
await axios(`http://localhost:8080/api/configs`) await axios(`http://localhost:8080/api/configs`)
).data; ).data;
pageProps.route = ctx.req.url;
} }
return { pageProps }; return { pageProps };

View File

@@ -11,11 +11,15 @@ export default class _Document extends Document {
<Html> <Html>
<Head> <Head>
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-white-128x128.png" /> <link rel="icon" type="image/x-icon" href="/img/favicon.ico" />
<link
rel="apple-touch-icon"
href="/img/icons/icon-white-128x128.png"
/>
<meta property="og:image" content="/img/opengraph-default.png" /> <meta property="og:image" content="/img/opengraph.png" />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="/img/opengraph-default.png" /> <meta name="twitter:image" content="/img/opengraph.png" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<meta name="theme-color" content="#46509e" /> <meta name="theme-color" content="#46509e" />
</Head> </Head>

View File

@@ -67,7 +67,7 @@ const MyShares = () => {
onClick={() => onClick={() =>
showCreateReverseShareModal( showCreateReverseShareModal(
modals, modals,
config.get("SMTP_ENABLED"), config.get("smtp.enabled"),
getReverseShares getReverseShares
) )
} }
@@ -129,9 +129,9 @@ const MyShares = () => {
onClick={() => { onClick={() => {
if (window.isSecureContext) { if (window.isSecureContext) {
clipboard.copy( clipboard.copy(
`${config.get("APP_URL")}/share/${ `${config.get(
share.id "general.appUrl"
}` )}/share/${share.id}`
); );
toast.success( toast.success(
"The share link was copied to the keyboard." "The share link was copied to the keyboard."
@@ -140,7 +140,7 @@ const MyShares = () => {
showShareLinkModal( showShareLinkModal(
modals, modals,
share.id, share.id,
config.get("APP_URL") config.get("general.appUrl")
); );
} }
}} }}

View File

@@ -84,7 +84,9 @@ const MyShares = () => {
onClick={() => { onClick={() => {
if (window.isSecureContext) { if (window.isSecureContext) {
clipboard.copy( clipboard.copy(
`${config.get("APP_URL")}/share/${share.id}` `${config.get("general.appUrl")}/share/${
share.id
}`
); );
toast.success( toast.success(
"Your link was copied to the keyboard." "Your link was copied to the keyboard."
@@ -93,7 +95,7 @@ const MyShares = () => {
showShareLinkModal( showShareLinkModal(
modals, modals,
share.id, share.id,
config.get("APP_URL") config.get("general.appUrl")
); );
} }
}} }}

View File

@@ -1,18 +0,0 @@
import { Space, Title } from "@mantine/core";
import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
import Meta from "../../components/Meta";
const AdminConfig = () => {
return (
<>
<Meta title="Configuration" />
<Title mb={30} order={3}>
Configuration
</Title>
<AdminConfigTable />
<Space h="xl" />
</>
);
};
export default AdminConfig;

View File

@@ -0,0 +1,148 @@
import {
AppShell,
Box,
Button,
Container,
Group,
Stack,
Text,
Title,
useMantineTheme,
} from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import AdminConfigInput from "../../../components/admin/configuration/AdminConfigInput";
import ConfigurationHeader from "../../../components/admin/configuration/ConfigurationHeader";
import ConfigurationNavBar from "../../../components/admin/configuration/ConfigurationNavBar";
import TestEmailButton from "../../../components/admin/configuration/TestEmailButton";
import CenterLoader from "../../../components/core/CenterLoader";
import Meta from "../../../components/Meta";
import useConfig from "../../../hooks/config.hook";
import configService from "../../../services/config.service";
import { AdminConfig, UpdateConfig } from "../../../types/config.type";
import {
capitalizeFirstLetter,
configVariableToFriendlyName,
} from "../../../utils/string.util";
import toast from "../../../utils/toast.util";
export default function AppShellDemo() {
const theme = useMantineTheme();
const router = useRouter();
const [isMobileNavBarOpened, setIsMobileNavBarOpened] = useState(false);
const isMobile = useMediaQuery("(max-width: 560px)");
const config = useConfig();
const categoryId = router.query.category as string;
const [configVariables, setConfigVariables] = useState<AdminConfig[]>();
const [updatedConfigVariables, setUpdatedConfigVariables] = useState<
UpdateConfig[]
>([]);
const saveConfigVariables = async () => {
await configService
.updateMany(updatedConfigVariables)
.then(() => {
setUpdatedConfigVariables([]);
toast.success("Configurations updated successfully");
})
.catch(toast.axiosError);
config.refresh();
};
const updateConfigVariable = (configVariable: UpdateConfig) => {
const index = updatedConfigVariables.findIndex(
(item) => item.key === configVariable.key
);
if (index > -1) {
updatedConfigVariables[index] = configVariable;
} else {
setUpdatedConfigVariables([...updatedConfigVariables, configVariable]);
}
};
useEffect(() => {
configService.getByCategory(categoryId).then((configVariables) => {
setConfigVariables(configVariables);
});
}, [categoryId]);
return (
<>
<Meta title="Configuration" />
<AppShell
styles={{
main: {
background:
theme.colorScheme === "dark"
? theme.colors.dark[8]
: theme.colors.gray[0],
},
}}
navbar={
<ConfigurationNavBar
categoryId={categoryId}
isMobileNavBarOpened={isMobileNavBarOpened}
setIsMobileNavBarOpened={setIsMobileNavBarOpened}
/>
}
header={
<ConfigurationHeader
isMobileNavBarOpened={isMobileNavBarOpened}
setIsMobileNavBarOpened={setIsMobileNavBarOpened}
/>
}
>
<Container size="lg">
{!configVariables ? (
<CenterLoader />
) : (
<>
<Stack>
<Title mb="md" order={3}>
{capitalizeFirstLetter(categoryId)}
</Title>
{configVariables.map((configVariable) => (
<Group key={configVariable.key} position="apart">
<Stack
style={{ maxWidth: isMobile ? "100%" : "40%" }}
spacing={0}
>
<Title order={6}>
{configVariableToFriendlyName(configVariable.name)}
</Title>
<Text color="dimmed" size="sm" mb="xs">
{configVariable.description}
</Text>
</Stack>
<Stack></Stack>
<Box style={{ width: isMobile ? "100%" : "50%" }}>
<AdminConfigInput
key={configVariable.key}
configVariable={configVariable}
updateConfigVariable={updateConfigVariable}
/>
</Box>
</Group>
))}
</Stack>
<Group mt="lg" position="right">
{categoryId == "smtp" && (
<TestEmailButton
configVariablesChanged={updatedConfigVariables.length != 0}
saveConfigVariables={saveConfigVariables}
/>
)}
<Button onClick={saveConfigVariables}>Save</Button>
</Group>
</>
)}
</Container>
</AppShell>
</>
);
}

View File

@@ -0,0 +1,15 @@
export function getServerSideProps() {
return {
redirect: {
permanent: false,
destination: "/admin/config/general",
},
props: {},
};
}
const Config = () => {
return null;
};
export default Config;

View File

@@ -0,0 +1,59 @@
import {
Anchor,
Button,
Center,
Container,
Stack,
Text,
Title,
} from "@mantine/core";
import Link from "next/link";
import Logo from "../../components/Logo";
import Meta from "../../components/Meta";
const Intro = () => {
return (
<>
<Meta title="Intro" />
<Container size="xs">
<Stack>
<Center>
<Logo height={80} width={80} />
</Center>
<Center>
<Title order={2}>Welcome to Pingvin Share</Title>
</Center>
<Text>
If you enjoy Pingvin Share please it on{" "}
<Anchor
target="_blank"
href="https://github.com/stonith404/pingvin-share"
>
GitHub
</Anchor>{" "}
or{" "}
<Anchor
target="_blank"
href="https://github.com/sponsors/stonith404"
>
buy me a coffee
</Anchor>{" "}
if you want to support my work.
</Text>
<Text>Enough talked, have fun with Pingvin Share!</Text>
<Text mt="lg">How to you want to continue?</Text>
<Stack>
<Button href="/admin/config" component={Link}>
Customize configuration
</Button>
<Button href="/" component={Link} variant="light">
Explore Pingvin Share
</Button>
</Stack>
</Stack>
</Container>
</>
);
};
export default Intro;

View File

@@ -1,23 +0,0 @@
import { Box, Stack, Text, Title } from "@mantine/core";
import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
import Logo from "../../components/Logo";
import Meta from "../../components/Meta";
const Setup = () => {
return (
<>
<Meta title="Setup" />
<Stack align="center">
<Logo height={80} width={80} />
<Title order={2}>Welcome to Pingvin Share</Title>
<Text>Let's customize Pingvin Share for you! </Text>
<Box style={{ width: "100%" }}>
<AdminConfigTable />
</Box>
</Stack>
</>
);
};
export default Setup;

View File

@@ -2,9 +2,10 @@ import { Button, Group, Space, Text, Title } from "@mantine/core";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { TbPlus } from "react-icons/tb"; import { TbPlus } from "react-icons/tb";
import ManageUserTable from "../../components/admin/ManageUserTable"; import ManageUserTable from "../../components/admin/users/ManageUserTable";
import showCreateUserModal from "../../components/admin/showCreateUserModal"; import showCreateUserModal from "../../components/admin/users/showCreateUserModal";
import Meta from "../../components/Meta"; import Meta from "../../components/Meta";
import useConfig from "../../hooks/config.hook";
import userService from "../../services/user.service"; import userService from "../../services/user.service";
import User from "../../types/user.type"; import User from "../../types/user.type";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
@@ -12,6 +13,8 @@ import toast from "../../utils/toast.util";
const Users = () => { const Users = () => {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const config = useConfig();
const modals = useModals(); const modals = useModals();
const getUsers = () => { const getUsers = () => {
@@ -54,7 +57,9 @@ const Users = () => {
User management User management
</Title> </Title>
<Button <Button
onClick={() => showCreateUserModal(modals, getUsers)} onClick={() =>
showCreateUserModal(modals, config.get("smtp.enabled"), getUsers)
}
leftIcon={<TbPlus size={20} />} leftIcon={<TbPlus size={20} />}
> >
Create Create

View File

@@ -11,7 +11,7 @@ export const config = {
export default (req: NextApiRequest, res: NextApiResponse) => { export default (req: NextApiRequest, res: NextApiResponse) => {
return httpProxyMiddleware(req, res, { return httpProxyMiddleware(req, res, {
headers: { headers: {
"X-Forwarded-For": req.socket.remoteAddress ?? "", "X-Forwarded-For": req.socket?.remoteAddress ?? "",
}, },
target: "http://localhost:8080", target: "http://localhost:8080",
}); });

View File

@@ -51,7 +51,6 @@ const ResetPassword = () => {
<Paper withBorder shadow="md" p={30} radius="md" mt="xl"> <Paper withBorder shadow="md" p={30} radius="md" mt="xl">
<form <form
onSubmit={form.onSubmit((values) => { onSubmit={form.onSubmit((values) => {
console.log(resetPasswordToken);
authService authService
.resetPassword(resetPasswordToken, values.password) .resetPassword(resetPasswordToken, values.password)
.then(() => { .then(() => {

View File

@@ -8,11 +8,11 @@ import {
ThemeIcon, ThemeIcon,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react"; import { useEffect } from "react";
import { TbCheck } from "react-icons/tb"; import { TbCheck } from "react-icons/tb";
import Logo from "../components/Logo";
import Meta from "../components/Meta"; import Meta from "../components/Meta";
import useUser from "../hooks/user.hook"; import useUser from "../hooks/user.hook";
@@ -150,12 +150,7 @@ export default function Home() {
</Group> </Group>
</div> </div>
<Group className={classes.image} align="center"> <Group className={classes.image} align="center">
<Image <Logo width={200} height={200} />
src="/img/logo.svg"
alt="Pingvin Share Logo"
width={200}
height={200}
/>
</Group> </Group>
</div> </div>
</Container> </Container>

View File

@@ -35,7 +35,7 @@ const Upload = ({
const [files, setFiles] = useState<FileUpload[]>([]); const [files, setFiles] = useState<FileUpload[]>([]);
const [isUploading, setisUploading] = useState(false); const [isUploading, setisUploading] = useState(false);
maxShareSize ??= parseInt(config.get("MAX_SHARE_SIZE")); maxShareSize ??= parseInt(config.get("share.maxSize"));
const uploadFiles = async (share: CreateShare) => { const uploadFiles = async (share: CreateShare) => {
setisUploading(true); setisUploading(true);
@@ -146,7 +146,7 @@ const Upload = ({
.completeShare(createdShare.id) .completeShare(createdShare.id)
.then((share) => { .then((share) => {
setisUploading(false); setisUploading(false);
showCompletedUploadModal(modals, share, config.get("APP_URL")); showCompletedUploadModal(modals, share, config.get("general.appUrl"));
setFiles([]); setFiles([]);
}) })
.catch(() => .catch(() =>
@@ -168,12 +168,12 @@ const Upload = ({
{ {
isUserSignedIn: user ? true : false, isUserSignedIn: user ? true : false,
isReverseShare, isReverseShare,
appUrl: config.get("APP_URL"), appUrl: config.get("general.appUrl"),
allowUnauthenticatedShares: config.get( allowUnauthenticatedShares: config.get(
"ALLOW_UNAUTHENTICATED_SHARES" "share.allowUnauthenticatedShares"
), ),
enableEmailRecepients: config.get( enableEmailRecepients: config.get(
"ENABLE_SHARE_EMAIL_RECIPIENTS" "email.enableShareEmailRecipients"
), ),
}, },
uploadFiles uploadFiles

View File

@@ -6,8 +6,8 @@ const list = async (): Promise<Config[]> => {
return (await api.get("/configs")).data; return (await api.get("/configs")).data;
}; };
const listForAdmin = async (): Promise<AdminConfig[]> => { const getByCategory = async (category: string): Promise<AdminConfig[]> => {
return (await api.get("/configs/admin")).data; return (await api.get(`/configs/admin/${category}`)).data;
}; };
const updateMany = async (data: UpdateConfig[]): Promise<AdminConfig[]> => { const updateMany = async (data: UpdateConfig[]): Promise<AdminConfig[]> => {
@@ -48,7 +48,7 @@ const isNewReleaseAvailable = async () => {
export default { export default {
list, list,
listForAdmin, getByCategory,
updateMany, updateMany,
get, get,
finishSetup, finishSetup,

View File

@@ -10,11 +10,11 @@ export type UpdateConfig = {
}; };
export type AdminConfig = Config & { export type AdminConfig = Config & {
name: string;
updatedAt: Date; updatedAt: Date;
secret: boolean; secret: boolean;
description: string; description: string;
obscured: boolean; obscured: boolean;
category: string;
}; };
export type AdminConfigGroupedByCategory = { export type AdminConfigGroupedByCategory = {
@@ -29,6 +29,11 @@ export type AdminConfigGroupedByCategory = {
]; ];
}; };
export type ConfigVariablesCategory = {
category: string;
count: number;
};
export type ConfigHook = { export type ConfigHook = {
configVariables: Config[]; configVariables: Config[];
refresh: () => void; refresh: () => void;

View File

@@ -9,7 +9,7 @@ type User = {
export type CreateUser = { export type CreateUser = {
username: string; username: string;
email: string; email: string;
password: string; password?: string;
isAdmin?: boolean; isAdmin?: boolean;
}; };

View File

@@ -1,8 +1,6 @@
export const configVariableToFriendlyName = (variable: string) => { export const configVariableToFriendlyName = (variable: string) => {
return variable const splitted = variable.split(/(?=[A-Z])/).join(" ");
.split("_") return splitted.charAt(0).toUpperCase() + splitted.slice(1);
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}; };
export const capitalizeFirstLetter = (string: string) => { export const capitalizeFirstLetter = (string: string) => {

View File

@@ -1,6 +1,6 @@
{ {
"name": "pingvin-share", "name": "pingvin-share",
"version": "0.10.2", "version": "0.11.1",
"scripts": { "scripts": {
"format": "cd frontend && npm run format && cd ../backend && npm run format", "format": "cd frontend && npm run format && cd ../backend && npm run format",
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint", "lint": "cd frontend && npm run lint && cd ../backend && npm run lint",