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

Merge pull request #27 from stonith404/feat/administrator-page

Feat/administrator page
This commit is contained in:
Elias Schneider 2022-12-05 18:12:57 +01:00 committed by GitHub
commit 50887b000d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
80 changed files with 3525 additions and 1469 deletions

View File

@ -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

View File

@ -3,10 +3,11 @@
We would ❤️ for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
## Getting started
You've found a bug, have suggestion or something else, just create an issue on GitHub and we can get in touch 😊.
## Submit a Pull Request
## Submit a Pull Request
Once you created a issue and you want to create a pull request, follow this guide.
Branch naming convention is as following
@ -74,20 +75,21 @@ The backend is built with [Nest.js](https://nestjs.com) and uses Typescript.
#### Setup
1. Open the `backend` folder
2. Duplicate the `.env.example` file, rename the duplicate to `.env` and change the environment variables if needed
3. Install the dependencies with `npm install`
4. Push the database schema to the database by running `npx prisma db push`
2. Install the dependencies with `npm install`
3. Push the database schema to the database by running `npx prisma db push`
4. Seed the database with `npx prisma db seed`
5. Start the backend with `npm run dev`
### Frontend
The frontend is built with [Next.js](https://nextjs.org) and uses Typescript.
#### Setup
1. Start the backend first
2. Open the `frontend` folder
3. Duplicate the `.env.example` file, rename the duplicate to `.env` and change the environment variables if needed
4. Install the dependencies with `npm install`
5. Start the frontend with `npm run dev`
3. Install the dependencies with `npm install`
4. Start the frontend with `npm run dev`
You're all set!

View File

@ -28,7 +28,5 @@ COPY --from=backend-builder /opt/app/prisma ./prisma
COPY --from=backend-builder /opt/app/package.json ./
WORKDIR /opt/app
RUN npm i -g dotenv-cli
EXPOSE 3000
CMD cd frontend && dotenv node_modules/.bin/next start & cd backend && npm run prod
CMD cd frontend && node_modules/.bin/next start & cd backend && npm run prod

View File

@ -10,6 +10,7 @@ Demo: https://pingvin-share.dev.eliasschneider.com
## ✨ Features
- Spin up your instance within 2 minutes
- Create a share with files that you can access with a link
- No file size limit, only your disk will be your limit
- Set a share expiration
@ -21,31 +22,14 @@ Demo: https://pingvin-share.dev.eliasschneider.com
> Pleas note that Pingvin Share is in early stage and could include some bugs
1. Download the `docker-compose.yml` and `.env.example` file.
2. Rename the `.env.example` file to `.env` and change the environment variables so that they fit to your environment. If you need help with the environment variables take a look [here](#environment-variables)
3. Run `docker-compose up -d`
1. Download the `docker-compose.yml` file
2. Run `docker-compose up -d`
The website is now listening available on `http://localhost:3000`, have fun with Pingvin Share 🐧!
### Environment variables
| Variable | Description | Possible values |
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------- |
| `APP_URL` | On which URL Pingvin Share is available. E.g http://localhost or https://pingvin-share.com. | URL |
| `SHOW_HOME_PAGE` | Whether the Pingvin Share home page should be shown. | true/false |
| `ALLOW_REGISTRATION` | Whether a new user can create a new account. | true/false |
| `ALLOW_UNAUTHENTICATED_SHARES` | Whether a user can create a share without being signed in. | true/false |
| `MAX_FILE_SIZE` | Maximum allowed size per file in bytes. | Number |
| `JWT_SECRET` | Long random string to sign the JWT's. | Random string |
| `EMAIL_RECIPIENTS_ENABLED` | Whether email reciepients are enabled. Only set this to true if you entered the host, port, email and password of your SMTP server. | true/false |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_EMAIL`, `SMTP_PASSWORD` | Credentials for your SMTP server. | - |
### Upgrade to a new version
1. Check if your local `docker-compose.yml` and `.env` files are up to date with the files in the repository
2. Run `docker compose pull && docker compose up -d` to update your docker container
> Note: If you installed Pingvin Share before it used Sqlite, you unfortunately have to set up the project from scratch again, sorry for that.
Run `docker compose pull && docker compose up -d` to update your docker container
## 🖤 Contribute

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -3,24 +3,27 @@
"version": "0.0.1",
"scripts": {
"build": "nest build",
"dev": "dotenv -- nest start --watch",
"prod": "npx prisma migrate deploy && dotenv node dist/main",
"dev": "nest start --watch",
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
"lint": "eslint 'src/**/*.ts'",
"format": "prettier --write 'src/**/*.ts'",
"test:system": "npx prisma migrate reset -f && nest start & sleep 10 && newman run ./test/system/newman-system-tests.json"
"test:system": "prisma migrate reset -f && nest start & sleep 10 && newman run ./test/system/newman-system-tests.json"
},
"prisma": {
"seed": "ts-node prisma/seed/config.seed.ts"
},
"dependencies": {
"@nestjs/common": "^9.1.2",
"@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.1.2",
"@nestjs/core": "^9.2.1",
"@nestjs/jwt": "^9.0.0",
"@nestjs/mapped-types": "^1.2.0",
"@nestjs/passport": "^9.0.0",
"@nestjs/platform-express": "^9.1.2",
"@nestjs/platform-express": "^9.2.1",
"@nestjs/schedule": "^2.1.0",
"@nestjs/swagger": "^6.1.2",
"@nestjs/throttler": "^3.1.0",
"archiver": "^5.3.1",
"argon2": "^0.29.1",
"argon2": "^0.30.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"content-disposition": "^0.5.4",
@ -33,36 +36,35 @@
"passport-local": "^1.0.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.5.7"
"rxjs": "^7.6.0"
},
"devDependencies": {
"@nestjs/cli": "^9.1.4",
"@nestjs/cli": "^9.1.5",
"@nestjs/schematics": "^9.0.3",
"@nestjs/testing": "^9.1.2",
"@prisma/client": "^4.4.0",
"@nestjs/testing": "^9.2.1",
"@prisma/client": "^4.7.1",
"@types/archiver": "^5.3.1",
"@types/cron": "^2.0.0",
"@types/express": "^4.17.14",
"@types/mime-types": "^2.1.1",
"@types/multer": "^1.4.7",
"@types/node": "^18.7.23",
"@types/node": "^18.11.10",
"@types/nodemailer": "^6.4.6",
"@types/passport-jwt": "^3.0.7",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.40.0",
"@typescript-eslint/parser": "^5.40.0",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"cross-env": "^7.0.3",
"dotenv-cli": "^6.0.0",
"eslint": "^8.25.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint": "^8.29.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"newman": "^5.3.2",
"prettier": "^2.7.1",
"prisma": "^4.4.0",
"prettier": "^2.8.0",
"prisma": "^4.7.1",
"source-map-support": "^0.5.21",
"ts-loader": "^9.4.1",
"ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"tsconfig-paths": "4.1.0",
"typescript": "^4.8.4"
"tsconfig-paths": "4.1.1",
"typescript": "^4.9.3"
}
}

View File

@ -0,0 +1,37 @@
/*
Warnings:
- You are about to drop the column `firstName` on the `User` table. All the data in the column will be lost.
- You are about to drop the column `lastName` on the `User` table. All the data in the column will be lost.
- Added the required column `username` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- CreateTable
CREATE TABLE "Config" (
"updatedAt" DATETIME NOT NULL,
"key" TEXT NOT NULL PRIMARY KEY,
"type" TEXT NOT NULL,
"value" TEXT NOT NULL,
"description" TEXT NOT NULL,
"secret" BOOLEAN NOT NULL DEFAULT true,
"locked" BOOLEAN NOT NULL DEFAULT false
);
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"isAdmin" BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO "new_User" ("createdAt", "email", "id", "password", "updatedAt", "username") SELECT "createdAt", "email", "id", "password", "updatedAt", 'user-' || User.id as "username" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@ -12,10 +12,10 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
email String @unique
password String
firstName String?
lastName String?
username String @unique
email String @unique
password String
isAdmin Boolean @default(false)
shares Share[]
refreshTokens RefreshToken[]
@ -76,3 +76,14 @@ model ShareSecurity {
shareId String? @unique
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
}
model Config {
updatedAt DateTime @updatedAt
key String @id
type String
value String
description String
secret Boolean @default(true)
locked Boolean @default(false)
}

View File

@ -0,0 +1,124 @@
import { PrismaClient } from "@prisma/client";
import * as crypto from "crypto";
const configVariables = [
{
key: "SETUP_FINISHED",
description: "Whether the setup has been finished",
type: "boolean",
value: "false",
secret: false,
locked: true,
},
{
key: "APP_URL",
description: "On which URL Pingvin Share is available",
type: "string",
value: "http://localhost:3000",
secret: false,
},
{
key: "SHOW_HOME_PAGE",
description: "Whether to show the home page",
type: "boolean",
value: "true",
secret: false,
},
{
key: "ALLOW_REGISTRATION",
description: "Whether registration is allowed",
type: "boolean",
value: "true",
secret: false,
},
{
key: "ALLOW_UNAUTHENTICATED_SHARES",
description: "Whether unauthorized users can create shares",
type: "boolean",
value: "false",
secret: false,
},
{
key: "MAX_FILE_SIZE",
description: "Maximum file size in bytes",
type: "number",
value: "1000000000",
secret: false,
},
{
key: "JWT_SECRET",
description: "Long random string used to sign JWT tokens",
type: "string",
value: crypto.randomBytes(256).toString("base64"),
locked: true,
},
{
key: "ENABLE_EMAIL_RECIPIENTS",
description:
"Whether to send emails to recipients. Only set this to true if you entered the host, port, email and password of your SMTP server.",
type: "boolean",
value: "false",
secret: false,
},
{
key: "SMTP_HOST",
description: "Host of the SMTP server",
type: "string",
value: "",
},
{
key: "SMTP_PORT",
description: "Port of the SMTP server",
type: "number",
value: "",
},
{
key: "SMTP_EMAIL",
description: "Email address of the SMTP server",
type: "string",
value: "",
},
{
key: "SMTP_PASSWORD",
description: "Password of the SMTP server",
type: "string",
value: "",
},
];
const prisma = new PrismaClient();
async function main() {
for (const variable of configVariables) {
const existingConfigVariable = await prisma.config.findUnique({
where: { key: variable.key },
});
// Create a new config variable if it doesn't exist
if (!existingConfigVariable) {
await prisma.config.create({
data: variable,
});
}
}
// Delete the config variable if it doesn't exist anymore
const configVariablesFromDatabase = await prisma.config.findMany();
for (const configVariableFromDatabase of configVariablesFromDatabase) {
if (!configVariables.find((v) => v.key == configVariableFromDatabase.key)) {
await prisma.config.delete({
where: { key: configVariableFromDatabase.key },
});
}
}
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});

View File

@ -1,19 +1,18 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { HttpException, HttpStatus, Module } from "@nestjs/common";
import { ScheduleModule } from "@nestjs/schedule";
import { AuthModule } from "./auth/auth.module";
import { JobsService } from "./jobs/jobs.service";
import { APP_GUARD } from "@nestjs/core";
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
import { FileController } from "./file/file.controller";
import { MulterModule } from "@nestjs/platform-express";
import { ThrottlerModule } from "@nestjs/throttler";
import { Request } from "express";
import { ConfigModule } from "./config/config.module";
import { ConfigService } from "./config/config.service";
import { EmailModule } from "./email/email.module";
import { FileModule } from "./file/file.module";
import { PrismaModule } from "./prisma/prisma.module";
import { PrismaService } from "./prisma/prisma.service";
import { ShareController } from "./share/share.controller";
import { ShareModule } from "./share/share.module";
import { UserController } from "./user/user.controller";
import { EmailModule } from "./email/email.module";
import { UserModule } from "./user/user.module";
@Module({
imports: [
@ -22,21 +21,31 @@ import { EmailModule } from "./email/email.module";
FileModule,
EmailModule,
PrismaModule,
ConfigModule.forRoot({ isGlobal: true }),
ConfigModule,
UserModule,
MulterModule.registerAsync({
useFactory: (config: ConfigService) => ({
fileFilter: (req: Request, file, cb) => {
const MAX_FILE_SIZE = config.get("MAX_FILE_SIZE");
const requestFileSize = parseInt(req.headers["content-length"]);
const isValidFileSize = requestFileSize <= MAX_FILE_SIZE;
cb(
!isValidFileSize &&
new HttpException(
`File must be smaller than ${MAX_FILE_SIZE} bytes`,
HttpStatus.PAYLOAD_TOO_LARGE
),
isValidFileSize
);
},
}),
inject: [ConfigService],
}),
ThrottlerModule.forRoot({
ttl: 60,
limit: 100,
}),
ScheduleModule.forRoot(),
],
providers: [
PrismaService,
JobsService,
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
controllers: [UserController, ShareController, FileController],
})
export class AppModule {}

View File

@ -3,14 +3,20 @@ import {
Controller,
ForbiddenException,
HttpCode,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client";
import { ConfigService } from "src/config/config.service";
import { AuthService } from "./auth.service";
import { GetUser } from "./decorator/getUser.decorator";
import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto";
import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto";
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
import { JwtGuard } from "./guard/jwt.guard";
@Controller("auth")
export class AuthController {
@ -21,8 +27,8 @@ export class AuthController {
@Throttle(10, 5 * 60)
@Post("signUp")
signUp(@Body() dto: AuthRegisterDTO) {
if (this.config.get("ALLOW_REGISTRATION") == "false")
async signUp(@Body() dto: AuthRegisterDTO) {
if (!this.config.get("ALLOW_REGISTRATION"))
throw new ForbiddenException("Registration is not allowed");
return this.authService.signUp(dto);
}
@ -34,6 +40,12 @@ export class AuthController {
return this.authService.signIn(dto);
}
@Patch("password")
@UseGuards(JwtGuard)
async updatePassword(@GetUser() user: User, @Body() dto: UpdatePasswordDTO) {
await this.authService.updatePassword(user, dto.oldPassword, dto.password);
}
@Post("token")
@HttpCode(200)
async refreshAccessToken(@Body() body: RefreshAccessTokenDTO) {

View File

@ -1,14 +1,15 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { User } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import * as argon from "argon2";
import * as moment from "moment";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service";
import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto";
@ -27,7 +28,9 @@ export class AuthService {
const user = await this.prisma.user.create({
data: {
email: dto.email,
username: dto.username,
password: hash,
isAdmin: !this.config.get("SETUP_FINISHED"),
},
});
@ -38,16 +41,22 @@ export class AuthService {
} catch (e) {
if (e instanceof PrismaClientKnownRequestError) {
if (e.code == "P2002") {
throw new BadRequestException("Credentials taken");
const duplicatedField: string = e.meta.target[0];
throw new BadRequestException(
`A user with this ${duplicatedField} already exists`
);
}
}
}
}
async signIn(dto: AuthSignInDTO) {
const user = await this.prisma.user.findUnique({
if (!dto.email && !dto.username)
throw new BadRequestException("Email or username is required");
const user = await this.prisma.user.findFirst({
where: {
email: dto.email,
OR: [{ email: dto.email }, { username: dto.username }],
},
});
@ -60,6 +69,18 @@ export class AuthService {
return { accessToken, refreshToken };
}
async updatePassword(user: User, oldPassword: string, newPassword: string) {
if (argon.verify(user.password, oldPassword))
throw new ForbiddenException("Invalid password");
const hash = await argon.hash(newPassword);
this.prisma.user.update({
where: { id: user.id },
data: { password: hash },
});
}
async createAccessToken(user: User) {
return this.jwtService.sign(
{

View File

@ -1,3 +1,8 @@
import { PickType } from "@nestjs/mapped-types";
import { UserDTO } from "src/user/dto/user.dto";
export class AuthRegisterDTO extends UserDTO {}
export class AuthRegisterDTO extends PickType(UserDTO, [
"email",
"username",
"password",
] as const) {}

View File

@ -1,7 +1,13 @@
import { PickType } from "@nestjs/swagger";
import { PickType } from "@nestjs/mapped-types";
import { IsEmail, IsOptional, IsString } from "class-validator";
import { UserDTO } from "src/user/dto/user.dto";
export class AuthSignInDTO extends PickType(UserDTO, [
"email",
"password",
] as const) {}
export class AuthSignInDTO extends PickType(UserDTO, ["password"] as const) {
@IsEmail()
@IsOptional()
email: string;
@IsString()
@IsOptional()
username: string;
}

View File

@ -0,0 +1,8 @@
import { PickType } from "@nestjs/mapped-types";
import { IsString } from "class-validator";
import { UserDTO } from "src/user/dto/user.dto";
export class UpdatePasswordDTO extends PickType(UserDTO, ["password"]) {
@IsString()
oldPassword: string;
}

View File

@ -0,0 +1,13 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
@Injectable()
export class AdministratorGuard implements CanActivate {
canActivate(context: ExecutionContext) {
const { user }: { user: User } = context.switchToHttp().getRequest();
if (!user) return false;
return user.isAdmin;
}
}

View File

@ -1,15 +1,17 @@
import { ExecutionContext } from "@nestjs/common";
import { ExecutionContext, Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { ConfigService } from "src/config/config.service";
@Injectable()
export class JwtGuard extends AuthGuard("jwt") {
constructor() {
constructor(private config: ConfigService) {
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> {
try {
return (await super.canActivate(context)) as boolean;
} catch {
return process.env.ALLOW_UNAUTHENTICATED_SHARES == "true";
return this.config.get("ALLOW_UNAUTHENTICATED_SHARES");
}
}
}

View File

@ -1,13 +1,14 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { User } from "@prisma/client";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService, private prisma: PrismaService) {
config.get("JWT_SECRET");
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get("JWT_SECRET"),
@ -18,7 +19,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
const user: User = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
return user;
}
}

View File

@ -0,0 +1,47 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.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 {
constructor(private configService: ConfigService) {}
@Get()
async list() {
return new ConfigDTO().fromList(await this.configService.list());
}
@Get("admin")
@UseGuards(JwtGuard, AdministratorGuard)
async listForAdmin() {
return new AdminConfigDTO().fromList(
await this.configService.listForAdmin()
);
}
@Patch("admin/:key")
@UseGuards(JwtGuard, AdministratorGuard)
async update(@Param("key") key: string, @Body() data: UpdateConfigDTO) {
return new AdminConfigDTO().from(
await this.configService.update(key, data.value)
);
}
@Post("admin/finishSetup")
@UseGuards(JwtGuard, AdministratorGuard)
async finishSetup() {
return await this.configService.finishSetup();
}
}

View File

@ -0,0 +1,21 @@
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { ConfigController } from "./config.controller";
import { ConfigService } from "./config.service";
@Global()
@Module({
providers: [
{
provide: "CONFIG_VARIABLES",
useFactory: async (prisma: PrismaService) => {
return await prisma.config.findMany();
},
inject: [PrismaService],
},
ConfigService,
],
controllers: [ConfigController],
exports: [ConfigService],
})
export class ConfigModule {}

View File

@ -0,0 +1,70 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Config } from "@prisma/client";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class ConfigService {
constructor(
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
private prisma: PrismaService
) {}
get(key: string): any {
const configVariable = this.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;
}
async listForAdmin() {
return await this.prisma.config.findMany({
where: { locked: { equals: false } },
});
}
async list() {
return await this.prisma.config.findMany({
where: { secret: { equals: false } },
});
}
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}`
);
const updatedVariable = await this.prisma.config.update({
where: { key },
data: { value: value.toString() },
});
this.configVariables = await this.prisma.config.findMany();
return updatedVariable;
}
async finishSetup() {
return await this.prisma.config.update({
where: { key: "SETUP_FINISHED" },
data: { value: "true" },
});
}
}

