Compare commits

...

6 Commits

Author SHA1 Message Date
Elias Schneider
54f591cd60 release: 0.5.1 2023-01-04 16:02:54 +01:00
Elias Schneider
f836a0a3cd chore: add db migration 2023-01-04 15:58:15 +01:00
Elias Schneider
11174656e4 fix: email configuration updated without restart 2023-01-04 15:30:49 +01:00
Elias Schneider
faea1abcc4 feat: use cookies for authentication 2023-01-04 11:54:28 +01:00
Elias Schneider
71658ad39d feat: show version and show button if new release is available on admin page 2022-12-30 19:23:17 +01:00
Elias Schneider
167f0f8c7a chore: improve release scripts 2022-12-30 18:59:05 +01:00
24 changed files with 355 additions and 151 deletions

View File

@@ -1,3 +1,16 @@
### [0.5.1](https://github.com/stonith404/pingvin-share/compare/v0.5.0...v0.5.1) (2023-01-04)
### Features
* show version and show button if new release is available on admin page ([71658ad](https://github.com/stonith404/pingvin-share/commit/71658ad39d7e3638de659e8230fad4e05f60fdd8))
* use cookies for authentication ([faea1ab](https://github.com/stonith404/pingvin-share/commit/faea1abcc4b533f391feaed427e211fef9166fe4))
### Bug Fixes
* email configuration updated without restart ([1117465](https://github.com/stonith404/pingvin-share/commit/11174656e425c4be60e4f7b1ea8463678e5c60d2))
## [0.5.0](https://github.com/stonith404/pingvin-share/compare/v0.4.0...v0.5.0) (2022-12-30) ## [0.5.0](https://github.com/stonith404/pingvin-share/compare/v0.4.0...v0.5.0) (2022-12-30)

View File

@@ -1,12 +1,12 @@
{ {
"name": "pingvin-share-backend", "name": "pingvin-share-backend",
"version": "0.0.1", "version": "0.5.1",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pingvin-share-backend", "name": "pingvin-share-backend",
"version": "0.0.1", "version": "0.5.1",
"dependencies": { "dependencies": {
"@nestjs/common": "^9.2.1", "@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0", "@nestjs/config": "^2.2.0",
@@ -23,6 +23,7 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"content-disposition": "^0.5.4", "content-disposition": "^0.5.4",
"cookie-parser": "^1.4.6",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"moment": "^2.29.4", "moment": "^2.29.4",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
@@ -42,6 +43,7 @@
"@nestjs/schematics": "^9.0.3", "@nestjs/schematics": "^9.0.3",
"@nestjs/testing": "^9.2.1", "@nestjs/testing": "^9.2.1",
"@types/archiver": "^5.3.1", "@types/archiver": "^5.3.1",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0", "@types/cron": "^2.0.0",
"@types/express": "^4.17.14", "@types/express": "^4.17.14",
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
@@ -1151,6 +1153,15 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cookie-parser": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.3.tgz",
"integrity": "sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==",
"dev": true,
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cookiejar": { "node_modules/@types/cookiejar": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",
@@ -2635,6 +2646,26 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz",
"integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==",
"dependencies": {
"cookie": "0.4.1",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@@ -8413,6 +8444,15 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"@types/cookie-parser": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.3.tgz",
"integrity": "sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==",
"dev": true,
"requires": {
"@types/express": "*"
}
},
"@types/cookiejar": { "@types/cookiejar": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",
@@ -9570,6 +9610,22 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
}, },
"cookie-parser": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz",
"integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==",
"requires": {
"cookie": "0.4.1",
"cookie-signature": "1.0.6"
},
"dependencies": {
"cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA=="
}
}
},
"cookie-signature": { "cookie-signature": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "pingvin-share-backend", "name": "pingvin-share-backend",
"version": "0.0.1", "version": "0.5.1",
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"dev": "nest start --watch", "dev": "nest start --watch",
@@ -28,6 +28,7 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"content-disposition": "^0.5.4", "content-disposition": "^0.5.4",
"cookie-parser": "^1.4.6",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"moment": "^2.29.4", "moment": "^2.29.4",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
@@ -47,6 +48,7 @@
"@nestjs/schematics": "^9.0.3", "@nestjs/schematics": "^9.0.3",
"@nestjs/testing": "^9.2.1", "@nestjs/testing": "^9.2.1",
"@types/archiver": "^5.3.1", "@types/archiver": "^5.3.1",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0", "@types/cron": "^2.0.0",
"@types/express": "^4.17.14", "@types/express": "^4.17.14",
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",

View File

@@ -0,0 +1,23 @@
/*
Warnings:
- The primary key for the `RefreshToken` table will be changed. If it partially fails, the table could be left without primary key constraint.
- The required column `id` was added to the `RefreshToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_RefreshToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"token" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_RefreshToken" ("createdAt", "expiresAt", "token", "userId") SELECT "createdAt", "expiresAt", "token", "userId" FROM "RefreshToken";
DROP TABLE "RefreshToken";
ALTER TABLE "new_RefreshToken" RENAME TO "RefreshToken";
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@@ -27,7 +27,8 @@ model User {
} }
model RefreshToken { model RefreshToken {
token String @id @default(uuid()) id String @id @default(uuid())
token String @unique @default(uuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
expiresAt DateTime expiresAt DateTime

View File

@@ -5,10 +5,14 @@ import {
HttpCode, HttpCode,
Patch, Patch,
Post, Post,
Req,
Res,
UnauthorizedException,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler"; import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { Request, Response } from "express";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthTotpService } from "./authTotp.service"; import { AuthTotpService } from "./authTotp.service";
@@ -17,7 +21,6 @@ import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto";
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
import { EnableTotpDTO } from "./dto/enableTotp.dto"; import { EnableTotpDTO } from "./dto/enableTotp.dto";
import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto";
import { UpdatePasswordDTO } from "./dto/updatePassword.dto"; import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
import { VerifyTotpDTO } from "./dto/verifyTotp.dto"; import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
import { JwtGuard } from "./guard/jwt.guard"; import { JwtGuard } from "./guard/jwt.guard";
@@ -32,24 +35,59 @@ export class AuthController {
@Throttle(10, 5 * 60) @Throttle(10, 5 * 60)
@Post("signUp") @Post("signUp")
async signUp(@Body() dto: AuthRegisterDTO) { async signUp(
@Body() dto: AuthRegisterDTO,
@Res({ passthrough: true }) response: Response
) {
if (!this.config.get("ALLOW_REGISTRATION")) if (!this.config.get("ALLOW_REGISTRATION"))
throw new ForbiddenException("Registration is not allowed"); throw new ForbiddenException("Registration is not allowed");
return this.authService.signUp(dto); const result = await this.authService.signUp(dto);
response = this.addTokensToResponse(
response,
result.accessToken,
result.refreshToken
);
return result;
} }
@Throttle(10, 5 * 60) @Throttle(10, 5 * 60)
@Post("signIn") @Post("signIn")
@HttpCode(200) @HttpCode(200)
signIn(@Body() dto: AuthSignInDTO) { async signIn(
return this.authService.signIn(dto); @Body() dto: AuthSignInDTO,
@Res({ passthrough: true }) response: Response
) {
const result = await this.authService.signIn(dto);
if (result.accessToken && result.refreshToken) {
response = this.addTokensToResponse(
response,
result.accessToken,
result.refreshToken
);
}
return result;
} }
@Throttle(10, 5 * 60) @Throttle(10, 5 * 60)
@Post("signIn/totp") @Post("signIn/totp")
@HttpCode(200) @HttpCode(200)
signInTotp(@Body() dto: AuthSignInTotpDTO) { async signInTotp(
return this.authTotpService.signInTotp(dto); @Body() dto: AuthSignInTotpDTO,
@Res({ passthrough: true }) response: Response
) {
const result = await this.authTotpService.signInTotp(dto);
response = this.addTokensToResponse(
response,
result.accessToken,
result.refreshToken
);
return result;
} }
@Patch("password") @Patch("password")
@@ -60,13 +98,33 @@ export class AuthController {
@Post("token") @Post("token")
@HttpCode(200) @HttpCode(200)
async refreshAccessToken(@Body() body: RefreshAccessTokenDTO) { async refreshAccessToken(
@Req() request: Request,
@Res({ passthrough: true }) response: Response
) {
if (!request.cookies.refresh_token) throw new UnauthorizedException();
const accessToken = await this.authService.refreshAccessToken( const accessToken = await this.authService.refreshAccessToken(
body.refreshToken request.cookies.refresh_token
); );
response.cookie("access_token", accessToken, { httpOnly: true });
return { accessToken }; return { accessToken };
} }
@Post("signOut")
async signOut(
@Req() request: Request,
@Res({ passthrough: true }) response: Response
) {
await this.authService.signOut(request.cookies.access_token);
response.cookie("access_token", "accessToken", { maxAge: -1 });
response.cookie("refresh_token", "", {
path: "/api/auth/token",
httpOnly: true,
maxAge: -1,
});
}
@Post("totp/enable") @Post("totp/enable")
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
async enableTotp(@GetUser() user: User, @Body() body: EnableTotpDTO) { async enableTotp(@GetUser() user: User, @Body() body: EnableTotpDTO) {
@@ -85,4 +143,19 @@ export class AuthController {
// Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code // Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code
return this.authTotpService.disableTotp(user, body.password, body.code); return this.authTotpService.disableTotp(user, body.password, body.code);
} }
private addTokensToResponse(
response: Response,
accessToken: string,
refreshToken: string
) {
response.cookie("access_token", accessToken);
response.cookie("refresh_token", refreshToken, {
path: "/api/auth/token",
httpOnly: true,
maxAge: 60 * 60 * 24 * 30 * 3,
});
return response;
}
} }

View File

@@ -34,8 +34,10 @@ export class AuthService {
}, },
}); });
const accessToken = await this.createAccessToken(user); const { refreshToken, refreshTokenId } = await this.createRefreshToken(
const refreshToken = await this.createRefreshToken(user.id); user.id
);
const accessToken = await this.createAccessToken(user, refreshTokenId);
return { accessToken, refreshToken }; return { accessToken, refreshToken };
} catch (e) { } catch (e) {
@@ -71,8 +73,10 @@ export class AuthService {
return { loginToken }; return { loginToken };
} }
const accessToken = await this.createAccessToken(user); const { refreshToken, refreshTokenId } = await this.createRefreshToken(
const refreshToken = await this.createRefreshToken(user.id); user.id
);
const accessToken = await this.createAccessToken(user, refreshTokenId);
return { accessToken, refreshToken }; return { accessToken, refreshToken };
} }
@@ -89,11 +93,12 @@ export class AuthService {
}); });
} }
async createAccessToken(user: User) { async createAccessToken(user: User, refreshTokenId: string) {
return this.jwtService.sign( return this.jwtService.sign(
{ {
sub: user.id, sub: user.id,
email: user.email, email: user.email,
refreshTokenId,
}, },
{ {
expiresIn: "15min", expiresIn: "15min",
@@ -102,6 +107,14 @@ export class AuthService {
); );
} }
async signOut(accessToken: string) {
const { refreshTokenId } = this.jwtService.decode(accessToken) as {
refreshTokenId: string;
};
await this.prisma.refreshToken.delete({ where: { id: refreshTokenId } });
}
async refreshAccessToken(refreshToken: string) { async refreshAccessToken(refreshToken: string) {
const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({ const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({
where: { token: refreshToken }, where: { token: refreshToken },
@@ -111,17 +124,18 @@ export class AuthService {
if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date()) if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date())
throw new UnauthorizedException(); throw new UnauthorizedException();
return this.createAccessToken(refreshTokenMetaData.user); return this.createAccessToken(
refreshTokenMetaData.user,
refreshTokenMetaData.id
);
} }
async createRefreshToken(userId: string) { async createRefreshToken(userId: string) {
const refreshToken = ( const { id, token } = await this.prisma.refreshToken.create({
await this.prisma.refreshToken.create({ data: { userId, expiresAt: moment().add(3, "months").toDate() },
data: { userId, expiresAt: moment().add(3, "months").toDate() }, });
})
).token;
return refreshToken; return { refreshTokenId: id, refreshToken: token };
} }
async createLoginToken(userId: string) { async createLoginToken(userId: string) {

View File

@@ -71,8 +71,12 @@ export class AuthTotpService {
data: { used: true }, data: { used: true },
}); });
const accessToken = await this.authService.createAccessToken(user); const { refreshToken, refreshTokenId } =
const refreshToken = await this.authService.createRefreshToken(user.id); await this.authService.createRefreshToken(user.id);
const accessToken = await this.authService.createAccessToken(
user,
refreshTokenId
);
return { accessToken, refreshToken }; return { accessToken, refreshToken };
} }

View File

@@ -1,6 +0,0 @@
import { IsNotEmpty } from "class-validator";
export class RefreshAccessTokenDTO {
@IsNotEmpty()
refreshToken: string;
}

View File

@@ -1,7 +1,8 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport"; import { PassportStrategy } from "@nestjs/passport";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { ExtractJwt, Strategy } from "passport-jwt"; import { Request } from "express";
import { Strategy } from "passport-jwt";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
@@ -10,11 +11,16 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService, private prisma: PrismaService) { constructor(config: ConfigService, private prisma: PrismaService) {
config.get("JWT_SECRET"); config.get("JWT_SECRET");
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: JwtStrategy.extractJWT,
secretOrKey: config.get("JWT_SECRET"), secretOrKey: config.get("JWT_SECRET"),
}); });
} }
private static extractJWT(req: Request) {
if (!req.cookies.access_token) return null;
return req.cookies.access_token;
}
async validate(payload: { sub: string }) { async validate(payload: { sub: string }) {
const user: User = await this.prisma.user.findUnique({ const user: User = await this.prisma.user.findUnique({
where: { id: payload.sub }, where: { id: payload.sub },

View File

@@ -7,15 +7,17 @@ import { ConfigService } from "src/config/config.service";
export class EmailService { export class EmailService {
constructor(private config: ConfigService) {} constructor(private config: ConfigService) {}
transporter = nodemailer.createTransport({ getTransporter() {
host: this.config.get("SMTP_HOST"), return nodemailer.createTransport({
port: parseInt(this.config.get("SMTP_PORT")), host: this.config.get("SMTP_HOST"),
secure: parseInt(this.config.get("SMTP_PORT")) == 465, port: parseInt(this.config.get("SMTP_PORT")),
auth: { secure: parseInt(this.config.get("SMTP_PORT")) == 465,
user: this.config.get("SMTP_USERNAME"), auth: {
pass: this.config.get("SMTP_PASSWORD"), user: this.config.get("SMTP_USERNAME"),
}, pass: this.config.get("SMTP_PASSWORD"),
}); },
});
}
async sendMail(recipientEmail: string, shareId: string, creator: User) { async sendMail(recipientEmail: string, shareId: string, creator: User) {
if (!this.config.get("ENABLE_EMAIL_RECIPIENTS")) if (!this.config.get("ENABLE_EMAIL_RECIPIENTS"))
@@ -23,7 +25,7 @@ export class EmailService {
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`; const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
await this.transporter.sendMail({ await this.getTransporter().sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail, to: recipientEmail,
subject: this.config.get("EMAIL_SUBJECT"), subject: this.config.get("EMAIL_SUBJECT"),
@@ -36,7 +38,7 @@ export class EmailService {
} }
async sendTestMail(recipientEmail: string) { async sendTestMail(recipientEmail: string) {
await this.transporter.sendMail({ await this.getTransporter().sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail, to: recipientEmail,
subject: "Test email", subject: "Test email",

View File

@@ -1,6 +1,7 @@
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common"; import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
import { NestFactory, Reflector } from "@nestjs/core"; import { NestFactory, Reflector } from "@nestjs/core";
import { NestExpressApplication } from "@nestjs/platform-express"; import { NestExpressApplication } from "@nestjs/platform-express";
import * as cookieParser from "cookie-parser";
import * as fs from "fs"; import * as fs from "fs";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";
@@ -9,6 +10,7 @@ async function bootstrap() {
app.useGlobalPipes(new ValidationPipe({ whitelist: true })); app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
app.use(cookieParser());
app.set("trust proxy", true); app.set("trust proxy", true);
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true }); await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });

View File

@@ -1,6 +1,6 @@
{ {
"info": { "info": {
"_postman_id": "84a95987-2997-429a-aba6-d38289b0b76a", "_postman_id": "4b16228d-41ef-4c6b-8a0b-294a30a4cfc2",
"name": "Pingvin Share Testing", "name": "Pingvin Share Testing",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "17822132" "_exporter_id": "17822132"
@@ -18,12 +18,12 @@
"exec": [ "exec": [
"if(pm.response.to.have.status(201)){", "if(pm.response.to.have.status(201)){",
" const token = pm.response.json()[\"accessToken\"]", " const token = pm.response.json()[\"accessToken\"]",
" pm.collectionVariables.set(\"USER_AUTH_TOKEN\", token)",
"",
" // Get user id", " // Get user id",
" const jwtPayload = JSON.parse(atob(token.split('.')[1]));", " const jwtPayload = JSON.parse(atob(token.split('.')[1]));",
" const userId = jwtPayload[\"sub\"]", " const userId = jwtPayload[\"sub\"]",
" pm.collectionVariables.set(\"USER_ID\", userId)", " pm.collectionVariables.set(\"USER_ID\", userId)",
"",
" pm.collectionVariables.set(\"COOKIES\", pm.response.headers.get(\"Set-Cookie\"))",
"}", "}",
"" ""
], ],
@@ -80,6 +80,7 @@
" pm.expect(responseBody).to.have.property(\"accessToken\")", " pm.expect(responseBody).to.have.property(\"accessToken\")",
" pm.expect(responseBody).to.have.property(\"refreshToken\")", " pm.expect(responseBody).to.have.property(\"refreshToken\")",
"});", "});",
"",
"" ""
], ],
"type": "text/javascript" "type": "text/javascript"
@@ -97,7 +98,7 @@
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"email\": \"system2@test.org\",\n \"username\": \"system.test2\",\n \"password\": \"N44HcHgeuAvfCT\"\n}", "raw": "{\n \"email\": \"system2@test.org\",\n \"username\": \"system2.test\",\n \"password\": \"N44HcHgeuAvfCT\"\n}",
"options": { "options": {
"raw": { "raw": {
"language": "json" "language": "json"
@@ -1556,23 +1557,13 @@
] ]
} }
], ],
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{USER_AUTH_TOKEN}}",
"type": "string"
}
]
},
"event": [ "event": [
{ {
"listen": "prerequest", "listen": "prerequest",
"script": { "script": {
"type": "text/javascript", "type": "text/javascript",
"exec": [ "exec": [
"" "pm.request.addHeader(\"Cookie\", pm.collectionVariables.get(\"COOKIES\"))"
] ]
} }
}, },

View File

@@ -1,8 +1,14 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const { version } = require('./package.json');
const withPWA = require("next-pwa")({ const withPWA = require("next-pwa")({
dest: "public", dest: "public",
disable: process.env.NODE_ENV == "development", disable: process.env.NODE_ENV == "development",
}); });
module.exports = withPWA({ output: "standalone" }); module.exports = withPWA({
output: "standalone", env: {
VERSION: version,
},
});

View File

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

View File

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

View File

@@ -36,8 +36,8 @@ const AdminConfigInput = ({
(configVariable.obscured ? ( (configVariable.obscured ? (
<PasswordInput <PasswordInput
style={{ width: "100%" }} style={{ width: "100%" }}
onChange={(e) => onValueChange(configVariable, e.target.value)}
{...form.getInputProps("stringValue")} {...form.getInputProps("stringValue")}
onChange={(e) => onValueChange(configVariable, e.target.value)}
/> />
) : ( ) : (
<TextInput <TextInput

View File

@@ -37,7 +37,7 @@ const ActionAvatar = () => {
<Menu.Item <Menu.Item
onClick={async () => { onClick={async () => {
authService.signOut(); await authService.signOut();
}} }}
icon={<TbDoorExit size={14} />} icon={<TbDoorExit size={14} />}
> >

View File

@@ -28,7 +28,6 @@ function App({ Component, pageProps }: AppProps) {
const systemTheme = useColorScheme(); const systemTheme = useColorScheme();
const router = useRouter(); const router = useRouter();
const preferences = usePreferences(); const preferences = usePreferences();
const [colorScheme, setColorScheme] = useState<ColorScheme>("light"); const [colorScheme, setColorScheme] = useState<ColorScheme>("light");
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<CurrentUser | null>(null); const [user, setUser] = useState<CurrentUser | null>(null);
@@ -89,7 +88,7 @@ function App({ Component, pageProps }: AppProps) {
<Container> <Container>
<Component {...pageProps} /> <Component {...pageProps} />
</Container> </Container>
</UserContext.Provider>{" "} </UserContext.Provider>
</ConfigContext.Provider> </ConfigContext.Provider>
)} )}
</GlobalLoadingContext.Provider> </GlobalLoadingContext.Provider>

View File

@@ -1,19 +1,17 @@
import { Col, createStyles, Grid, Paper, Text } from "@mantine/core"; import {
Center,
Col,
createStyles,
Grid,
Paper,
Stack,
Text,
Title,
} from "@mantine/core";
import Link from "next/link"; import Link from "next/link";
import { TbSettings, TbUsers } from "react-icons/tb"; import { useEffect, useState } from "react";
import { TbRefresh, TbSettings, TbUsers } from "react-icons/tb";
const managementOptions = [ import configService from "../../services/config.service";
{
title: "User management",
icon: TbUsers,
route: "/admin/users",
},
{
title: "Configuration",
icon: TbSettings,
route: "/admin/config",
},
];
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
item: { item: {
@@ -33,27 +31,69 @@ const useStyles = createStyles((theme) => ({
const Admin = () => { const Admin = () => {
const { classes, theme } = useStyles(); const { classes, theme } = useStyles();
const [managementOptions, setManagementOptions] = useState([
{
title: "User management",
icon: TbUsers,
route: "/admin/users",
},
{
title: "Configuration",
icon: TbSettings,
route: "/admin/config",
},
]);
useEffect(() => {
configService.isNewReleaseAvailable().then((isNewReleaseAvailable) => {
if (isNewReleaseAvailable) {
setManagementOptions([
...managementOptions,
{
title: "Update",
icon: TbRefresh,
route:
"https://github.com/stonith404/pingvin-share/releases/tag/v0.5.0",
},
]);
}
});
}, []);
return ( return (
<Paper withBorder p={40}> <>
<Grid mt="md"> <Title mb={30} order={3}>
{managementOptions.map((item) => { Administration
return ( </Title>
<Col xs={6} key={item.route}> <Stack justify="space-between" style={{ height: "calc(100vh - 180px)" }}>
<Paper <Paper withBorder p={40}>
withBorder <Grid>
component={Link} {managementOptions.map((item) => {
href={item.route} return (
key={item.title} <Col xs={6} key={item.route}>
className={classes.item} <Paper
> withBorder
<item.icon color={theme.colors.victoria[8]} size={35} /> component={Link}
<Text mt={7}>{item.title}</Text> href={item.route}
</Paper> key={item.title}
</Col> className={classes.item}
); >
})} <item.icon color={theme.colors.victoria[8]} size={35} />
</Grid> <Text mt={7}>{item.title}</Text>
</Paper> </Paper>
</Col>
);
})}
</Grid>
</Paper>
<Center>
<Text size="xs" color="dimmed">
Version {process.env.VERSION}
</Text>
</Center>
</Stack>
</>
); );
}; };

View File

@@ -1,20 +1,7 @@
import axios, { AxiosError } from "axios"; import axios from "axios";
import { getCookie } from "cookies-next";
const api = axios.create({ const api = axios.create({
baseURL: "/api", baseURL: "/api",
}); });
api.interceptors.request.use(
(config) => {
const accessToken = getCookie("access_token");
if (accessToken) {
config!.headers!.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error: AxiosError) => {
return Promise.reject(error);
}
);
export default api; export default api;

View File

@@ -1,4 +1,4 @@
import { getCookie, setCookie } from "cookies-next"; import { getCookie } from "cookies-next";
import * as jose from "jose"; import * as jose from "jose";
import api from "./api.service"; import api from "./api.service";
@@ -12,11 +12,6 @@ const signIn = async (emailOrUsername: string, password: string) => {
password, password,
}); });
setCookie("access_token", response.data.accessToken);
setCookie("refresh_token", response.data.refreshToken, {
maxAge: 60 * 60 * 24 * 30 * 3,
});
return response; return response;
}; };
@@ -37,45 +32,30 @@ const signInTotp = async (
loginToken, loginToken,
}); });
setCookie("access_token", response.data.accessToken);
setCookie("refresh_token", response.data.refreshToken, {
maxAge: 60 * 60 * 24 * 30 * 3,
});
return response; return response;
}; };
const signUp = async (email: string, username: string, password: string) => { const signUp = async (email: string, username: string, password: string) => {
const response = await api.post("auth/signUp", { email, username, password }); const response = await api.post("auth/signUp", { email, username, password });
setCookie("access_token", response.data.accessToken);
setCookie("refresh_token", response.data.refreshToken, {
maxAge: 60 * 60 * 24 * 30 * 3,
});
return response; return response;
}; };
const signOut = () => { const signOut = async () => {
setCookie("access_token", null); await api.post("/auth/signOut");
setCookie("refresh_token", null);
window.location.reload(); window.location.reload();
}; };
const refreshAccessToken = async () => { const refreshAccessToken = async () => {
try { try {
const accessToken = getCookie("access_token") as string; const accessToken = getCookie("access_token") as string;
const refreshToken = getCookie("refresh_token");
if ( if (
(accessToken && !accessToken ||
(jose.decodeJwt(accessToken).exp ?? 0) * 1000 < (jose.decodeJwt(accessToken).exp ?? 0) * 1000 < Date.now() + 2 * 60 * 1000
Date.now() + 2 * 60 * 1000) ||
(refreshToken && !accessToken)
) { ) {
const response = await api.post("auth/token", { refreshToken }); await api.post("/auth/token");
setCookie("access_token", response.data.accessToken);
} }
} catch { } catch (e) {
console.info("Refresh token invalid or expired"); console.info("Refresh token invalid or expired");
} }
}; };

View File

@@ -1,3 +1,4 @@
import axios from "axios";
import Config, { AdminConfig, UpdateConfig } from "../types/config.type"; import Config, { AdminConfig, UpdateConfig } from "../types/config.type";
import api from "./api.service"; import api from "./api.service";
@@ -36,6 +37,15 @@ const sendTestEmail = async (email: string) => {
await api.post("/configs/admin/testEmail", { email }); await api.post("/configs/admin/testEmail", { email });
}; };
const isNewReleaseAvailable = async () => {
const response = (
await axios.get(
"https://api.github.com/repos/stonith404/pingvin-share/releases/latest"
)
).data;
return response.tag_name.replace("v", "") != process.env.VERSION;
};
export default { export default {
list, list,
listForAdmin, listForAdmin,
@@ -43,4 +53,5 @@ export default {
get, get,
finishSetup, finishSetup,
sendTestEmail, sendTestEmail,
isNewReleaseAvailable,
}; };

View File

@@ -1,12 +1,12 @@
{ {
"name": "pingvin-share", "name": "pingvin-share",
"version": "0.5.0", "version": "0.5.1",
"scripts": { "scripts": {
"format": "cd frontend && npm run format && cd ../backend && npm run format", "format": "cd frontend && npm run format && cd ../backend && npm run format",
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint", "lint": "cd frontend && npm run lint && cd ../backend && npm run lint",
"version": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s && git add CHANGELOG.md", "version": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s && git add CHANGELOG.md",
"release:patch": "npm version patch -m 'release: %s' && git push && git push --tags", "release:patch": "cd backend && npm version patch --commit-hooks false && cd ../frontend && npm version patch --commit-hooks false && cd .. && git add . && npm version patch --force -m 'release: %s' && git push && git push --tags",
"release:minor": "npm version minor -m 'release: %s' && git push && git push --tags", "release:minor": "cd backend && npm version minor --commit-hooks false && cd ../frontend && npm version minor --commit-hooks false && cd .. && git add . && npm version minor --force -m 'release: %s' && git push && git push --tags",
"deploy:dev": "docker buildx build --push --tag stonith404/pingvin-share:development --platform linux/amd64,linux/arm64 ." "deploy:dev": "docker buildx build --push --tag stonith404/pingvin-share:development --platform linux/amd64,linux/arm64 ."
} }
} }