Compare commits

..

50 Commits

Author SHA1 Message Date
Elias Schneider
3c74cc14df release: 0.3.3 2022-12-08 23:22:59 +01:00
Elias Schneider
a165f8ec4d refactor: remove console log 2022-12-08 23:22:15 +01:00
Elias Schneider
d6a88f2a22 performance: reduce docker image size 2022-12-08 23:21:31 +01:00
Elias Schneider
b8172efd59 fix: allow empty strings in config variable 2022-12-08 23:21:16 +01:00
Elias Schneider
cbe37c6798 fix: obscured text length 2022-12-08 23:12:25 +01:00
Elias Schneider
a545c44426 fix: improve admin dashboard color and layout 2022-12-08 22:43:14 +01:00
Elias Schneider
08a2f60f72 chore: add migration for v0.3.3 2022-12-08 21:58:58 +01:00
Elias Schneider
907e56af0f fix: space character in email 2022-12-08 20:04:56 +01:00
Elias Schneider
888a0c5faf feat: add support for different email and user 2022-12-08 20:00:04 +01:00
Elias Schneider
bfb0d151ea fix: obscure critical config variables 2022-12-08 19:14:06 +01:00
Elias Schneider
1f63f22591 docs: add review to README 2022-12-08 17:30:12 +01:00
Elias Schneider
a2d5e0f72c test: fix system tests not await backend start 2022-12-07 13:44:02 +01:00
Elias Schneider
c0d0f6fa90 release: 0.3.2 2022-12-07 12:41:14 +01:00
Elias Schneider
4a016ed57d fix: unauthenticated dialog not shown 2022-12-06 11:05:04 +01:00
Elias Schneider
5ea63fb60b fix: use session storage for share token 2022-12-06 10:54:17 +01:00
Elias Schneider
57cb683c64 fix: make share password optional 2022-12-05 23:58:18 +01:00
Elias Schneider
783b8c2e91 release: 0.3.1 2022-12-05 22:09:53 +01:00
Elias Schneider
75f57a4e57 fix: dropzone rejection on chrome 2022-12-05 22:09:41 +01:00
Elias Schneider
eb142b75f7 ci/cd: remove .env step 2022-12-05 18:23:19 +01:00
Elias Schneider
90a3c69954 release: 0.3.0 2022-12-05 18:18:41 +01:00
Elias Schneider
50887b000d Merge pull request #27 from stonith404/feat/administrator-page
Feat/administrator page
2022-12-05 18:12:57 +01:00
Elias Schneider
e2527de976 refactor: remove type email 2022-12-05 18:09:18 +01:00
Elias Schneider
b5c7b04fcb chore: upgrade dependencies 2022-12-05 17:27:19 +01:00
Elias Schneider
38f493ac5a refactor: run formatter 2022-12-05 16:54:15 +01:00
Elias Schneider
0499548dd3 refactor: convert config variables to upper case 2022-12-05 16:53:52 +01:00
Elias Schneider
d4a0f1a4f1 fix: unable to update user privileges 2022-12-05 16:17:41 +01:00
Elias Schneider
c795b988df fix: share password validation 2022-12-05 16:04:10 +01:00
Elias Schneider
7a3967fd6f feat: add user management 2022-12-05 15:53:24 +01:00
Elias Schneider
31b3f6cb2f feat: add user operations to backend 2022-12-05 10:02:19 +01:00
Elias Schneider
e9526fc039 fix: database migration by adding a username 2022-12-02 23:00:24 +01:00
Elias Schneider
6b0b979414 docs: updated README for new version 2022-12-02 20:33:17 +01:00
Elias Schneider
176196bc35 refactor: remove providers and controllers from app module 2022-12-02 20:17:28 +01:00
Elias Schneider
e958a83b87 fix: docker build 2022-12-02 15:10:49 +01:00
Elias Schneider
63368557c1 refactor: remove .env variables 2022-12-02 14:45:42 +01:00
Elias Schneider
1dbfe0bbc9 fix: convert async function to sync function 2022-12-02 14:43:52 +01:00
Elias Schneider
b649d8bf8e feat: add job that deleted temporary files 2022-12-01 23:21:12 +01:00
Elias Schneider
b579b8f330 feat: add setup wizard 2022-12-01 23:07:49 +01:00
Elias Schneider
493705e4ef feat: add add new config strategy to frontend 2022-11-28 17:50:36 +01:00
Elias Schneider
1b5e53ff7e feat: add new config strategy to backend 2022-11-28 15:04:32 +01:00
Elias Schneider
13f98cc32c feat: add administrator guard 2022-11-14 17:03:45 +01:00
Elias Schneider
29b4a825d1 test: add email recepients to request body 2022-11-13 23:38:04 +01:00
Elias Schneider
53c7457697 release: 0.2.0 2022-11-13 23:27:51 +01:00
Elias Schneider
1abc0f7ef3 chore: migrate database for release 2022-11-13 23:24:59 +01:00
Elias Schneider
2c3760e064 chore: add smtp environment variables to docker compose 2022-11-13 23:08:51 +01:00
Elias Schneider
32eaee4236 fix: email sending when not signed in 2022-11-13 23:08:25 +01:00
Elias Schneider
99492c2ecc docs: add SMTP variables to readme 2022-11-13 22:39:04 +01:00
Elias Schneider
34db3ae2a9 fix: hide and disallow email recipients if disabled 2022-11-11 19:03:08 +01:00
Elias Schneider
32ad43ae27 feat: add email recepients functionality 2022-11-11 15:12:16 +01:00
Elias Schneider
0efd2d8bf9 fix: add public userDTO to prevent confusion 2022-11-10 13:50:52 +01:00
Elias Schneider
43299522ee chore: upgrade to Next.js 13 2022-10-31 11:20:54 +01:00
93 changed files with 4505 additions and 1976 deletions