View File

@ -0,0 +1,25 @@
import { Expose, plainToClass } from "class-transformer";
import { ConfigDTO } from "./config.dto";
export class AdminConfigDTO extends ConfigDTO {
@Expose()
secret: boolean;
@Expose()
updatedAt: Date;
@Expose()
description: string;
from(partial: Partial<AdminConfigDTO>) {
return plainToClass(AdminConfigDTO, partial, {
excludeExtraneousValues: true,
});
}
fromList(partial: Partial<AdminConfigDTO>[]) {
return partial.map((part) =>
plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true })
);
}
}

View File

@ -0,0 +1,18 @@
import { Expose, plainToClass } from "class-transformer";
export class ConfigDTO {
@Expose()
key: string;
@Expose()
value: string;
@Expose()
type: string;
fromList(partial: Partial<ConfigDTO>[]) {
return partial.map((part) =>
plainToClass(ConfigDTO, part, { excludeExtraneousValues: true })
);
}
}

View File

@ -0,0 +1,8 @@
import { IsNotEmpty } from "class-validator";
class UpdateConfigDTO {
@IsNotEmpty()
value: string | number | boolean;
}
export default UpdateConfigDTO;

View File

@ -1,38 +1,34 @@
import { Injectable, InternalServerErrorException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { User } from "@prisma/client";
import * as nodemailer from "nodemailer";
import { ConfigService } from "src/config/config.service";
@Injectable()
export class EmailService {
constructor(private config: ConfigService) {}
// create reusable transporter object using the default SMTP transport
transporter = nodemailer.createTransport({
host: this.config.get("SMTP_HOST"),
port: parseInt(this.config.get("SMTP_PORT")),
secure: parseInt(this.config.get("SMTP_PORT")) == 465,
auth: {
user: this.config.get("SMTP_EMAIL"),
pass: this.config.get("SMTP_PASSWORD"),
},
});
async sendMail(recipientEmail: string, shareId: string, creator: User) {
if (this.config.get("EMAIL_RECIPIENTS_ENABLED") == "false")
// create reusable transporter object using the default SMTP transport
const transporter = nodemailer.createTransport({
host: this.config.get("SMTP_HOST"),
port: parseInt(this.config.get("SMTP_PORT")),
secure: parseInt(this.config.get("SMTP_PORT")) == 465,
auth: {
user: this.config.get("SMTP_EMAIL"),
pass: this.config.get("SMTP_PASSWORD"),
},
});
if (!this.config.get("ENABLE_EMAIL_RECIPIENTS"))
throw new InternalServerErrorException("Email service disabled");
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
const creatorIdentifier = creator ?
creator.firstName && creator.lastName
? `${creator.firstName} ${creator.lastName}`
: creator.email : "A Pingvin Share user";
await this.transporter.sendMail({
await transporter.sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail,
subject: "Files shared with you",
text: `Hey!\n${creatorIdentifier} shared some files with you. View or dowload the files with this link: ${shareUrl}.\n Shared securely with Pingvin Share 🐧`,
text: `Hey!\n${creator.username} shared some files with you. View or dowload the files with this link: ${shareUrl}.\n Shared securely with Pingvin Share 🐧`,
});
}
}

View File

@ -2,7 +2,6 @@ import {
Controller,
Get,
Param,
ParseFilePipeBuilder,
Post,
Res,
StreamableFile,
@ -32,13 +31,7 @@ export class FileController {
})
)
async create(
@UploadedFile(
new ParseFilePipeBuilder()
.addMaxSizeValidator({
maxSize: parseInt(process.env.MAX_FILE_SIZE),
})
.build()
)
@UploadedFile()
file: Express.Multer.File,
@Param("shareId") shareId: string
) {

View File

@ -3,11 +3,12 @@ import { JwtModule } from "@nestjs/jwt";
import { ShareModule } from "src/share/share.module";
import { FileController } from "./file.controller";
import { FileService } from "./file.service";
import { FileValidationPipe } from "./pipe/fileValidation.pipe";
@Module({
imports: [JwtModule.register({}), ShareModule],
controllers: [FileController],
providers: [FileService],
providers: [FileService, FileValidationPipe],
exports: [FileService],
})
export class FileModule {}

View File

@ -3,11 +3,11 @@ import {
Injectable,
NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { randomUUID } from "crypto";
import * as fs from "fs";
import * as mime from "mime-types";
import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
@ -80,6 +80,7 @@ export class FileService {
getFileDownloadUrl(shareId: string, fileId: string) {
const downloadToken = this.generateFileDownloadToken(shareId, fileId);
return `${this.config.get(
"APP_URL"
)}/api/shares/${shareId}/files/${fileId}?token=${downloadToken}`;

View File

@ -0,0 +1,17 @@
import {
ArgumentMetadata,
BadRequestException,
Injectable,
PipeTransform,
} from "@nestjs/common";
import { ConfigService } from "src/config/config.service";
@Injectable()
export class FileValidationPipe implements PipeTransform {
constructor(private config: ConfigService) {}
async transform(value: any, metadata: ArgumentMetadata) {
if (value.size > this.config.get("MAX_FILE_SIZE"))
throw new BadRequestException("File is ");
return value;
}
}

View File

@ -1,5 +1,6 @@
import { Injectable } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import * as fs from "fs";
import * as moment from "moment";
import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service";
@ -35,6 +36,22 @@ export class JobsService {
console.log(`job: deleted ${expiredShares.length} expired shares`);
}
@Cron("0 0 * * *")
deleteTemporaryFiles() {
const files = fs.readdirSync("./data/uploads/_temp");
for (const file of files) {
const stats = fs.statSync(`./data/uploads/_temp/${file}`);
const isOlderThanOneDay = moment(stats.mtime)
.add(1, "day")
.isBefore(moment());
if (isOlderThanOneDay) fs.rmSync(`./data/uploads/_temp/${file}`);
}
console.log(`job: deleted ${files.length} temporary files`);
}
@Cron("0 * * * *")
async deleteExpiredRefreshTokens() {
const expiredRefreshTokens = await this.prisma.refreshToken.deleteMany({

View File

@ -6,7 +6,7 @@ import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.useGlobalPipes(new ValidationPipe());
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
app.set("trust proxy", true);

View File

@ -4,7 +4,7 @@ import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService extends PrismaClient {
constructor(config: ConfigService) {
constructor() {
super({
datasources: {
db: {

View File

@ -11,7 +11,7 @@ import { ShareSecurityDTO } from "./shareSecurity.dto";
export class CreateShareDTO {
@IsString()
@Matches("^[a-zA-Z0-9_-]*$", undefined, {
message: "ID only can contain letters, numbers, underscores and hyphens",
message: "ID can only contain letters, numbers, underscores and hyphens",
})
@Length(3, 50)
id: string;

View File

@ -1,3 +1,6 @@
import { IsString } from "class-validator";
export class SharePasswordDto {
@IsString()
password: string;
}

View File

@ -4,13 +4,13 @@ import {
Injectable,
NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { Share, User } from "@prisma/client";
import * as archiver from "archiver";
import * as argon from "argon2";
import * as fs from "fs";
import * as moment from "moment";
import { ConfigService } from "src/config/config.service";
import { EmailService } from "src/email/email.service";
import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service";

View File

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

View File

@ -1,4 +1,4 @@
import { PickType } from "@nestjs/swagger";
import { PickType } from "@nestjs/mapped-types";
import { UserDTO } from "./user.dto";
export class PublicUserDTO extends PickType(UserDTO, ["email"] as const) {}

View File

@ -0,0 +1,6 @@
import { OmitType, PartialType } from "@nestjs/mapped-types";
import { UserDTO } from "./user.dto";
export class UpdateOwnUserDTO extends PartialType(
OmitType(UserDTO, ["isAdmin", "password"] as const)
) {}

View File

@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreateUserDTO } from "./createUser.dto";
export class UpdateUserDto extends PartialType(CreateUserDTO) {}

View File

@ -1,26 +1,34 @@
import { Expose, plainToClass } from "class-transformer";
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
import { IsEmail, Length, Matches, MinLength } from "class-validator";
export class UserDTO {
@Expose()
id: string;
@Expose()
firstName: string;
@Matches("^[a-zA-Z0-9_.]*$", undefined, {
message: "Username can only contain letters, numbers, dots and underscores",
})
@Length(3, 32)
username: string;
@Expose()
lastName: string;
@Expose()
@IsNotEmpty()
@IsEmail()
email: string;
@IsNotEmpty()
@IsString()
@MinLength(8)
password: string;
@Expose()
isAdmin: boolean;
from(partial: Partial<UserDTO>) {
return plainToClass(UserDTO, partial, { excludeExtraneousValues: true });
}
fromList(partial: Partial<UserDTO>[]) {
return partial.map((part) =>
plainToClass(UserDTO, part, { excludeExtraneousValues: true })
);
}
}

View File

@ -1,14 +1,71 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { User } from "@prisma/client";
import { GetUser } from "src/auth/decorator/getUser.decorator";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { CreateUserDTO } from "./dto/createUser.dto";
import { UpdateOwnUserDTO } from "./dto/updateOwnUser.dto";
import { UpdateUserDto } from "./dto/updateUser.dto";
import { UserDTO } from "./dto/user.dto";
import { UserSevice } from "./user.service";
@Controller("users")
export class UserController {
constructor(private userService: UserSevice) {}
// Own user operations
@Get("me")
@UseGuards(JwtGuard)
async getCurrentUser(@GetUser() user: User) {
return new UserDTO().from(user);
}
@Patch("me")
@UseGuards(JwtGuard)
async updateCurrentUser(
@GetUser() user: User,
@Body() data: UpdateOwnUserDTO
) {
return new UserDTO().from(await this.userService.update(user.id, data));
}
@Delete("me")
@UseGuards(JwtGuard)
async deleteCurrentUser(@GetUser() user: User) {
return new UserDTO().from(await this.userService.delete(user.id));
}
// Global user operations
@Get()
@UseGuards(JwtGuard, AdministratorGuard)
async list() {
return new UserDTO().fromList(await this.userService.list());
}
@Post()
@UseGuards(JwtGuard, AdministratorGuard)
async create(@Body() user: CreateUserDTO) {
return new UserDTO().from(await this.userService.create(user));
}
@Patch(":id")
@UseGuards(JwtGuard, AdministratorGuard)
async update(@Param("id") id: string, @Body() user: UpdateUserDto) {
return new UserDTO().from(await this.userService.update(id, user));
}
@Delete(":id")
@UseGuards(JwtGuard, AdministratorGuard)
async delete(@Param("id") id: string) {
return new UserDTO().from(await this.userService.delete(id));
}
}

View File

@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { UserController } from "./user.controller";
import { UserSevice } from "./user.service";
@Module({
providers: [UserSevice],
controllers: [UserController],
})
export class UserModule {}

View File

@ -0,0 +1,65 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import * as argon from "argon2";
import { PrismaService } from "src/prisma/prisma.service";
import { CreateUserDTO } from "./dto/createUser.dto";
import { UpdateUserDto } from "./dto/updateUser.dto";
import { UserDTO } from "./dto/user.dto";
@Injectable()
export class UserSevice {
constructor(private prisma: PrismaService) {}
async list() {
return await this.prisma.user.findMany();
}
async get(id: string) {
return await this.prisma.user.findUnique({ where: { id } });
}
async create(dto: CreateUserDTO) {
const hash = await argon.hash(dto.password);
try {
return await this.prisma.user.create({
data: {
...dto,
password: hash,
},
});
} catch (e) {
if (e instanceof PrismaClientKnownRequestError) {
if (e.code == "P2002") {
const duplicatedField: string = e.meta.target[0];
throw new BadRequestException(
`A user with this ${duplicatedField} already exists`
);
}
}
}
}
async update(id: string, user: UpdateUserDto) {
try {
const hash = user.password && (await argon.hash(user.password));
return await this.prisma.user.update({
where: { id },
data: { ...user, password: hash },
});
} catch (e) {
if (e instanceof PrismaClientKnownRequestError) {
if (e.code == "P2002") {
const duplicatedField: string = e.meta.target[0];
throw new BadRequestException(
`A user with this ${duplicatedField} already exists`
);
}
}
}
}
async delete(id: string) {
return await this.prisma.user.delete({ where: { id } });
}
}

View File

@ -36,7 +36,7 @@
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"firstName\" : \"System\",\n \"lastName\" : \"Test\",\n \"email\": \"system@test.org\",\n \"password\": \"J2y8unpJUcJDRv\"\n}",
"raw": "{\n \"email\": \"system@test.org\",\n \"username\": \"system.test\",\n \"password\": \"J2y8unpJUcJDRv\"\n}",
"options": {
"raw": {
"language": "json"
@ -97,7 +97,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"firstName\" : \"System\",\n \"lastName\" : \"Test2\",\n \"email\": \"system2@test.org\",\n \"password\": \"N44HcHgeuAvfCT\"\n}",
"raw": "{\n \"email\": \"system2@test.org\",\n \"username\": \"system.test2\",\n \"password\": \"N44HcHgeuAvfCT\"\n}",
"options": {
"raw": {
"language": "json"

View File

@ -5,17 +5,5 @@ services:
restart: unless-stopped
ports:
- 3000:3000
environment:
- APP_URL=${APP_URL}
- SHOW_HOME_PAGE=${SHOW_HOME_PAGE}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION}
- ALLOW_UNAUTHENTICATED_SHARES=${ALLOW_UNAUTHENTICATED_SHARES}
- MAX_FILE_SIZE=${MAX_FILE_SIZE}
- JWT_SECRET=${JWT_SECRET}
- EMAIL_RECIPIENTS_ENABLED=${EMAIL_RECIPIENTS_ENABLED}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_EMAIL=${SMTP_EMAIL}
- SMTP_PASSWORD=${SMTP_PASSWORD}
volumes:
- "${PWD}/data:/opt/app/backend/data"

View File

@ -1,5 +0,0 @@
SHOW_HOME_PAGE=true
ALLOW_REGISTRATION=true
MAX_FILE_SIZE=1000000000
ALLOW_UNAUTHENTICATED_SHARES=false
EMAIL_RECIPIENTS_ENABLED=false

View File

@ -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();

File diff suppressed because it is too large Load Diff

View File

@ -2,47 +2,46 @@
"name": "pingvin-share",
"version": "0.0.1",
"scripts": {
"dev": "dotenv next dev",
"dev": "next dev",
"build": "next build",
"start": "dotenv next start",
"start": "next start",
"lint": "next lint",
"format": "prettier --write \"src/**/*.ts*\""
},
"dependencies": {
"@emotion/react": "^11.10.4",
"@emotion/react": "^11.10.5",
"@emotion/server": "^11.10.0",
"@mantine/core": "^5.5.2",
"@mantine/dropzone": "^5.5.2",
"@mantine/form": "^5.5.2",
"@mantine/hooks": "^5.5.2",
"@mantine/modals": "^5.5.2",
"@mantine/next": "^5.5.2",
"@mantine/notifications": "^5.5.2",
"axios": "^0.26.1",
"cookies-next": "^2.0.4",
"@mantine/core": "^5.9.1",
"@mantine/dropzone": "^5.9.1",
"@mantine/form": "^5.9.1",
"@mantine/hooks": "^5.9.1",
"@mantine/modals": "^5.9.1",
"@mantine/next": "^5.9.1",
"@mantine/notifications": "^5.9.1",
"axios": "^1.2.0",
"cookies-next": "^2.1.1",
"file-saver": "^2.0.5",
"jose": "^4.8.1",
"jose": "^4.11.1",
"moment": "^2.29.4",
"next": "^13.0.0",
"next": "^13.0.6",
"next-cookies": "^2.0.3",
"next-http-proxy-middleware": "^1.2.4",
"next-http-proxy-middleware": "^1.2.5",
"next-pwa": "^5.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.4.0",
"react-icons": "^4.7.1",
"yup": "^0.32.11"
},
"devDependencies": {
"@types/node": "17.0.23",
"@types/react": "18.0.4",
"@types/react-dom": "18.0.0",
"axios": "^0.26.1",
"dotenv-cli": "^6.0.0",
"eslint": "8.13.0",
"eslint-config-next": "^13.0.0",
"@types/node": "18.11.10",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.9",
"axios": "^1.2.0",
"eslint": "8.29.0",
"eslint-config-next": "^13.0.6",
"eslint-config-prettier": "^8.5.0",
"prettier": "^2.7.1",
"tar": "^6.1.11",
"typescript": "^4.6.3"
"prettier": "^2.8.0",
"tar": "^6.1.12",
"typescript": "^4.9.3"
}
}

View File

@ -0,0 +1,93 @@
import { ActionIcon, Code, Group, Skeleton, Table, Text } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { useEffect, useState } from "react";
import { TbEdit, TbLock } from "react-icons/tb";
import configService from "../../services/config.service";
import { AdminConfig as AdminConfigType } from "../../types/config.type";
import showUpdateConfigVariableModal from "./showUpdateConfigVariableModal";
const AdminConfigTable = () => {
const modals = useModals();
const [isLoading, setIsLoading] = useState(false);
const [configVariables, setConfigVariables] = useState<AdminConfigType[]>([]);
const getConfigVariables = async () => {
await configService.listForAdmin().then((configVariables) => {
setConfigVariables(configVariables);
});
};
useEffect(() => {
setIsLoading(true);
getConfigVariables().then(() => setIsLoading(false));
}, []);
const skeletonRows = [...Array(9)].map((c, i) => (
<tr key={i}>
<td>
<Skeleton height={18} width={80} mb="sm" />
<Skeleton height={30} />
</td>
<td>
<Skeleton height={18} />
</td>
<td>
<Group position="right">
<Skeleton height={25} width={25} />
</Group>
</td>
</tr>
));
return (
<Table verticalSpacing="sm" horizontalSpacing="xl" withBorder>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th></th>
</tr>
</thead>
<tbody>
{isLoading
? skeletonRows
: configVariables.map((element) => (
<tr key={element.key}>
<td style={{ maxWidth: "200px" }}>
<Code>{element.key}</Code> {element.secret && <TbLock />}{" "}
<br />
<Text size="xs" color="dimmed">
{element.description}
</Text>
</td>
<td>{element.value}</td>
<td>
<Group position="right">
<ActionIcon
color="primary"
variant="light"
size={25}
onClick={() =>
showUpdateConfigVariableModal(
modals,
element,
getConfigVariables
)
}
>
<TbEdit />
</ActionIcon>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
);
};
export default AdminConfigTable;

View File

@ -0,0 +1,86 @@
import { ActionIcon, Box, Group, Skeleton, Table } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { TbCheck, TbEdit, TbTrash } from "react-icons/tb";
import User from "../../types/user.type";
import showUpdateUserModal from "./showUpdateUserModal";
const ManageUserTable = ({
users,
getUsers,
deleteUser,
isLoading,
}: {
users: User[];
getUsers: () => void;
deleteUser: (user: User) => void;
isLoading: boolean;
}) => {
const modals = useModals();
return (
<Box sx={{ display: "block", overflowX: "auto", whiteSpace: "nowrap" }}>
<Table verticalSpacing="sm" highlightOnHover>
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Admin</th>
<th></th>
</tr>
</thead>
<tbody>
{isLoading
? skeletonRows
: users.map((user) => (
<tr key={user.id}>
<td>{user.username}</td>
<td>{user.email}</td>
<td>{user.isAdmin && <TbCheck />}</td>
<td>
<Group position="right">
<ActionIcon
variant="light"
color="primary"
size="sm"
onClick={() =>
showUpdateUserModal(modals, user, getUsers)
}
>
<TbEdit />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="sm"
onClick={() => deleteUser(user)}
>
<TbTrash />
</ActionIcon>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
</Box>
);
};
const skeletonRows = [...Array(10)].map((v, i) => (
<tr key={i}>
<td>
<Skeleton key={i} height={20} />
</td>
<td>
<Skeleton key={i} height={20} />
</td>
<td>
<Skeleton key={i} height={20} />
</td>
<td>
<Skeleton key={i} height={20} />
</td>
</tr>
));
export default ManageUserTable;

View File

@ -0,0 +1,88 @@
import {
Button,
Group,
PasswordInput,
Stack,
Switch,
TextInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import * as yup from "yup";
import userService from "../../services/user.service";
import toast from "../../utils/toast.util";
const showCreateUserModal = (
modals: ModalsContextProps,
getUsers: () => void
) => {
return modals.openModal({
title: <Title order={5}>Create user</Title>,
children: <Body modals={modals} getUsers={getUsers} />,
});
};
const Body = ({
modals,
getUsers,
}: {
modals: ModalsContextProps;
getUsers: () => void;
}) => {
const form = useForm({
initialValues: {
username: "",
email: "",
password: "",
isAdmin: false,
},
validate: yupResolver(
yup.object().shape({
email: yup.string().email(),
username: yup.string().min(3),
password: yup.string().min(8),
})
),
});
return (
<Stack>
<form
onSubmit={form.onSubmit(async (values) => {
console.log(values);
userService
.create(values)
.then(() => {
getUsers();
modals.closeAll();
})
.catch(toast.axiosError);
})}
>
<Stack>
<TextInput label="Username" {...form.getInputProps("username")} />
<TextInput
label="Email"
{...form.getInputProps("email")}
/>
<PasswordInput
label="New password"
{...form.getInputProps("password")}
/>
<Switch
mt="xs"
labelPosition="left"
label="Admin privileges"
{...form.getInputProps("isAdmin", { type: "checkbox" })}
/>
<Group position="right">
<Button type="submit">Create</Button>
</Group>
</Stack>
</form>
</Stack>
);
};
export default showCreateUserModal;

View File

@ -0,0 +1,96 @@
import {
Button,
Code,
NumberInput,
Select,
Space,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import configService from "../../services/config.service";
import { AdminConfig } from "../../types/config.type";
import toast from "../../utils/toast.util";
const showUpdateConfigVariableModal = (
modals: ModalsContextProps,
configVariable: AdminConfig,
getConfigVariables: () => void
) => {
return modals.openModal({
title: <Title order={5}>Update configuration variable</Title>,
children: (
<Body
configVariable={configVariable}
getConfigVariables={getConfigVariables}
/>
),
});
};
const Body = ({
configVariable,
getConfigVariables,
}: {
configVariable: AdminConfig;
getConfigVariables: () => void;
}) => {
const modals = useModals();
const form = useForm({
initialValues: {
stringValue: configVariable.value,
numberValue: parseInt(configVariable.value),
booleanValue: configVariable.value,
},
});
return (
<Stack align="stretch">
<Text>
Set <Code>{configVariable.key}</Code> to
</Text>
{configVariable.type == "string" && (
<TextInput label="Value" {...form.getInputProps("stringValue")} />
)}
{configVariable.type == "number" && (
<NumberInput label="Value" {...form.getInputProps("numberValue")} />
)}
{configVariable.type == "boolean" && (
<Select
data={[
{ value: "true", label: "True" },
{ value: "false", label: "False" },
]}
{...form.getInputProps("booleanValue")}
/>
)}
<Space />
<Button
onClick={async () => {
const value =
configVariable.type == "string"
? form.values.stringValue
: configVariable.type == "number"
? form.values.numberValue
: form.values.booleanValue == "true";
await configService
.update(configVariable.key, value)
.then(() => {
getConfigVariables();
modals.closeAll();
})
.catch(toast.axiosError);
}}
>
Save
</Button>
</Stack>
);
};
export default showUpdateConfigVariableModal;

View File

@ -0,0 +1,130 @@
import {
Accordion,
Button,
Group,
PasswordInput,
Stack,
Switch,
TextInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import * as yup from "yup";
import userService from "../../services/user.service";
import User from "../../types/user.type";
import toast from "../../utils/toast.util";
const showUpdateUserModal = (
modals: ModalsContextProps,
user: User,
getUsers: () => void
) => {
return modals.openModal({
title: <Title order={5}>Update {user.username}</Title>,
children: <Body user={user} modals={modals} getUsers={getUsers} />,
});
};
const Body = ({
user,
modals,
getUsers,
}: {
modals: ModalsContextProps;
user: User;
getUsers: () => void;
}) => {
const accountForm = useForm({
initialValues: {
username: user.username,
email: user.email,
isAdmin: user.isAdmin,
},
validate: yupResolver(
yup.object().shape({
email: yup.string().email(),
username: yup.string().min(3),
})
),
});
const passwordForm = useForm({
initialValues: {
password: "",
},
validate: yupResolver(
yup.object().shape({
password: yup.string().min(8),
})
),
});
return (
<Stack>
<form
id="accountForm"
onSubmit={accountForm.onSubmit(async (values) => {
userService
.update(user.id, values)
.then(() => {
getUsers();
modals.closeAll();
})
.catch(toast.axiosError);
})}
>
<Stack>
<TextInput
label="Username"
{...accountForm.getInputProps("username")}
/>
<TextInput
label="Email"
{...accountForm.getInputProps("email")}
/>
<Switch
mt="xs"
labelPosition="left"
label="Admin privileges"
{...accountForm.getInputProps("isAdmin", { type: "checkbox" })}
/>
</Stack>
</form>
<Accordion>
<Accordion.Item sx={{ borderBottom: "none" }} value="changePassword">
<Accordion.Control>Change password</Accordion.Control>
<Accordion.Panel>
<form
onSubmit={passwordForm.onSubmit(async (values) => {
userService
.update(user.id, {
password: values.password,
})
.then(() => toast.success("Password changed successfully"))
.catch(toast.axiosError);
})}
>
<Stack>
<PasswordInput
label="New password"
{...passwordForm.getInputProps("password")}
/>
<Button variant="light" type="submit">
Save new password
</Button>
</Stack>
</form>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group position="right">
<Button type="submit" form="accountForm">
Save
</Button>
</Group>
</Stack>
);
};
export default showUpdateUserModal;

View File

@ -0,0 +1,86 @@
import {
Anchor,
Button,
Container,
Paper,
PasswordInput,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
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 SignInForm = () => {
const config = useConfig();
const validationSchema = yup.object().shape({
emailOrUsername: yup.string().required(),
password: yup.string().min(8).required(),
});
const form = useForm({
initialValues: {
emailOrUsername: "",
password: "",
},
validate: yupResolver(validationSchema),
});
const signIn = (email: string, password: string) => {
authService
.signIn(email, password)
.then(() => window.location.replace("/"))
.catch(toast.axiosError);
};
return (
<Container size={420} my={40}>
<Title
align="center"
sx={(theme) => ({
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
fontWeight: 900,
})}
>
Welcome back
</Title>
{config.get("ALLOW_REGISTRATION") && (
<Text color="dimmed" size="sm" align="center" mt={5}>
You don't have an account yet?{" "}
<Anchor component={Link} href={"signUp"} size="sm">
{"Sign up"}
</Anchor>
</Text>
)}
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form
onSubmit={form.onSubmit((values) =>
signIn(values.emailOrUsername, values.password)
)}
>
<TextInput
label="Email or username"
placeholder="you@email.com"
{...form.getInputProps("emailOrUsername")}
/>
<PasswordInput
label="Password"
placeholder="Your password"
mt="md"
{...form.getInputProps("password")}
/>
<Button fullWidth mt="xl" type="submit">
Sign in
</Button>
</form>
</Paper>
</Container>
);
};
export default SignInForm;

View File

@ -9,23 +9,25 @@ 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 SignUpForm = () => {
const config = useConfig();
const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
const validationSchema = yup.object().shape({
email: yup.string().email().required(),
username: yup.string().min(3).required(),
password: yup.string().min(8).required(),
});
const form = useForm({
initialValues: {
email: "",
username: "",
password: "",
},
validate: yupResolver(validationSchema),
@ -34,14 +36,14 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
const signIn = (email: string, password: string) => {
authService
.signIn(email, password)
.then(() => window.location.replace("/upload"))
.catch((e) => toast.error(e.response.data.message));
.then(() => window.location.replace("/"))
.catch(toast.axiosError);
};
const signUp = (email: string, password: string) => {
const signUp = (email: string, username: string, password: string) => {
authService
.signUp(email, password)
.signUp(email, username, password)
.then(() => signIn(email, password))
.catch((e) => toast.error(e.response.data.message));
.catch(toast.axiosError);
};
return (
@ -53,33 +55,31 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
fontWeight: 900,
})}
>
{mode == "signUp" ? "Sign up" : "Welcome back"}
Sign up
</Title>
{publicRuntimeConfig.ALLOW_REGISTRATION == "true" && (
{config.get("ALLOW_REGISTRATION") && (
<Text color="dimmed" size="sm" align="center" mt={5}>
{mode == "signUp"
? "You have an account already?"
: "You don't have an account yet?"}{" "}
<Anchor
component={Link}
href={mode == "signUp" ? "signIn" : "signUp"}
size="sm"
>
{mode == "signUp" ? "Sign in" : "Sign up"}
You have an account already?{" "}
<Anchor component={Link} href={"signIn"} size="sm">
Sign in
</Anchor>
</Text>
)}
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form
onSubmit={form.onSubmit((values) =>
mode == "signIn"
? signIn(values.email, values.password)
: signUp(values.email, values.password)
signUp(values.email, values.username, values.password)
)}
>
<TextInput
label="Username"
placeholder="john.doe"
{...form.getInputProps("username")}
/>
<TextInput
label="Email"
placeholder="you@email.com"
mt="md"
{...form.getInputProps("email")}
/>
<PasswordInput
@ -89,7 +89,7 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
{...form.getInputProps("password")}
/>
<Button fullWidth mt="xl" type="submit">
{mode == "signUp" ? "Let's get started" : "Sign in"}
Let's get started
</Button>
</form>
</Paper>
@ -97,4 +97,4 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
);
};
export default AuthForm;
export default SignUpForm;

View File

@ -1,9 +1,12 @@
import { ActionIcon, Avatar, Menu } from "@mantine/core";
import Link from "next/link";
import { TbDoorExit, TbLink } from "react-icons/tb";
import { TbDoorExit, TbLink, TbSettings, TbUser } from "react-icons/tb";
import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service";
const ActionAvatar = () => {
const user = useUser();
return (
<Menu position="bottom-start" withinPortal>
<Menu.Target>
@ -19,6 +22,19 @@ const ActionAvatar = () => {
>
My shares
</Menu.Item>
<Menu.Item component={Link} href="/account" icon={<TbUser size={14} />}>
My account
</Menu.Item>
{user!.isAdmin && (
<Menu.Item
component={Link}
href="/admin"
icon={<TbSettings size={14} />}
>
Administration
</Menu.Item>
)}
<Menu.Item
onClick={async () => {
authService.signOut();

View File

@ -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("SHOW_HOME_PAGE"))
setUnauthenticatedLinks((array) => [
{
link: "/",
@ -139,7 +139,7 @@ const NavBar = () => {
...array,
]);
if (publicRuntimeConfig.ALLOW_REGISTRATION == "true")
if (config.get("ALLOW_REGISTRATION"))
setUnauthenticatedLinks((array) => [
...array,
{

View File

@ -13,23 +13,6 @@ const FileList = ({
shareId: string;
isLoading: boolean;
}) => {
const skeletonRows = [...Array(5)].map((c, i) => (
<tr key={i}>
<td>
<Skeleton height={30} width={30} />
</td>
<td>
<Skeleton height={14} />
</td>
<td>
<Skeleton height={14} />
</td>
<td>
<Skeleton height={25} width={25} />
</td>
</tr>
));
const rows = files.map((file) => (
<tr key={file.name}>
<td>{file.name}</td>
@ -69,4 +52,21 @@ const FileList = ({
);
};
const skeletonRows = [...Array(5)].map((c, i) => (
<tr key={i}>
<td>
<Skeleton height={30} width={30} />
</td>
<td>
<Skeleton height={14} />
</td>
<td>
<Skeleton height={14} />
</td>
<td>
<Skeleton height={25} width={25} />
</td>
</tr>
));
export default FileList;

View File

@ -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,27 +38,31 @@ const Dropzone = ({
isUploading: boolean;
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
}) => {
const config = useConfig();
const { classes } = useStyles();
const openRef = useRef<() => void>();
return (
<div className={classes.wrapper}>
<MantineDropzone
maxSize={parseInt(publicRuntimeConfig.MAX_FILE_SIZE!)}
// Temporary fix for Dropzone issue (https://github.com/mantinedev/mantine/issues/3115)
getFilesFromEvent={(e) => {
return Promise.resolve([
...((e.target as EventTarget & HTMLInputElement)?.files as any),
]);
}}
maxSize={parseInt(config.get("MAX_FILE_SIZE"))}
onReject={(e) => {
toast.error(e[0].errors[0].message);
}}
disabled={isUploading}
openRef={openRef as ForwardedRef<() => void>}
onDrop={(files) => {
if (files.length > 100) {
toast.error("You can't upload more than 100 files per share.");
} else {
const newFiles = files.map((file) => {
(file as FileUpload).uploadingProgress = 0;
return file as FileUpload;
});
setFiles(newFiles);
}
const newFiles = files.map((file) => {
(file as FileUpload).uploadingProgress = 0;
return file as FileUpload;
});
setFiles(newFiles);
}}
className={classes.dropzone}
radius="md"
@ -75,8 +77,7 @@ const Dropzone = ({
<Text align="center" size="sm" mt="xs" color="dimmed">
Drag&apos;n&apos;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("MAX_FILE_SIZE"))} in size.
</Text>
</div>
</MantineDropzone>

View File

@ -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;
ALLOW_UNAUTHENTICATED_SHARES: boolean;
ENABLE_EMAIL_RECIPIENTS: boolean;
},
uploadCallback: (
id: string,
expiration: string,
@ -42,7 +43,7 @@ const showCreateUploadModal = (
title: <Title order={4}>Share</Title>,
children: (
<CreateUploadModalBody
isSignedIn={isSignedIn}
options={options}
uploadCallback={uploadCallback}
/>
),
@ -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;
ALLOW_UNAUTHENTICATED_SHARES: boolean;
ENABLE_EMAIL_RECIPIENTS: boolean;
};
}) => {
const modals = useModals();
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(
publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "true"
options.ENABLE_EMAIL_RECIPIENTS
);
const validationSchema = yup.object().shape({
@ -93,7 +98,7 @@ const CreateUploadModalBody = ({
});
return (
<Group>
{showNotSignedInAlert && !isSignedIn && (
{showNotSignedInAlert && !options.isUserSignedIn && (
<Alert
withCloseButton
onClose={() => setShowNotSignedInAlert(false)}
@ -225,7 +230,7 @@ const CreateUploadModalBody = ({
{ExpirationPreview({ form })}
</Text>
<Accordion>
{publicRuntimeConfig.EMAIL_RECIPIENTS_ENABLED == "true" && (
{options.ENABLE_EMAIL_RECIPIENTS && (
<Accordion.Item value="recipients" sx={{ borderBottom: "none" }}>
<Accordion.Control>Email recipients</Accordion.Control>
<Accordion.Panel>

View File

@ -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<Config[] | null>(null);
const useConfig = () => {
const configVariables = useContext(ConfigContext) as Config[];
return {
get: (key: string) => configService.get(key, configVariables),
};
};
export default useConfig;

View File

@ -8,25 +8,33 @@ import { useColorScheme } from "@mantine/hooks";
import { ModalsProvider } from "@mantine/modals";
import { NotificationsProvider } from "@mantine/notifications";
import type { AppProps } from "next/app";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import Header from "../components/navBar/NavBar";
import useConfig, { 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";
function App({ Component, pageProps }: AppProps) {
const systemTheme = useColorScheme();
const router = useRouter();
const config = useConfig();
const [colorScheme, setColorScheme] = useState<ColorScheme>();
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<CurrentUser | null>(null);
const [configVariables, setConfigVariables] = useState<Config[] | null>(null);
const getInitalData = async () => {
setIsLoading(true);
setConfigVariables(await configService.list());
await authService.refreshAccessToken();
setUser(await userService.getCurrentUser());
setIsLoading(false);
@ -37,6 +45,16 @@ function App({ Component, pageProps }: AppProps) {
getInitalData();
}, []);
useEffect(() => {
if (
configVariables &&
configVariables.filter((variable) => variable.key)[0].value == "false" &&
!["/auth/signUp", "/admin/setup"].includes(router.asPath)
) {
router.push(!user ? "/auth/signUp" : "/admin/setup");
}
}, [router.asPath]);
useEffect(() => {
setColorScheme(systemTheme);
}, [systemTheme]);
@ -54,13 +72,15 @@ function App({ Component, pageProps }: AppProps) {
{isLoading ? (
<LoadingOverlay visible overlayOpacity={1} />
) : (
<UserContext.Provider value={user}>
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
<Header />
<Container>
<Component {...pageProps} />
</Container>
</UserContext.Provider>
<ConfigContext.Provider value={configVariables}>
<UserContext.Provider value={user}>
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
<Header />
<Container>
<Component {...pageProps} />
</Container>
</UserContext.Provider>{" "}
</ConfigContext.Provider>
)}
</GlobalLoadingContext.Provider>
</ModalsProvider>
@ -69,9 +89,4 @@ function App({ Component, pageProps }: AppProps) {
);
}
// Opts out of static site generation to use publicRuntimeConfig
App.getInitialProps = () => {
return {};
};
export default App;

View File

@ -0,0 +1,153 @@
import {
Button,
Center,
Container,
Group,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { useModals } from "@mantine/modals";
import { useRouter } from "next/router";
import * as yup from "yup";
import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service";
import userService from "../../services/user.service";
import toast from "../../utils/toast.util";
const Account = () => {
const user = useUser();
const modals = useModals();
const router = useRouter();
const accountForm = useForm({
initialValues: {
username: user?.username,
email: user?.email,
},
validate: yupResolver(
yup.object().shape({
email: yup.string().email(),
username: yup.string().min(3),
})
),
});
const passwordForm = useForm({
initialValues: {
oldPassword: "",
password: "",
},
validate: yupResolver(
yup.object().shape({
oldPassword: yup.string().min(8),
password: yup.string().min(8),
})
),
});
if (!user) {
router.push("/");
return;
}
return (
<Container size="sm">
<Title order={3} mb="xs">
My account
</Title>
<Paper withBorder p="xl">
<Title order={5} mb="xs">
Account Info
</Title>
<form
onSubmit={accountForm.onSubmit((values) =>
userService
.updateCurrentUser({
username: values.username,
email: values.email,
})
.then(() => toast.success("User updated successfully"))
.catch(toast.axiosError)
)}
>
<Stack>
<TextInput
label="Username"
{...accountForm.getInputProps("username")}
/>
<TextInput
label="Email"
{...accountForm.getInputProps("email")}
/>
<Group position="right">
<Button type="submit">Save</Button>
</Group>
</Stack>
</form>
</Paper>
<Paper withBorder p="xl" mt="lg">
<Title order={5} mb="xs">
Password
</Title>
<form
onSubmit={passwordForm.onSubmit((values) =>
authService
.updatePassword(values.oldPassword, values.password)
.then(() => {
toast.success("Password updated successfully");
passwordForm.reset();
})
.catch(toast.axiosError)
)}
>
<Stack>
<PasswordInput
label="Old password"
{...passwordForm.getInputProps("oldPassword")}
/>
<PasswordInput
label="New password"
{...passwordForm.getInputProps("password")}
/>
<Group position="right">
<Button type="submit">Save</Button>
</Group>
</Stack>
</form>
</Paper>
<Center mt={80}>
<Button
variant="light"
color="red"
onClick={() =>
modals.openConfirmModal({
title: "Account deletion",
children: (
<Text size="sm">
Do you really want to delete your account including all your
active shares?
</Text>
),
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: async () => {
await userService.removeCurrentUser();
window.location.reload();
},
})
}
>
Delete Account
</Button>
</Center>
</Container>
);
};
export default Account;

View File

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

View File

@ -0,0 +1,62 @@
import { Col, Container, createStyles, Grid, Paper, Text } from "@mantine/core";
import Link from "next/link";
import { TbSettings, TbUsers } from "react-icons/tb";
const managementOptions = [
{
title: "User management",
icon: TbUsers,
route: "/admin/users",
},
{
title: "Configuration",
icon: TbSettings,
route: "/admin/config",
},
];
const useStyles = createStyles((theme) => ({
item: {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
height: 90,
"&:hover": {
boxShadow: `${theme.shadows.sm} !important`,
transform: "scale(1.01)",
},
},
}));
const Admin = () => {
const { classes, theme } = useStyles();
return (
<Container size="xl">
<Paper withBorder radius="md" p={40}>
<Grid mt="md">
{managementOptions.map((item) => {
return (
<Col xs={6} key={item.route}>
<Paper
withBorder
component={Link}
href={item.route}
key={item.title}
className={classes.item}
>
<item.icon color={theme.colors.victoria[5]} size={35} />
<Text mt={7}>{item.title}</Text>
</Paper>
</Col>
);
})}
</Grid>
</Paper>
</Container>
);
};
export default Admin;

View File

@ -0,0 +1,50 @@
import { Button, Stack, Text, Title } from "@mantine/core";
import { useRouter } from "next/router";
import { useState } from "react";
import AdminConfigTable from "../../components/admin/AdminConfigTable";
import Logo from "../../components/Logo";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
import configService from "../../services/config.service";
const Setup = () => {
const router = useRouter();
const config = useConfig();
const user = useUser();
const [isLoading, setIsLoading] = useState(false);
if (!user) {
router.push("/auth/signUp");
return;
} else if (config.get("SETUP_FINISHED")) {
router.push("/");
return;
}
return (
<>
<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>
<AdminConfigTable />
<Button
loading={isLoading}
onClick={async () => {
setIsLoading(true);
await configService.finishSetup();
setIsLoading(false);
window.location.reload();
}}
mb={70}
mt="lg"
>
Let me in
</Button>
</Stack>
</>
);
};
export default Setup;

View File

@ -0,0 +1,73 @@
import { Button, Group, Space, Text, Title } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { useEffect, useState } from "react";
import { TbPlus } from "react-icons/tb";
import ManageUserTable from "../../components/admin/ManageUserTable";
import showCreateUserModal from "../../components/admin/showCreateUserModal";
import userService from "../../services/user.service";
import User from "../../types/user.type";
import toast from "../../utils/toast.util";
const Users = () => {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const modals = useModals();
const getUsers = () => {
setIsLoading(true);
userService.list().then((users) => {
setUsers(users);
setIsLoading(false);
});
};
const deleteUser = (user: User) => {
modals.openConfirmModal({
title: `Delete ${user.username}?`,
children: (
<Text size="sm">
Do you really want to delete <b>{user.username}</b> and all his
shares?
</Text>
),
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: async () => {
userService
.remove(user.id)
.then(() => setUsers(users.filter((v) => v.id != user.id)))
.catch(toast.axiosError);
},
});
};
useEffect(() => {
getUsers();
}, []);
return (
<>
<Group position="apart" align="baseline" mb={20}>
<Title mb={30} order={3}>
User management
</Title>
<Button
onClick={() => showCreateUserModal(modals, getUsers)}
leftIcon={<TbPlus size={20} />}
>
Create
</Button>
</Group>
<ManageUserTable
users={users}
getUsers={getUsers}
deleteUser={deleteUser}
isLoading={isLoading}
/>
<Space h="xl" />
</>
);
};
export default Users;

View File

@ -1,5 +1,5 @@
import { useRouter } from "next/router";
import AuthForm from "../../components/auth/AuthForm";
import SignInForm from "../../components/auth/SignInForm";
import Meta from "../../components/Meta";
import useUser from "../../hooks/user.hook";
@ -12,7 +12,7 @@ const SignIn = () => {
return (
<>
<Meta title="Sign In" />
<AuthForm mode="signIn" />
<SignInForm />
</>
);
}

View File

@ -1,23 +1,22 @@
import getConfig from "next/config";
import { useRouter } from "next/router";
import AuthForm from "../../components/auth/AuthForm";
import SignUpForm from "../../components/auth/SignUpForm";
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("ALLOW_REGISTRATION") == "false") {
router.replace("/auth/signIn");
} else {
return (
<>
<Meta title="Sign Up" />
<AuthForm mode="signUp" />
<SignUpForm />
</>
);
}

View File

@ -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("ALLOW_UNAUTHENTICATED_SHARES")) {
router.replace("/upload");
} else if (publicRuntimeConfig.SHOW_HOME_PAGE == "false") {
} else if (!config.get("SHOW_HOME_PAGE")) {
router.replace("/auth/signIn");
} else {
return (

View File

@ -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<FileUpload[]>([]);
const [isUploading, setisUploading] = useState(false);
@ -95,7 +95,7 @@ const Upload = () => {
}
}
}, [files]);
if (!user && publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "false") {
if (!user && !config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
router.replace("/");
} else {
return (
@ -106,7 +106,19 @@ const Upload = () => {
loading={isUploading}
disabled={files.length <= 0}
onClick={() =>
showCreateUploadModal(modals, user ? true : false, uploadFiles)
showCreateUploadModal(
modals,
{
isUserSignedIn: user ? true : false,
ALLOW_UNAUTHENTICATED_SHARES: config.get(
"ALLOW_UNAUTHENTICATED_SHARES"
),
ENABLE_EMAIL_RECIPIENTS: config.get(
"ENABLE_EMAIL_RECIPIENTS"
),
},
uploadFiles
)
}
>
Share

View File

@ -2,15 +2,22 @@ import { getCookie, setCookies } from "cookies-next";
import * as jose from "jose";
import api from "./api.service";
const signIn = async (email: string, password: string) => {
const response = await api.post("auth/signIn", { email, password });
const signIn = async (emailOrUsername: string, password: string) => {
const emailOrUsernameBody = emailOrUsername.includes("@")
? { email: emailOrUsername }
: { username: emailOrUsername };
const response = await api.post("auth/signIn", {
...emailOrUsernameBody,
password,
});
setCookies("access_token", response.data.accessToken);
setCookies("refresh_token", response.data.refreshToken);
return response;
};
const signUp = async (email: string, password: string) => {
return await api.post("auth/signUp", { email, password });
const signUp = async (email: string, username: string, password: string) => {
return await api.post("auth/signUp", { email, username, password });
};
const signOut = () => {
@ -37,9 +44,14 @@ const refreshAccessToken = async () => {
}
};
const updatePassword = async (oldPassword: string, password: string) => {
await api.patch("/auth/password", { oldPassword, password });
};
export default {
signIn,
signUp,
signOut,
refreshAccessToken,
updatePassword,
};

View File

@ -0,0 +1,43 @@
import Config, { AdminConfig } from "../types/config.type";
import api from "./api.service";
const list = async (): Promise<Config[]> => {
return (await api.get("/configs")).data;
};
const listForAdmin = async (): Promise<AdminConfig[]> => {
return (await api.get("/configs/admin")).data;
};
const update = async (
key: string,
value: string | number | boolean
): Promise<AdminConfig[]> => {
return (await api.patch(`/configs/admin/${key}`, { value })).data;
};
const get = (key: string, configVariables: Config[]): any => {
if (!configVariables) return null;
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;
};
const finishSetup = async (): Promise<AdminConfig[]> => {
return (await api.post("/configs/admin/finishSetup")).data;
};
export default {
list,
listForAdmin,
update,
get,
finishSetup,
};

View File

@ -80,7 +80,7 @@ const uploadFile = async (
const response = await api.post(`shares/${shareId}/files`, formData, {
onUploadProgress: (progressEvent) => {
const uploadingProgress = Math.round(
(100 * progressEvent.loaded) / progressEvent.total
(100 * progressEvent.loaded) / (progressEvent.total ?? 1)
);
if (uploadingProgress < 100) progressCallBack(uploadingProgress);
},

View File

@ -1,7 +1,36 @@
import { CurrentUser } from "../types/user.type";
import {
CreateUser,
CurrentUser,
UpdateCurrentUser,
UpdateUser,
} from "../types/user.type";
import api from "./api.service";
import authService from "./auth.service";
const list = async () => {
return (await api.get("/users")).data;
};
const create = async (user: CreateUser) => {
return (await api.post("/users", user)).data;
};
const update = async (id: string, user: UpdateUser) => {
return (await api.patch(`/users/${id}`, user)).data;
};
const remove = async (id: string) => {
await api.delete(`/users/${id}`);
};
const updateCurrentUser = async (user: UpdateCurrentUser) => {
return (await api.patch("/users/me", user)).data;
};
const removeCurrentUser = async () => {
await api.delete("/users/me");
};
const getCurrentUser = async (): Promise<CurrentUser | null> => {
try {
await authService.refreshAccessToken();
@ -12,5 +41,11 @@ const getCurrentUser = async (): Promise<CurrentUser | null> => {
};
export default {
list,
create,
update,
remove,
getCurrentUser,
updateCurrentUser,
removeCurrentUser,
};

View File

@ -0,0 +1,13 @@
type Config = {
key: string;
value: string;
type: string;
};
export type AdminConfig = Config & {
updatedAt: Date;
secret: boolean;
description: string;
};
export default Config;

View File

@ -1,8 +1,29 @@
export default interface User {
type User = {
id: string;
firstName?: string;
lastName?: string;
username: string;
email: string;
}
isAdmin: boolean;
};
export interface CurrentUser extends User {}
export type CreateUser = {
username: string;
email: string;
password: string;
isAdmin?: boolean;
};
export type UpdateUser = {
username?: string;
email?: string;
password?: string;
isAdmin?: boolean;
};
export type UpdateCurrentUser = {
username?: string;
email?: string;
};
export type CurrentUser = User & {};
export default User;

View File

@ -10,6 +10,9 @@ const error = (message: string) =>
message: message,
});
const axiosError = (axiosError: any) =>
error(axiosError?.response?.data?.message ?? "An unknown error occured");
const success = (message: string) =>
showNotification({
icon: <TbCheck />,
@ -22,5 +25,6 @@ const success = (message: string) =>
const toast = {
error,
success,
axiosError,
};
export default toast;