View File

@@ -1,11 +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

View File

@@ -17,9 +17,6 @@ jobs:
- name: Install Dependencies
working-directory: ./backend
run: npm install
- name: Create .env file
working-directory: ./backend
run: mv .env.example .env
- name: Run Server and Test with Newman
working-directory: ./backend
run: npm run test:system

View File

@@ -1,3 +1,71 @@
### [0.3.3](https://github.com/stonith404/pingvin-share/compare/v0.3.2...v0.3.3) (2022-12-08)
### Features
* add support for different email and user ([888a0c5](https://github.com/stonith404/pingvin-share/commit/888a0c5fafc51b6872ed71e37d4b40c9bf6a07f1))
### Bug Fixes
* allow empty strings in config variable ([b8172ef](https://github.com/stonith404/pingvin-share/commit/b8172efd59fb3271ab9b818b13a7003342b2cebd))
* improve admin dashboard color and layout ([a545c44](https://github.com/stonith404/pingvin-share/commit/a545c444261c90105dcb165ebcf4b26634e729ca))
* obscure critical config variables ([bfb0d15](https://github.com/stonith404/pingvin-share/commit/bfb0d151ea2ba125e536a16b1873e143a67e9f64))
* obscured text length ([cbe37c6](https://github.com/stonith404/pingvin-share/commit/cbe37c679853ecef1522ed213e4cac5defd5b45a))
* space character in email ([907e56a](https://github.com/stonith404/pingvin-share/commit/907e56af0faccdbc8d7f5ab3418a4ad71ff849f5))
### [0.3.2](https://github.com/stonith404/pingvin-share/compare/v0.3.1...v0.3.2) (2022-12-07)
### Bug Fixes
* make share password optional ([57cb683](https://github.com/stonith404/pingvin-share/commit/57cb683c64eaedec2697ea6863948bd2ae68dd75))
* unauthenticated dialog not shown ([4a016ed](https://github.com/stonith404/pingvin-share/commit/4a016ed57db526ee900c567f7b7f0991f948c631))
* use session storage for share token ([5ea63fb](https://github.com/stonith404/pingvin-share/commit/5ea63fb60be0c508c38ba228cc8ac6dd7b403aac))
### [0.3.1](https://github.com/stonith404/pingvin-share/compare/v0.3.0...v0.3.1) (2022-12-05)
### Bug Fixes
* dropzone rejection on chrome ([75f57a4](https://github.com/stonith404/pingvin-share/commit/75f57a4e57fb13cc62e87428e8302b453ea6b44b))
## [0.3.0](https://github.com/stonith404/pingvin-share/compare/v0.2.0...v0.3.0) (2022-12-05)
### Features
* add add new config strategy to frontend ([493705e](https://github.com/stonith404/pingvin-share/commit/493705e4ef21cb638620b0037b9ff2cec8046c95))
* add administrator guard ([13f98cc](https://github.com/stonith404/pingvin-share/commit/13f98cc32c804c786c71b10dc4cf029d7795be76))
* add job that deleted temporary files ([b649d8b](https://github.com/stonith404/pingvin-share/commit/b649d8bf8e849aff3f350e3c5fd0151a063b9706))
* add new config strategy to backend ([1b5e53f](https://github.com/stonith404/pingvin-share/commit/1b5e53ff7ee00228eda6dc5c62d5cd8c3752b03b))
* add setup wizard ([b579b8f](https://github.com/stonith404/pingvin-share/commit/b579b8f3309e2d7070e6a82c5da76ab8029bee11))
* add user management ([7a3967f](https://github.com/stonith404/pingvin-share/commit/7a3967fd6f76a03461d05e962e82fe5130528ca5))
* add user operations to backend ([31b3f6c](https://github.com/stonith404/pingvin-share/commit/31b3f6cb2fc662623df92cdbaf803f1b98a696ae))
### Bug Fixes
* convert async function to sync function ([1dbfe0b](https://github.com/stonith404/pingvin-share/commit/1dbfe0bbc9821bbee02220484c87cf9fe12fd033))
* database migration by adding a username ([e9526fc](https://github.com/stonith404/pingvin-share/commit/e9526fc0390cc8ba70c824370041ea9aaf6f9ef9))
* docker build ([e958a83](https://github.com/stonith404/pingvin-share/commit/e958a83b87a452e42fb38c12d4b11d71b2323c2d))
* share password validation ([c795b98](https://github.com/stonith404/pingvin-share/commit/c795b988df437c85efb91e0f6f8ec782f38dbe3d))
* unable to update user privileges ([d4a0f1a](https://github.com/stonith404/pingvin-share/commit/d4a0f1a4f16b7980fb244a4e582ceeb9bfaff877))
## [0.2.0](https://github.com/stonith404/pingvin-share/compare/v0.1.1...v0.2.0) (2022-11-13)
### Features
* add email recepients functionality ([32ad43a](https://github.com/stonith404/pingvin-share/commit/32ad43ae27a29b946bfba0040cac7eb158c84553))
### Bug Fixes
* add public userDTO to prevent confusion ([0efd2d8](https://github.com/stonith404/pingvin-share/commit/0efd2d8bf96506cf7d7dc2dc3164a8d59009cec7))
* email sending when not signed in ([32eaee4](https://github.com/stonith404/pingvin-share/commit/32eaee42363250defa92913c738a2702ba3e2693))
* hide and disallow email recipients if disabled ([34db3ae](https://github.com/stonith404/pingvin-share/commit/34db3ae2a997498edaa70404807d0e770dad6edb))
### [0.1.1](https://github.com/stonith404/pingvin-share/compare/v0.1.0...v0.1.1) (2022-10-31)

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

@@ -1,11 +1,12 @@
FROM node:18-alpine AS frontend-builder
FROM node:18-slim AS frontend-builder
WORKDIR /opt/app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY ./frontend .
RUN npm run build
FROM node:18 AS backend-builder
FROM node:18-slim AS backend-builder
RUN apt-get update && apt-get install -y openssl
WORKDIR /opt/app
COPY backend/package.json backend/package-lock.json ./
RUN npm ci
@@ -13,9 +14,10 @@ COPY ./backend .
RUN npx prisma generate
RUN npm run build
FROM node:18 AS runner
WORKDIR /opt/app/frontend
FROM node:18-slim AS runner
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y openssl
WORKDIR /opt/app/frontend
COPY --from=frontend-builder /opt/app/next.config.js .
COPY --from=frontend-builder /opt/app/public ./public
COPY --from=frontend-builder /opt/app/.next ./.next
@@ -26,9 +28,7 @@ COPY --from=backend-builder /opt/app/node_modules ./node_modules
COPY --from=backend-builder /opt/app/dist ./dist
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

@@ -2,47 +2,35 @@
Pingvin Share is self-hosted file sharing platform and an alternative for WeTransfer.
## 🎪 Showcase
Demo: https://pingvin-share.dev.eliasschneider.com
<img src="https://user-images.githubusercontent.com/58886915/167101708-b85032ad-f5b1-480a-b8d7-ec0096ea2a43.png" width="700"/>
## ✨ 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
- Optionally secure your share with a visitor limit and a password
- Email recepients
- Light & dark mode
## 🐧 Get to know Pingvin Share
- [Demo](https://pingvin-share.dev.eliasschneider.com)
- [Review by DB Tech](https://www.youtube.com/watch?v=rWwNeZCOPJA)
<img src="https://user-images.githubusercontent.com/58886915/167101708-b85032ad-f5b1-480a-b8d7-ec0096ea2a43.png" width="700"/>
## ⌨️ Setup
> 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 |
### 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,8 +0,0 @@
# CONFIGURATION
APP_URL=http://localhost:3000
ALLOW_REGISTRATION=true
MAX_FILE_SIZE=5000000000
ALLOW_UNAUTHENTICATED_SHARES=false
# SECURITY
JWT_SECRET=random-string

File diff suppressed because it is too large Load Diff

View File

@@ -3,64 +3,69 @@
"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 & wait-on http://localhost:8080/api/configs && 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",
"mime-types": "^2.1.35",
"moment": "^2.29.4",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.8.0",
"passport": "^0.6.0",
"passport-jwt": "^4.0.0",
"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",
"wait-on": "^6.0.1"
}
}

View File

@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "ShareRecipient" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"shareId" TEXT NOT NULL,
CONSTRAINT "ShareRecipient_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "Share" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Share" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"uploadLocked" BOOLEAN NOT NULL DEFAULT false,
"isZipReady" BOOLEAN NOT NULL DEFAULT false,
"views" INTEGER NOT NULL DEFAULT 0,
"expiration" DATETIME NOT NULL,
"creatorId" TEXT,
CONSTRAINT "Share_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Share" ("createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views") SELECT "createdAt", "creatorId", "expiration", "id", "isZipReady", "uploadLocked", "views" FROM "Share";
DROP TABLE "Share";
ALTER TABLE "new_Share" RENAME TO "Share";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

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

@@ -0,0 +1,17 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Config" (
"updatedAt" DATETIME NOT NULL,
"key" TEXT NOT NULL PRIMARY KEY,
"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
);
INSERT INTO "new_Config" ("description", "key", "locked", "secret", "type", "updatedAt", "value") SELECT "description", "key", "locked", "secret", "type", "updatedAt", "value" FROM "Config";
DROP TABLE "Config";
ALTER TABLE "new_Config" RENAME TO "Config";
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[]
@@ -40,10 +40,19 @@ model Share {
views Int @default(0)
expiration DateTime
creatorId String?
creator User? @relation(fields: [creatorId], references: [id])
security ShareSecurity?
files File[]
creatorId String?
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
security ShareSecurity?
recipients ShareRecipient[]
files File[]
}
model ShareRecipient {
id String @id @default(uuid())
email String
shareId String
share Share @relation(fields: [shareId], references: [id], onDelete: Cascade)
}
model File {
@@ -67,3 +76,15 @@ 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
obscured Boolean @default(false)
secret Boolean @default(true)
locked Boolean @default(false)
}

View File

@@ -0,0 +1,151 @@
import { Prisma, PrismaClient } from "@prisma/client";
import * as crypto from "crypto";
const configVariables: Prisma.ConfigCreateInput[] = [
{
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, user 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 which the emails get sent from",
type: "string",
value: "",
},
{
key: "SMTP_USERNAME",
description: "Username of the SMTP server",
type: "string",
value: "",
},
{
key: "SMTP_PASSWORD",
description: "Password of the SMTP server",
type: "string",
value: "",
obscured: true,
},
];
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,
});
}
}
const configVariablesFromDatabase = await prisma.config.findMany();
// Delete the config variable if it doesn't exist anymore
for (const configVariableFromDatabase of configVariablesFromDatabase) {
const configVariable = configVariables.find(
(v) => v.key == configVariableFromDatabase.key
);
if (!configVariable) {
await prisma.config.delete({
where: { key: configVariableFromDatabase.key },
});
// Update the config variable if the metadata changed
} else if (
JSON.stringify({
...configVariable,
key: configVariableFromDatabase.key,
value: configVariableFromDatabase.value,
}) != JSON.stringify(configVariableFromDatabase)
) {
await prisma.config.update({
where: { key: configVariableFromDatabase.key },
data: {
...configVariable,
key: configVariableFromDatabase.key,
value: configVariableFromDatabase.value,
},
});
}
}
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});

View File

@@ -1,40 +1,51 @@
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 { UserModule } from "./user/user.module";
@Module({
imports: [
AuthModule,
ShareModule,
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,28 @@
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;
@Expose()
obscured: boolean;
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,9 @@
import { IsNotEmpty, ValidateIf } from "class-validator";
class UpdateConfigDTO {
@IsNotEmpty()
@ValidateIf((dto) => dto.value !== "")
value: string | number | boolean;
}
export default UpdateConfigDTO;

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { EmailService } from "./email.service";
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}

View File

@@ -0,0 +1,34 @@
import { Injectable, InternalServerErrorException } from "@nestjs/common";
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) {}
async sendMail(recipientEmail: string, shareId: string, creator: User) {
// 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_USERNAME"),
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}`;
await transporter.sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail,
subject: "Files shared with you",
text: `Hey!\n${creator.username} shared some files with you. View or dowload the files with this link: ${shareUrl}.\nShared 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

@@ -1,11 +1,17 @@
import { Type } from "class-transformer";
import { IsString, Length, Matches, ValidateNested } from "class-validator";
import {
IsEmail,
IsString,
Length,
Matches,
ValidateNested,
} from "class-validator";
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;
@@ -13,6 +19,9 @@ export class CreateShareDTO {
@IsString()
expiration: string;
@IsEmail({}, { each: true })
recipients: string[];
@ValidateNested()
@Type(() => ShareSecurityDTO)
security: ShareSecurityDTO;

View File

@@ -8,6 +8,9 @@ export class MyShareDTO extends ShareDTO {
@Expose()
createdAt: Date;
@Expose()
recipients: string[];
from(partial: Partial<MyShareDTO>) {
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
}

View File

@@ -1,6 +1,6 @@
import { Expose, plainToClass, Type } from "class-transformer";
import { AuthSignInDTO } from "src/auth/dto/authSignIn.dto";
import { FileDTO } from "src/file/dto/file.dto";
import { PublicUserDTO } from "src/user/dto/publicUser.dto";
export class ShareDTO {
@Expose()
@@ -14,8 +14,8 @@ export class ShareDTO {
files: FileDTO[];
@Expose()
@Type(() => AuthSignInDTO)
creator: AuthSignInDTO;
@Type(() => PublicUserDTO)
creator: PublicUserDTO;
from(partial: Partial<ShareDTO>) {
return plainToClass(ShareDTO, partial, { excludeExtraneousValues: true });

View File

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

View File

@@ -1,11 +1,12 @@
import { forwardRef, Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { EmailModule } from "src/email/email.module";
import { FileModule } from "src/file/file.module";
import { ShareController } from "./share.controller";
import { ShareService } from "./share.service";
@Module({
imports: [JwtModule.register({}), forwardRef(() => FileModule)],
imports: [JwtModule.register({}), EmailModule, forwardRef(() => FileModule)],
controllers: [ShareController],
providers: [ShareService],
exports: [ShareService],

View File

@@ -4,13 +4,14 @@ 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";
import { CreateShareDTO } from "./dto/createShare.dto";
@@ -20,6 +21,7 @@ export class ShareService {
constructor(
private prisma: PrismaService,
private fileService: FileService,
private emailService: EmailService,
private config: ConfigService,
private jwtService: JwtService
) {}
@@ -36,7 +38,7 @@ export class ShareService {
}
// We have to add an exception for "never" (since moment won't like that)
let expirationDate;
let expirationDate: Date;
if (share.expiration !== "never") {
expirationDate = moment()
.add(
@@ -60,6 +62,11 @@ export class ShareService {
expiration: expirationDate,
creator: { connect: user ? { id: user.id } : undefined },
security: { create: share.security },
recipients: {
create: share.recipients
? share.recipients.map((email) => ({ email }))
: [],
},
},
});
}
@@ -84,21 +91,33 @@ export class ShareService {
}
async complete(id: string) {
const share = await this.prisma.share.findUnique({
where: { id },
include: { files: true, recipients: true, creator: true },
});
if (await this.isShareCompleted(id))
throw new BadRequestException("Share already completed");
const moreThanOneFileInShare =
(await this.prisma.file.findMany({ where: { shareId: id } })).length != 0;
if (!moreThanOneFileInShare)
if (share.files.length == 0)
throw new BadRequestException(
"You need at least on file in your share to complete it."
);
// Asynchronously create a zip of all files
this.createZip(id).then(() =>
this.prisma.share.update({ where: { id }, data: { isZipReady: true } })
);
// Send email for each recepient
for (const recepient of share.recipients) {
await this.emailService.sendMail(
recepient.email,
share.id,
share.creator
);
}
return await this.prisma.share.update({
where: { id },
data: { uploadLocked: true },
@@ -106,7 +125,7 @@ export class ShareService {
}
async getSharesByUser(userId: string) {
return await this.prisma.share.findMany({
const shares = await this.prisma.share.findMany({
where: {
creator: { id: userId },
uploadLocked: true,
@@ -119,7 +138,17 @@ export class ShareService {
orderBy: {
expiration: "desc",
},
include: { recipients: true },
});
const sharesWithEmailRecipients = shares.map((share) => {
return {
...share,
recipients: share.recipients.map((recipients) => recipients.email),
};
});
return sharesWithEmailRecipients;
}
async get(id: string) {

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

@@ -0,0 +1,4 @@
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"
@@ -444,7 +444,7 @@
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"id\": \"test-share\",\n \"expiration\": \"1-day\",\n \"security\": {\n \"password\": \"share-password\",\n \"maxViews\": 1\n }\n}",
"raw": "{\n \"id\": \"test-share\",\n \"expiration\": \"1-day\",\n \"recipients\": [],\n \"security\": {\n \"password\": \"share-password\",\n \"maxViews\": 1\n }\n}",
"options": {
"raw": {
"language": "json"

View File

@@ -5,12 +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}
volumes:
- "${PWD}/data:/opt/app/backend/data"

View File

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

View File

@@ -1,13 +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
}
}
const withPWA = require("next-pwa")({
dest: "public",
@@ -15,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.2",
"@mantine/dropzone": "^5.9.2",
"@mantine/form": "^5.9.2",
"@mantine/hooks": "^5.9.2",
"@mantine/modals": "^5.9.2",
"@mantine/next": "^5.9.2",
"@mantine/notifications": "^5.9.2",
"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": "^12.3.1",
"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.0.0",
"react-dom": "18.0.0",
"react-icons": "^4.4.0",
"react": "^18.2.0",
"react-dom": "^18.2.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": "12.1.5",
"@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,96 @@
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((configVariable) => (
<tr key={configVariable.key}>
<td style={{ maxWidth: "200px" }}>
<Code>{configVariable.key}</Code>{" "}
{configVariable.secret && <TbLock />} <br />
<Text size="xs" color="dimmed">
{configVariable.description}
</Text>
</td>
<td>
{configVariable.obscured
? "•".repeat(configVariable.value.length)
: configVariable.value}
</td>
<td>
<Group position="right">
<ActionIcon
color="primary"
variant="light"
size={25}
onClick={() =>
showUpdateConfigVariableModal(
modals,
configVariable,
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,84 @@
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) => {
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,100 @@
import {
Button,
Code,
NumberInput,
PasswordInput,
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" &&
(configVariable.obscured ? (
<PasswordInput label="Value" {...form.getInputProps("stringValue")} />
) : (
<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,127 @@
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 { NextLink } from "@mantine/next";
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={NextLink}
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 { NextLink } from "@mantine/next";
import { TbDoorExit, TbLink } from "react-icons/tb";
import Link from "next/link";
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>
@@ -13,12 +16,25 @@ const ActionAvatar = () => {
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
component={NextLink}
component={Link}
href="/account/shares"
icon={<TbLink size={14} />}
>
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,18 +11,16 @@ import {
Transition,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { NextLink } from "@mantine/next";
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 Link = {
type NavLink = {
link?: string;
label?: string;
component?: ReactNode;
@@ -110,6 +108,8 @@ const useStyles = createStyles((theme) => ({
const NavBar = () => {
const user = useUser();
const config = useConfig();
const [opened, toggleOpened] = useDisclosure(false);
const authenticatedLinks = [
@@ -122,7 +122,7 @@ const NavBar = () => {
},
];
const [unauthenticatedLinks, setUnauthenticatedLinks] = useState<Link[]>([
const [unauthenticatedLinks, setUnauthenticatedLinks] = useState<NavLink[]>([
{
link: "/auth/signIn",
label: "Sign in",
@@ -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,
{
@@ -161,7 +161,7 @@ const NavBar = () => {
);
}
return (
<NextLink
<Link
key={link.label}
href={link.link ?? ""}
onClick={() => toggleOpened.toggle()}
@@ -170,7 +170,7 @@ const NavBar = () => {
})}
>
{link.label}
</NextLink>
</Link>
);
})}
</>
@@ -178,12 +178,12 @@ const NavBar = () => {
return (
<Header height={HEADER_HEIGHT} mb={40} className={classes.root}>
<Container className={classes.header}>
<NextLink href="/">
<Link href="/" passHref>
<Group>
<Logo height={35} width={35} />
<Text weight={600}>Pingvin Share</Text>
</Group>
</NextLink>
</Link>
<Group spacing={5} className={classes.links}>
<Group>{items} </Group>
</Group>

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,25 @@ 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!)}
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 +71,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

@@ -10,14 +10,11 @@ import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import moment from "moment";
import getConfig from "next/config";
import { useRouter } from "next/router";
import { TbCopy } from "react-icons/tb";
import { Share } from "../../../types/share.type";
import toast from "../../../utils/toast.util";
const { publicRuntimeConfig } = getConfig();
const showCompletedUploadModal = (modals: ModalsContextProps, share: Share) => {
return modals.openModal({
closeOnClickOutside: false,

View File

@@ -6,6 +6,7 @@ import {
Col,
Grid,
Group,
MultiSelect,
NumberInput,
PasswordInput,
Select,
@@ -17,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";
@@ -25,14 +25,17 @@ import shareService from "../../../services/share.service";
import { ShareSecurity } from "../../../types/share.type";
import ExpirationPreview from "../ExpirationPreview";
const { publicRuntimeConfig } = getConfig();
const showCreateUploadModal = (
modals: ModalsContextProps,
isSignedIn: boolean,
options: {
isUserSignedIn: boolean;
allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean;
},
uploadCallback: (
id: string,
expiration: string,
recipients: string[],
security: ShareSecurity
) => void
) => {
@@ -40,7 +43,7 @@ const showCreateUploadModal = (
title: <Title order={4}>Share</Title>,
children: (
<CreateUploadModalBody
isSignedIn={isSignedIn}
options={options}
uploadCallback={uploadCallback}
/>
),
@@ -49,20 +52,23 @@ const showCreateUploadModal = (
const CreateUploadModalBody = ({
uploadCallback,
isSignedIn,
options,
}: {
uploadCallback: (
id: string,
expiration: string,
recipients: string[],
security: ShareSecurity
) => void;
isSignedIn: boolean;
options: {
isUserSignedIn: boolean;
allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean;
};
}) => {
const modals = useModals();
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(
publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "true"
);
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(true);
const validationSchema = yup.object().shape({
link: yup
@@ -79,7 +85,7 @@ const CreateUploadModalBody = ({
const form = useForm({
initialValues: {
link: "",
recipients: [] as string[],
password: undefined,
maxViews: undefined,
expiration_num: 1,
@@ -90,7 +96,7 @@ const CreateUploadModalBody = ({
});
return (
<Group>
{showNotSignedInAlert && !isSignedIn && (
{showNotSignedInAlert && !options.isUserSignedIn && (
<Alert
withCloseButton
onClose={() => setShowNotSignedInAlert(false)}
@@ -110,7 +116,7 @@ const CreateUploadModalBody = ({
const expiration = form.values.never_expires
? "never"
: form.values.expiration_num + form.values.expiration_unit;
uploadCallback(values.link, expiration, {
uploadCallback(values.link, expiration, values.recipients, {
password: values.password,
maxViews: values.maxViews,
});
@@ -211,7 +217,6 @@ const CreateUploadModalBody = ({
label="Never Expires"
{...form.getInputProps("never_expires")}
/>
{/* Preview expiration date text */}
<Text
italic
@@ -222,8 +227,37 @@ const CreateUploadModalBody = ({
>
{ExpirationPreview({ form })}
</Text>
<Accordion>
{options.enableEmailRecepients && (
<Accordion.Item value="recipients" sx={{ borderBottom: "none" }}>
<Accordion.Control>Email recipients</Accordion.Control>
<Accordion.Panel>
<MultiSelect
data={form.values.recipients}
placeholder="Enter email recipients"
searchable
{...form.getInputProps("recipients")}
creatable
getCreateLabel={(query) => `+ ${query}`}
onCreate={(query) => {
if (!query.match(/^\S+@\S+\.\S+$/)) {
form.setFieldError(
"recipients",
"Invalid email address"
);
} else {
form.setFieldError("recipients", null);
form.setFieldValue("recipients", [
...form.values.recipients,
query,
]);
return query;
}
}}
/>
</Accordion.Panel>
</Accordion.Item>
)}
<Accordion.Item value="security" sx={{ borderBottom: "none" }}>
<Accordion.Control>Security options</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,7 +8,7 @@ import {
Group,
} from "@mantine/core";
import Meta from "../components/Meta";
import { NextLink } from "@mantine/next";
import Link from "next/link";
const useStyles = createStyles((theme) => ({
root: {
@@ -53,7 +53,7 @@ const ErrorNotFound = () => {
className={classes.description}
></Text>
<Group position="center">
<Button component={NextLink} href="/" variant="light">
<Button component={Link} href="/" variant="light">
Bring me back
</Button>
</Group>

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

@@ -12,9 +12,8 @@ import {
} from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals";
import { NextLink } from "@mantine/next";
import moment from "moment";
import getConfig from "next/config";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { TbLink, TbTrash } from "react-icons/tb";
@@ -24,8 +23,6 @@ import shareService from "../../services/share.service";
import { MyShare } from "../../types/share.type";
import toast from "../../utils/toast.util";
const { publicRuntimeConfig } = getConfig();
const MyShares = () => {
const modals = useModals();
const clipboard = useClipboard();
@@ -54,7 +51,7 @@ const MyShares = () => {
<Title order={3}>It's empty here 👀</Title>
<Text>You don't have any shares.</Text>
<Space h={5} />
<Button component={NextLink} href="/upload" variant="light">
<Button component={Link} href="/upload" variant="light">
Create one
</Button>
</Stack>

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,60 @@
import { Col, 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 (
<Paper withBorder 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[8]} size={35} />
<Text mt={7}>{item.title}</Text>
</Paper>
</Col>
);
})}
</Grid>
</Paper>
);
};
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 { NextLink } from "@mantine/next";
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 (
@@ -126,7 +125,7 @@ export default function Home() {
<Group mt={30}>
<Button
component={NextLink}
component={Link}
href="/auth/signUp"
radius="xl"
size="md"
@@ -135,7 +134,7 @@ export default function Home() {
Get started
</Button>
<Button
component={NextLink}
component={Link}
href="https://github.com/stonith404/pingvin-share"
target="_blank"
variant="default"

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,12 +22,14 @@ const Upload = () => {
const modals = useModals();
const user = useUser();
const config = useConfig();
const [files, setFiles] = useState<FileUpload[]>([]);
const [isUploading, setisUploading] = useState(false);
const uploadFiles = async (
id: string,
expiration: string,
recipients: string[],
security: ShareSecurity
) => {
setisUploading(true);
@@ -39,7 +40,7 @@ const Upload = () => {
return file;
})
);
share = await shareService.create(id, expiration, security);
share = await shareService.create(id, expiration, recipients, security);
for (let i = 0; i < files.length; i++) {
const progressCallBack = (progress: number) => {
setFiles((files) => {
@@ -94,7 +95,7 @@ const Upload = () => {
}
}
}, [files]);
if (!user && publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "false") {
if (!user && !config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
router.replace("/");
} else {
return (
@@ -104,9 +105,19 @@ const Upload = () => {
<Button
loading={isUploading}
disabled={files.length <= 0}
onClick={() =>
showCreateUploadModal(modals, user ? true : false, uploadFiles)
}
onClick={() => {
showCreateUploadModal(
modals,
{
isUserSignedIn: user ? true : false,
allowUnauthenticatedShares: config.get(
"ALLOW_UNAUTHENTICATED_SHARES"
),
enableEmailRecepients: config.get("ENABLE_EMAIL_RECIPIENTS"),
},
uploadFiles
);
}}
>
Share
</Button>

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

@@ -9,9 +9,11 @@ import api from "./api.service";
const create = async (
id: string,
expiration: string,
recipients: string[],
security?: ShareSecurity
) => {
return (await api.post("shares", { id, expiration, security })).data;
return (await api.post("shares", { id, expiration, recipients, security }))
.data;
};
const completeShare = async (id: string) => {
@@ -19,7 +21,7 @@ const completeShare = async (id: string) => {
};
const get = async (id: string): Promise<Share> => {
const shareToken = localStorage.getItem(`share_${id}_token`);
const shareToken = sessionStorage.getItem(`share_${id}_token`);
return (
await api.get(`shares/${id}`, {
headers: { "X-Share-Token": shareToken ?? "" },
@@ -28,7 +30,7 @@ const get = async (id: string): Promise<Share> => {
};
const getMetaData = async (id: string): Promise<ShareMetaData> => {
const shareToken = localStorage.getItem(`share_${id}_token`);
const shareToken = sessionStorage.getItem(`share_${id}_token`);
return (
await api.get(`shares/${id}/metaData`, {
headers: { "X-Share-Token": shareToken ?? "" },
@@ -47,7 +49,7 @@ const getMyShares = async (): Promise<MyShare[]> => {
const getShareToken = async (id: string, password?: string) => {
const { token } = (await api.post(`/shares/${id}/token`, { password })).data;
localStorage.setItem(`share_${id}_token`, token);
sessionStorage.setItem(`share_${id}_token`, token);
};
const isShareIdAvailable = async (id: string): Promise<boolean> => {
@@ -55,7 +57,7 @@ const isShareIdAvailable = async (id: string): Promise<boolean> => {
};
const getFileDownloadUrl = async (shareId: string, fileId: string) => {
const shareToken = localStorage.getItem(`share_${shareId}_token`);
const shareToken = sessionStorage.getItem(`share_${shareId}_token`);
return (
await api.get(`shares/${shareId}/files/${fileId}/download`, {
headers: { "X-Share-Token": shareToken ?? "" },
@@ -78,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

@@ -3,7 +3,7 @@ import { Global } from "@mantine/core";
const GlobalStyle = () => {
return (
<Global
styles={(theme) => ({
styles={() => ({
a: {
color: "inherit",
textDecoration: "none",

View File

@@ -0,0 +1,14 @@
type Config = {
key: string;
value: string;
type: string;
};
export type AdminConfig = Config & {
updatedAt: Date;
secret: boolean;
description: string;
obscured: boolean;
};
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;

View File

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