run prettier

This commit is contained in:
StarAppeal
2025-09-25 02:40:25 +02:00
parent e935ba66f3
commit 6872f57bca
50 changed files with 750 additions and 721 deletions
+2 -2
View File
@@ -38,7 +38,7 @@ function isValidUrl(u: string): boolean {
}
}
const NODE_ENV = (optionalString("NODE_ENV", process.env.NODE_ENV, "development") as NodeEnv);
const NODE_ENV = optionalString("NODE_ENV", process.env.NODE_ENV, "development") as NodeEnv;
const PORT = optionalNumber("PORT", process.env.PORT, 3000);
const FRONTEND_URL = required("FRONTEND_URL", process.env.FRONTEND_URL);
@@ -46,7 +46,7 @@ if (!isValidUrl(FRONTEND_URL)) {
throw new Error("FRONTEND_URL must be a valid URL");
}
export const config :BaseConfig = {
export const config: BaseConfig = {
env: NODE_ENV,
port: PORT,
cors: {
+56 -44
View File
@@ -1,6 +1,6 @@
import "dotenv/config";
import mongoose, {Schema, Document} from "mongoose";
import {PasswordUtils} from "../../utils/passwordUtils";
import mongoose, { Schema, Document } from "mongoose";
import { PasswordUtils } from "../../utils/passwordUtils";
export interface IUser extends Document {
name: string;
@@ -14,10 +14,10 @@ export interface IUser extends Document {
}
export interface CreateUserPayload {
name: string,
password: string,
uuid: string,
config: UserConfig,
name: string;
password: string;
uuid: string;
config: UserConfig;
timezone: string;
location: string;
}
@@ -30,12 +30,12 @@ export interface UserConfig {
export interface MatrixState {
global: {
mode: 'image' | 'text' | "idle" | "music" | "clock";
mode: "image" | "text" | "idle" | "music" | "clock";
brightness: number;
};
text: {
text: string;
align: 'left' | 'center' | 'right';
align: "left" | "center" | "right";
speed: number;
size: number;
color: [number, number, number];
@@ -58,71 +58,83 @@ export interface SpotifyConfig {
scope: string;
}
const matrixStateSchema = new Schema({
const matrixStateSchema = new Schema(
{
global: {
mode: {type: String, enum: ['image', 'text', 'idle', 'music', 'clock'], default: 'idle'},
brightness: {type: Number, min: 0, max: 100, default: 50},
mode: { type: String, enum: ["image", "text", "idle", "music", "clock"], default: "idle" },
brightness: { type: Number, min: 0, max: 100, default: 50 },
},
text: {
text: {type: String, default: ""},
align: {type: String, enum: ['left', 'center', 'right'], default: 'center'},
speed: {type: Number, min: 0, max: 10, default: 3},
size: {type: Number, min: 1, max: 64, default: 12},
text: { type: String, default: "" },
align: { type: String, enum: ["left", "center", "right"], default: "center" },
speed: { type: Number, min: 0, max: 10, default: 3 },
size: { type: Number, min: 1, max: 64, default: 12 },
color: {
type: [Number],
validate: {
validator: (v: number[]) =>
Array.isArray(v) && v.length === 3 && v.every(n => Number.isInteger(n) && n >= 0 && n <= 255),
Array.isArray(v) && v.length === 3 && v.every((n) => Number.isInteger(n) && n >= 0 && n <= 255),
message: "color must be an array of three integers between 0 and 255",
},
default: [255, 255, 255],
},
},
image: {
image: {type: String, default: ""},
image: { type: String, default: "" },
},
clock: {
color: {
type: [Number],
validate: {
validator: (v: number[]) =>
Array.isArray(v) && v.length === 3 && v.every(n => Number.isInteger(n) && n >= 0 && n <= 255),
Array.isArray(v) && v.length === 3 && v.every((n) => Number.isInteger(n) && n >= 0 && n <= 255),
message: "color must be an array of three integers between 0 and 255",
},
default: [255, 255, 255],
},
},
music: {
fullscreen: {type: Boolean, default: false},
fullscreen: { type: Boolean, default: false },
},
}, {_id: false});
},
{ _id: false }
);
const spotifyConfigSchema = new Schema({
accessToken: {type: String},
refreshToken: {type: String},
expirationDate: {type: Date},
scope: {type: String},
}, {_id: false});
const spotifyConfigSchema = new Schema(
{
accessToken: { type: String },
refreshToken: { type: String },
expirationDate: { type: Date },
scope: { type: String },
},
{ _id: false }
);
const userConfigSchema = new Schema({
isVisible: {type: Boolean, required: true},
canBeModified: {type: Boolean, required: true},
isAdmin: {type: Boolean, required: true},
}, {_id: false});
const userConfigSchema = new Schema(
{
isVisible: { type: Boolean, required: true },
canBeModified: { type: Boolean, required: true },
isAdmin: { type: Boolean, required: true },
},
{ _id: false }
);
const userSchema = new Schema({
name: {type: String, required: true, index: true},
password: {type: String, required: true, select: false},
uuid: {type: String, required: true, unique: true, index: true},
config: {type: userConfigSchema, required: true},
lastState: {type: matrixStateSchema},
spotifyConfig: {type: spotifyConfigSchema},
timezone: {type: String, required: true},
location: {type: String, required: true},
}, {
const userSchema = new Schema(
{
name: { type: String, required: true, index: true },
password: { type: String, required: true, select: false },
uuid: { type: String, required: true, unique: true, index: true },
config: { type: userConfigSchema, required: true },
lastState: { type: matrixStateSchema },
spotifyConfig: { type: spotifyConfigSchema },
timezone: { type: String, required: true },
location: { type: String, required: true },
},
{
optimisticConcurrency: true,
timestamps: true,
});
}
);
userSchema.virtual("id").get(function (this: any) {
return this._id?.toHexString?.() ?? this._id;
@@ -136,7 +148,7 @@ async function hashIfNeeded(next: Function, user: any) {
if (!user.isModified?.("password")) return next();
if (isBcryptHash(user.password)) return next();
try {
user.password = await PasswordUtils.hashPassword(user.password)
user.password = await PasswordUtils.hashPassword(user.password);
return next();
} catch (e) {
return next(e);
@@ -165,4 +177,4 @@ userSchema.pre("findOneAndUpdate", async function (next) {
}
});
export const UserModel = mongoose.model<IUser>('User', userSchema);
export const UserModel = mongoose.model<IUser>("User", userSchema);
+5 -5
View File
@@ -1,11 +1,11 @@
import { UserModel } from './user';
import {appEventBus, USER_UPDATED_EVENT} from "../../utils/eventBus";
import { UserModel } from "./user";
import { appEventBus, USER_UPDATED_EVENT } from "../../utils/eventBus";
export function watchUserChanges() {
const changeStream = UserModel.watch([], { fullDocument: 'updateLookup' });
const changeStream = UserModel.watch([], { fullDocument: "updateLookup" });
changeStream.on('change', (change: any) => {
if (change.operationType === 'update' && change.fullDocument) {
changeStream.on("change", (change: any) => {
if (change.operationType === "update" && change.fullDocument) {
const updatedUser = change.fullDocument;
appEventBus.emit(USER_UPDATED_EVENT, updatedUser);
+24 -24
View File
@@ -1,13 +1,13 @@
import {Server} from "./server";
import {config as baseConfig} from "./config/config";
import {S3Service} from "./services/s3Service";
import {UserService} from "./services/db/UserService";
import {SpotifyTokenService} from "./services/spotifyTokenService";
import {connectToDatabase} from "./services/db/database.service";
import {SpotifyApiService} from "./services/spotifyApiService";
import {SpotifyPollingService} from "./services/spotifyPollingService";
import {WeatherPollingService} from "./services/weatherPollingService";
import {JwtAuthenticator} from "./utils/jwtAuthenticator";
import { Server } from "./server";
import { config as baseConfig } from "./config/config";
import { S3Service } from "./services/s3Service";
import { UserService } from "./services/db/UserService";
import { SpotifyTokenService } from "./services/spotifyTokenService";
import { connectToDatabase } from "./services/db/database.service";
import { SpotifyApiService } from "./services/spotifyApiService";
import { SpotifyPollingService } from "./services/spotifyPollingService";
import { WeatherPollingService } from "./services/weatherPollingService";
import { JwtAuthenticator } from "./utils/jwtAuthenticator";
async function bootstrap() {
const {
@@ -20,7 +20,7 @@ async function bootstrap() {
MINIO_ROOT_USER,
MINIO_ROOT_PASSWORD,
DB_NAME,
DB_CONN_STRING
DB_CONN_STRING,
} = process.env;
if (!SECRET_KEY || SECRET_KEY.length < 32) {
@@ -54,22 +54,19 @@ async function bootstrap() {
port: parseInt(MINIO_PORT),
accessKey: MINIO_ROOT_USER,
secretAccessKey: MINIO_ROOT_PASSWORD,
bucket: MINIO_BUCKET_NAME
bucket: MINIO_BUCKET_NAME,
};
const dbConfig = {
dbName: DB_NAME,
dbConnString: DB_CONN_STRING
}
dbConnString: DB_CONN_STRING,
};
await connectToDatabase(dbConfig.dbName, dbConfig.dbConnString);
const s3Service = S3Service.getInstance(s3ClientConfig);
const userService = await UserService.create();
const spotifyTokenService = new SpotifyTokenService(
SPOTIFY_CLIENT_ID!,
SPOTIFY_CLIENT_SECRET!
);
const spotifyTokenService = new SpotifyTokenService(SPOTIFY_CLIENT_ID!, SPOTIFY_CLIENT_SECRET!);
const spotifyApiService = new SpotifyApiService();
const spotifyPollingService = new SpotifyPollingService(userService, spotifyApiService, spotifyTokenService);
@@ -77,24 +74,27 @@ async function bootstrap() {
const jwtAuthenticator = new JwtAuthenticator(SECRET_KEY);
const server = new Server({
const server = new Server(
{
port: baseConfig.port,
jwtSecret: SECRET_KEY,
cors: baseConfig.cors,
}, {
},
{
s3Service,
userService,
spotifyTokenService,
spotifyPollingService,
weatherPollingService,
jwtAuthenticator
});
jwtAuthenticator,
}
);
await server.start();
}
if (process.env.NODE_ENV !== 'test') {
bootstrap().catch(error => {
if (process.env.NODE_ENV !== "test") {
bootstrap().catch((error) => {
console.error("Fatal error during server startup:", error.message);
process.exit(1);
});
+3 -3
View File
@@ -1,6 +1,6 @@
import {WebSocket} from "ws";
import {DecodedToken} from "./decodedToken";
import {IUser} from "../db/models/user";
import { WebSocket } from "ws";
import { DecodedToken } from "./decodedToken";
import { IUser } from "../db/models/user";
export interface ExtendedWebSocket extends WebSocket {
payload: DecodedToken;
+34 -31
View File
@@ -1,12 +1,12 @@
import express from "express";
import {CreateUserPayload} from "../db/models/user";
import {JwtAuthenticator} from "../utils/jwtAuthenticator";
import { CreateUserPayload } from "../db/models/user";
import { JwtAuthenticator } from "../utils/jwtAuthenticator";
import crypto from "crypto";
import {PasswordUtils} from "../utils/passwordUtils";
import {asyncHandler} from "./middleware/asyncHandler";
import {validateBody, v} from "./middleware/validate";
import {ok, badRequest, unauthorized, created, conflict, notFound} from "./utils/responses";
import {UserService} from "../services/db/UserService";
import { PasswordUtils } from "../utils/passwordUtils";
import { asyncHandler } from "./middleware/asyncHandler";
import { validateBody, v } from "./middleware/validate";
import { ok, badRequest, unauthorized, created, conflict, notFound } from "./utils/responses";
import { UserService } from "../services/db/UserService";
export class RestAuth {
private readonly userService: UserService;
@@ -23,25 +23,28 @@ export class RestAuth {
router.post(
"/register",
validateBody({
username: {required: true, validator: v.isString({nonEmpty: true, min: 3})},
password: {required: true, validator: v.isString({nonEmpty: true, min: 8})},
timezone: {required: true, validator: v.isString({nonEmpty: true})},
location: {required: true, validator: v.isString({nonEmpty: true})},
username: { required: true, validator: v.isString({ nonEmpty: true, min: 3 }) },
password: { required: true, validator: v.isString({ nonEmpty: true, min: 8 }) },
timezone: { required: true, validator: v.isString({ nonEmpty: true }) },
location: { required: true, validator: v.isString({ nonEmpty: true }) },
}),
asyncHandler(async (req, res) => {
const {username, password, timezone, location} = req.body as {
username: string; password: string; timezone: string; location: string;
const { username, password, timezone, location } = req.body as {
username: string;
password: string;
timezone: string;
location: string;
};
if (await this.userService.existsUserByName(username)) {
return conflict(res, "Username already exists", {field: "username", code: "USERNAME_TAKEN"});
return conflict(res, "Username already exists", { field: "username", code: "USERNAME_TAKEN" });
}
const passwordValidation = PasswordUtils.validatePassword(password);
if (!passwordValidation.valid) {
return badRequest(res, passwordValidation.message ?? "Invalid password", {
field: "password",
code: "INVALID_PASSWORD_FORMAT"
code: "INVALID_PASSWORD_FORMAT",
});
}
@@ -53,58 +56,58 @@ export class RestAuth {
config: {
isVisible: false,
isAdmin: false,
canBeModified: false
canBeModified: false,
},
timezone,
location
location,
};
const result = await this.userService.createUser(newUser);
return created(res, {user: result});
return created(res, { user: result });
})
);
router.post(
"/login",
validateBody({
username: {required: true, validator: v.isString({nonEmpty: true})},
password: {required: true, validator: v.isString({nonEmpty: true})},
username: { required: true, validator: v.isString({ nonEmpty: true }) },
password: { required: true, validator: v.isString({ nonEmpty: true }) },
}),
asyncHandler(async (req, res) => {
const {username, password} = req.body as { username: string; password: string };
const { username, password } = req.body as { username: string; password: string };
const user = await this.userService.getUserAuthByName(username);
if (!user) {
return notFound(res, "User not found", {field: "username", code: "INVALID_USER"});
return notFound(res, "User not found", { field: "username", code: "INVALID_USER" });
}
const isValid = await PasswordUtils.comparePassword(password, user.password!);
if (!isValid) {
return unauthorized(res, "Invalid password", {field: "password", code: "INVALID_PASSWORD"});
return unauthorized(res, "Invalid password", { field: "password", code: "INVALID_PASSWORD" });
}
const jwtToken = this.jwtAuthenticator.generateToken({
username: user.name,
id: user.id,
uuid: user.uuid
uuid: user.uuid,
});
res.cookie('auth-token', jwtToken, {
res.cookie("auth-token", jwtToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 24 * 60 * 60 * 1000,
});
return ok(res, {token: jwtToken});
return ok(res, { token: jwtToken });
})
);
router.post(
"/logout",
asyncHandler(async (req, res) => {
res.clearCookie('auth-token');
return ok(res, {message: "Successfully logged out"});
res.clearCookie("auth-token");
return ok(res, { message: "Successfully logged out" });
})
);
+15 -7
View File
@@ -7,18 +7,26 @@ export class JwtTokenPropertiesExtractor {
public createRouter() {
const router = express.Router();
router.get("/id", asyncHandler(async (req: Request, res: Response) => {
router.get(
"/id",
asyncHandler(async (req: Request, res: Response) => {
return ok(res, req.payload.id);
}));
})
);
router.get("/username", asyncHandler(async (req: Request, res: Response) => {
router.get(
"/username",
asyncHandler(async (req: Request, res: Response) => {
return ok(res, req.payload.username);
}));
})
);
router.get("/uuid", asyncHandler(async (req: Request, res: Response) => {
router.get(
"/uuid",
asyncHandler(async (req: Request, res: Response) => {
return ok(res, req.payload.uuid);
}));
})
);
return router;
}
+1 -3
View File
@@ -1,8 +1,6 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
export function asyncHandler(
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
): RequestHandler {
export function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<any>): RequestHandler {
return async (req, res, next) => {
try {
await fn(req, res, next);
+1 -4
View File
@@ -9,10 +9,7 @@ export function authenticateJwt(jwtAuthenticator: JwtAuthenticator) {
const authHeader = req.headers["authorization"];
if (!authHeader) {
return unauthorized(
res,
"Unauthorized: No Authorization header provided"
);
return unauthorized(res, "Unauthorized: No Authorization header provided");
}
if (!authHeader.startsWith(BEARER_PREFIX)) {
@@ -1,11 +1,11 @@
import { Request, Response, NextFunction } from 'express';
import { Request, Response, NextFunction } from "express";
export const extractTokenFromCookie = (req: Request, res: Response, next: NextFunction) => {
if (req.headers.authorization) {
return next();
}
const token = req.cookies['auth-token'];
const token = req.cookies["auth-token"];
if (token) {
req.headers.authorization = `Bearer ${token}`;
+1 -1
View File
@@ -1,6 +1,6 @@
import type { NextFunction, Request, Response } from "express";
import { notFound } from "../utils/responses";
import {UserService} from "../../services/db/UserService";
import { UserService } from "../../services/db/UserService";
export function isAdmin(userService: UserService) {
return async (req: Request, res: Response, next: NextFunction) => {
+3 -2
View File
@@ -1,5 +1,5 @@
import type { Request, Response, NextFunction } from "express";
import {badRequest} from "../utils/responses";
import { badRequest } from "../utils/responses";
/**
* A type definition for a validation function.
@@ -64,7 +64,8 @@ export const v = {
return (value: any) => (values.includes(value) ? true : `must be one of: ${values.join(", ")}`);
},
isArrayLength: (len: number): Validator => {
return (value: any) => (Array.isArray(value) && value.length === len ? true : `must be an array of length ${len}`);
return (value: any) =>
Array.isArray(value) && value.length === len ? true : `must be an array of length ${len}`;
},
isObject: (opts?: { nonEmpty?: boolean }): Validator => {
return (value: any) => {
+32 -25
View File
@@ -1,32 +1,30 @@
import {S3Service} from "../services/s3Service";
import multer from "multer"
import { S3Service } from "../services/s3Service";
import multer from "multer";
import express from "express";
import {asyncHandler} from "./middleware/asyncHandler";
import {badRequest, created, forbidden, notFound, ok} from "./utils/responses";
import {vi} from "vitest";
import { asyncHandler } from "./middleware/asyncHandler";
import { badRequest, created, forbidden, notFound, ok } from "./utils/responses";
import { vi } from "vitest";
vi.mock("../../src/services/db/UserService", () => ({
UserService: {
create: vi.fn()
}
create: vi.fn(),
},
}));
export class RestStorage {
constructor(private readonly s3Service: S3Service) {
}
constructor(private readonly s3Service: S3Service) {}
public createRouter() {
const router = express.Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: {fileSize: 10 * 1024 * 1024},
limits: { fileSize: 10 * 1024 * 1024 },
});
router.post(
"/upload",
upload.single('image'),
upload.single("image"),
asyncHandler(async (req, res) => {
if (!req.file) {
return badRequest(res, "No file provided.");
@@ -35,23 +33,28 @@ export class RestStorage {
const userId = req.payload.uuid;
const objectKey = await this.s3Service.uploadFile(req.file, userId);
return created(res, {message: "File uploaded successfully", objectKey})
return created(res, { message: "File uploaded successfully", objectKey });
})
);
router.get("/files", asyncHandler(async (req, res) => {
router.get(
"/files",
asyncHandler(async (req, res) => {
const userId = req.payload.uuid;
const files = await this.s3Service.listFilesForUser(userId);
return ok(res, files);
}));
})
);
router.get(/\/files\/(.*)\/url$/, asyncHandler(async (req, res) => {
router.get(
/\/files\/(.*)\/url$/,
asyncHandler(async (req, res) => {
const userId = req.payload.uuid;
const objectKey =req.params[0];
const objectKey = req.params[0];
console.log(userId);
console.log(objectKey)
console.log(objectKey);
if (!objectKey || !objectKey.startsWith(`user-${userId}`)) {
return forbidden(res);
@@ -60,7 +63,7 @@ export class RestStorage {
const expiresInSeconds = 60;
const downloadUrl = await this.s3Service.getSignedDownloadUrl(objectKey, expiresInSeconds);
return ok(res, {url: downloadUrl});
return ok(res, { url: downloadUrl });
} catch (error: any) {
if (error.name === "NoSuchKey") {
return notFound(res, "File not found.");
@@ -68,14 +71,17 @@ export class RestStorage {
throw error;
}
}
})
);
}));
router.delete(/\/files\/(.*)/, asyncHandler(async (req, res) => { // <-- ÄNDERUNG HIER
router.delete(
/\/files\/(.*)/,
asyncHandler(async (req, res) => {
// <-- ÄNDERUNG HIER
const userId = req.payload.uuid;
const objectKey =req.params[0];
const objectKey = req.params[0];
console.log(objectKey)
console.log(objectKey);
if (!objectKey.startsWith(`user-${userId}/`)) {
return forbidden(res);
}
@@ -83,7 +89,8 @@ export class RestStorage {
await this.s3Service.deleteFile(objectKey);
return ok(res, "File deleted successfully");
}));
})
);
return router;
}
+44 -29
View File
@@ -1,13 +1,12 @@
import express from "express";
import {PasswordUtils} from "../utils/passwordUtils";
import {asyncHandler} from "./middleware/asyncHandler";
import {v, validateBody, validateParams} from "./middleware/validate";
import {badRequest, ok} from "./utils/responses";
import {isAdmin} from "./middleware/isAdmin";
import {UserService} from "../services/db/UserService";
import { PasswordUtils } from "../utils/passwordUtils";
import { asyncHandler } from "./middleware/asyncHandler";
import { v, validateBody, validateParams } from "./middleware/validate";
import { badRequest, ok } from "./utils/responses";
import { isAdmin } from "./middleware/isAdmin";
import { UserService } from "../services/db/UserService";
export class RestUser {
private readonly userService: UserService;
constructor(userService: UserService) {
@@ -17,27 +16,37 @@ export class RestUser {
public createRouter() {
const router = express.Router();
router.get("/", isAdmin(this.userService), asyncHandler(async (_req, res) => {
router.get(
"/",
isAdmin(this.userService),
asyncHandler(async (_req, res) => {
const users = await this.userService.getAllUsers();
return ok(res, {users});
}));
return ok(res, { users });
})
);
router.get("/me", asyncHandler(async (req, res) => {
router.get(
"/me",
asyncHandler(async (req, res) => {
const user = await this.userService.getUserByUUID(req.payload.uuid);
return ok(res, user);
}));
})
);
router.put(
"/me/spotify",
validateBody({
accessToken: {required: true, validator: v.isString({nonEmpty: true})},
refreshToken: {required: true, validator: v.isString({nonEmpty: true})},
scope: {required: true, validator: v.isString({nonEmpty: true})},
expirationDate: {required: true, validator: v.isString({nonEmpty: true})},
accessToken: { required: true, validator: v.isString({ nonEmpty: true }) },
refreshToken: { required: true, validator: v.isString({ nonEmpty: true }) },
scope: { required: true, validator: v.isString({ nonEmpty: true }) },
expirationDate: { required: true, validator: v.isString({ nonEmpty: true }) },
}),
asyncHandler(async (req, res) => {
const {accessToken, refreshToken, scope, expirationDate} = req.body as {
accessToken: string; refreshToken: string; scope: string; expirationDate: string;
const { accessToken, refreshToken, scope, expirationDate } = req.body as {
accessToken: string;
refreshToken: string;
scope: string;
expirationDate: string;
};
const spotifyConfig = {
@@ -47,24 +56,30 @@ export class RestUser {
expirationDate: new Date(expirationDate),
};
await this.userService.updateUserByUUID(req.payload.uuid, {spotifyConfig: spotifyConfig});
return ok(res, {message: "Spotify config changed successfully."});
await this.userService.updateUserByUUID(req.payload.uuid, { spotifyConfig: spotifyConfig });
return ok(res, { message: "Spotify config changed successfully." });
})
);
router.delete("/me/spotify", asyncHandler(async (req, res) => {
router.delete(
"/me/spotify",
asyncHandler(async (req, res) => {
const updated = await this.userService.clearSpotifyConfigByUUID(req.payload.uuid);
return ok(res, {user: updated});
}));
return ok(res, { user: updated });
})
);
router.put(
"/me/password",
validateBody({
password: {required: true, validator: v.isString({nonEmpty: true, min: 8})},
passwordConfirmation: {required: true, validator: v.isString({nonEmpty: true, min: 8})},
password: { required: true, validator: v.isString({ nonEmpty: true, min: 8 }) },
passwordConfirmation: { required: true, validator: v.isString({ nonEmpty: true, min: 8 }) },
}),
asyncHandler(async (req, res) => {
const {password, passwordConfirmation} = req.body as { password: string; passwordConfirmation: string };
const { password, passwordConfirmation } = req.body as {
password: string;
passwordConfirmation: string;
};
if (password !== passwordConfirmation) {
return badRequest(res, "Passwörter stimmen nicht überein");
@@ -77,15 +92,15 @@ export class RestUser {
const newPassword = await PasswordUtils.hashPassword(password);
await this.userService.updateUserByUUID(req.payload.uuid, {password: newPassword});
return ok(res, {message: "Password changed successfully"});
await this.userService.updateUserByUUID(req.payload.uuid, { password: newPassword });
return ok(res, { message: "Password changed successfully" });
})
);
router.get(
"/:id",
validateParams({
id: {required: true, validator: v.isString({nonEmpty: true})},
id: { required: true, validator: v.isString({ nonEmpty: true }) },
}),
isAdmin(this.userService),
asyncHandler(async (req, res) => {
+10 -6
View File
@@ -1,9 +1,9 @@
import express, { Router, Request, Response } from "express";
import { ExtendedWebSocketServer } from "../websocket";
import { asyncHandler } from "./middleware/asyncHandler";
import {v, validateBody} from "./middleware/validate";
import { v, validateBody } from "./middleware/validate";
import { ok } from "./utils/responses";
import {ExtendedWebSocket} from "../interfaces/extendedWebsocket";
import { ExtendedWebSocket } from "../interfaces/extendedWebsocket";
export class RestWebSocket {
constructor(private webSocketServer: ExtendedWebSocketServer) {}
@@ -36,7 +36,9 @@ export class RestWebSocket {
users: {
required: true,
validator: (value: any) =>
Array.isArray(value) && value.length > 0 && value.every((s) => typeof s === "string" && s.trim().length > 0)
Array.isArray(value) &&
value.length > 0 &&
value.every((s) => typeof s === "string" && s.trim().length > 0)
? true
: "must be a non-empty array of strings",
},
@@ -51,12 +53,14 @@ export class RestWebSocket {
})
);
router.get("/all-clients", asyncHandler(async (_req: Request, res: Response) => {
router.get(
"/all-clients",
asyncHandler(async (_req: Request, res: Response) => {
const connectedClients = this.webSocketServer.getConnectedClients();
const result = Array.from(connectedClients).map((client: ExtendedWebSocket) => client.payload);
return ok(res, { result });
}));
})
);
return router;
}
+12 -14
View File
@@ -1,13 +1,11 @@
import express from "express";
import {asyncHandler} from "./middleware/asyncHandler";
import {validateBody, v} from "./middleware/validate";
import {ok, internalError} from "./utils/responses";
import {SpotifyTokenService} from "../services/spotifyTokenService";
import { asyncHandler } from "./middleware/asyncHandler";
import { validateBody, v } from "./middleware/validate";
import { ok, internalError } from "./utils/responses";
import { SpotifyTokenService } from "../services/spotifyTokenService";
export class SpotifyTokenGenerator {
constructor(private spotifyTokenService: SpotifyTokenService) {
}
constructor(private spotifyTokenService: SpotifyTokenService) {}
public createRouter() {
const router = express.Router();
@@ -15,29 +13,29 @@ export class SpotifyTokenGenerator {
router.post(
"/token/refresh",
validateBody({
refreshToken: {required: true, validator: v.isString({nonEmpty: true})},
refreshToken: { required: true, validator: v.isString({ nonEmpty: true }) },
}),
asyncHandler(async (req, res) => {
const {refreshToken} = req.body as { refreshToken: string };
const { refreshToken } = req.body as { refreshToken: string };
const token = await this.spotifyTokenService.refreshToken(refreshToken);
return ok(res, {token});
return ok(res, { token });
})
);
router.post(
"/token/generate",
validateBody({
authCode: {required: true, validator: v.isString({nonEmpty: true})},
redirectUri: {required: true, validator: v.isUrl()},
authCode: { required: true, validator: v.isString({ nonEmpty: true }) },
redirectUri: { required: true, validator: v.isUrl() },
}),
asyncHandler(async (req, res) => {
const {authCode, redirectUri} = req.body as { authCode: string; redirectUri: string };
const { authCode, redirectUri } = req.body as { authCode: string; redirectUri: string };
const token = await this.spotifyTokenService.generateToken(authCode, redirectUri);
return ok(res, {token});
return ok(res, { token });
})
);
+1 -6
View File
@@ -2,12 +2,7 @@ import type { Response } from "express";
type ErrorDetails = unknown;
function respondError(
res: Response,
status: number,
message: string,
details?: ErrorDetails
) {
function respondError(res: Response, status: number, message: string, details?: ErrorDetails) {
return res.status(status).send({
ok: false,
data: {
+48 -33
View File
@@ -1,27 +1,27 @@
import express, {Express, Request, Response, NextFunction} from "express";
import {Server as HttpServer} from "http";
import express, { Express, Request, Response, NextFunction } from "express";
import { Server as HttpServer } from "http";
import cors from "cors";
import cookieParser from 'cookie-parser';
import {randomUUID} from "crypto";
import cookieParser from "cookie-parser";
import { randomUUID } from "crypto";
import {ExtendedWebSocketServer} from "./websocket";
import {RestWebSocket} from "./rest/restWebSocket";
import {RestUser} from "./rest/restUser";
import {JwtTokenPropertiesExtractor} from "./rest/jwtTokenPropertiesExtractor";
import {SpotifyTokenGenerator} from "./rest/spotifyTokenGenerator";
import {RestAuth} from "./rest/auth";
import {authLimiter, spotifyLimiter} from "./rest/middleware/rateLimit";
import {extractTokenFromCookie} from "./rest/middleware/extractTokenFromCookie";
import {JwtAuthenticator} from "./utils/jwtAuthenticator";
import {authenticateJwt} from "./rest/middleware/authenticateJwt";
import {watchUserChanges} from "./db/models/userWatch";
import {SpotifyPollingService} from "./services/spotifyPollingService";
import {UserService} from "./services/db/UserService";
import {disconnectFromDatabase} from "./services/db/database.service";
import {SpotifyTokenService} from "./services/spotifyTokenService";
import {WeatherPollingService} from "./services/weatherPollingService";
import {S3Service} from "./services/s3Service";
import {RestStorage} from "./rest/restStorage";
import { ExtendedWebSocketServer } from "./websocket";
import { RestWebSocket } from "./rest/restWebSocket";
import { RestUser } from "./rest/restUser";
import { JwtTokenPropertiesExtractor } from "./rest/jwtTokenPropertiesExtractor";
import { SpotifyTokenGenerator } from "./rest/spotifyTokenGenerator";
import { RestAuth } from "./rest/auth";
import { authLimiter, spotifyLimiter } from "./rest/middleware/rateLimit";
import { extractTokenFromCookie } from "./rest/middleware/extractTokenFromCookie";
import { JwtAuthenticator } from "./utils/jwtAuthenticator";
import { authenticateJwt } from "./rest/middleware/authenticateJwt";
import { watchUserChanges } from "./db/models/userWatch";
import { SpotifyPollingService } from "./services/spotifyPollingService";
import { UserService } from "./services/db/UserService";
import { disconnectFromDatabase } from "./services/db/database.service";
import { SpotifyTokenService } from "./services/spotifyTokenService";
import { WeatherPollingService } from "./services/weatherPollingService";
import { S3Service } from "./services/s3Service";
import { RestStorage } from "./rest/restStorage";
interface ServerDependencies {
userService: UserService;
@@ -46,8 +46,10 @@ export class Server {
private httpServer: HttpServer | null = null;
private webSocketServer: ExtendedWebSocketServer | null = null;
constructor(private readonly config: ServerConfig,
private readonly dependencies: ServerDependencies) {
constructor(
private readonly config: ServerConfig,
private readonly dependencies: ServerDependencies
) {
this.app = express();
}
@@ -58,10 +60,10 @@ export class Server {
spotifyTokenService,
spotifyPollingService,
weatherPollingService,
jwtAuthenticator
jwtAuthenticator,
} = this.dependencies;
await s3Service.ensureBucketExists()
await s3Service.ensureBucketExists();
watchUserChanges();
@@ -73,7 +75,13 @@ export class Server {
console.log(`Server is running on port ${this.config.port}`);
});
this.webSocketServer = new ExtendedWebSocketServer(this.httpServer, userService, spotifyPollingService, weatherPollingService, jwtAuthenticator);
this.webSocketServer = new ExtendedWebSocketServer(
this.httpServer,
userService,
spotifyPollingService,
weatherPollingService,
jwtAuthenticator
);
this._setupGracefulShutdown();
@@ -93,15 +101,22 @@ export class Server {
private _setupMiddleware(): void {
this.app.set("trust proxy", 1);
this.app.use(cookieParser());
this.app.use(cors({
this.app.use(
cors({
origin: this.config.cors.origin,
credentials: this.config.cors.credentials,
}));
})
);
this.app.use(this._securityHeaders);
this.app.use(express.json({limit: "2mb"}));
this.app.use(express.json({ limit: "2mb" }));
}
private _setupRoutes(userService: UserService, spotifyTokenService: SpotifyTokenService, jwtAuthenticator: JwtAuthenticator, s3Service: S3Service): void {
private _setupRoutes(
userService: UserService,
spotifyTokenService: SpotifyTokenService,
jwtAuthenticator: JwtAuthenticator,
s3Service: S3Service
): void {
const _authenticateJwt = authenticateJwt(jwtAuthenticator);
const restAuth = new RestAuth(userService, jwtAuthenticator);
@@ -110,7 +125,7 @@ export class Server {
const jwtTokenExtractor = new JwtTokenPropertiesExtractor();
const storage = new RestStorage(s3Service);
this.app.get("/api/healthz", (_req, res) => res.status(200).send({status: "ok"}));
this.app.get("/api/healthz", (_req, res) => res.status(200).send({ status: "ok" }));
this.app.use("/api/auth", authLimiter, restAuth.createRouter());
@@ -160,7 +175,7 @@ export class Server {
ok: false,
data: {
error: errorMessage,
...(statusCode >= 500 && {errorId: errorId}),
...(statusCode >= 500 && { errorId: errorId }),
},
});
});
+16 -30
View File
@@ -1,12 +1,11 @@
import {connectToDatabase} from "./database.service";
import { UpdateQuery} from "mongoose";
import {CreateUserPayload, IUser, SpotifyConfig, UserModel} from "../../db/models/user";
import { connectToDatabase } from "./database.service";
import { UpdateQuery } from "mongoose";
import { CreateUserPayload, IUser, SpotifyConfig, UserModel } from "../../db/models/user";
export class UserService {
private static _instance: UserService;
private constructor() {
}
private constructor() {}
public static async create(): Promise<UserService> {
if (!this._instance) {
@@ -23,15 +22,11 @@ export class UserService {
}
public async updateUserByUUID(uuid: string, updates: Partial<IUser>): Promise<IUser | null> {
return await UserModel.findOneAndUpdate(
{ uuid: uuid },
{ $set: updates },
{ new: true }
).exec();
return await UserModel.findOneAndUpdate({ uuid: uuid }, { $set: updates }, { new: true }).exec();
}
public async getAllUsers(): Promise<IUser[]> {
return await UserModel.find({}, {spotifyConfig: 0, lastState: 0}).exec();
return await UserModel.find({}, { spotifyConfig: 0, lastState: 0 }).exec();
}
public async getUserById(id: string): Promise<IUser | null> {
@@ -39,25 +34,21 @@ export class UserService {
}
public async getUserByUUID(uuid: string): Promise<IUser | null> {
return await UserModel.findOne({uuid}).exec();
return await UserModel.findOne({ uuid }).exec();
}
public async getUserByName(name: string): Promise<IUser | null> {
return await UserModel.findOne({name})
.collation({locale: "en", strength: 2})
.exec();
return await UserModel.findOne({ name }).collation({ locale: "en", strength: 2 }).exec();
}
public async getUserAuthByName(name: string): Promise<IUser | null> {
return await UserModel.findOne({name})
.collation({locale: "en", strength: 2})
.select("+password")
.exec();
return await UserModel.findOne({ name }).collation({ locale: "en", strength: 2 }).select("+password").exec();
}
public async getSpotifyConfigByUUID(uuid: string): Promise<SpotifyConfig | undefined> {
return await UserModel.findOne({uuid}, {spotifyConfig: 1}).exec().then(user => user?.spotifyConfig);
return await UserModel.findOne({ uuid }, { spotifyConfig: 1 })
.exec()
.then((user) => user?.spotifyConfig);
}
public async createUser(userData: CreateUserPayload): Promise<IUser> {
@@ -68,13 +59,12 @@ export class UserService {
delete userObject.password;
return userObject as IUser;
} catch (error: any) {
if (error.code === 11000 && error.keyPattern?.uuid) {
throw new Error("User with that uuid already exists");
}
if (error.name === 'ValidationError') {
if (error.name === "ValidationError") {
throw new Error(`ValidationError: ${error.message}`);
}
@@ -88,12 +78,8 @@ export class UserService {
}
public async clearSpotifyConfigByUUID(uuid: string): Promise<IUser | null> {
return await UserModel.findOneAndUpdate(
{ uuid },
{ $unset: { spotifyConfig: 1 } } as UpdateQuery<IUser>,
{ new: true }
).exec();
return await UserModel.findOneAndUpdate({ uuid }, { $unset: { spotifyConfig: 1 } } as UpdateQuery<IUser>, {
new: true,
}).exec();
}
}
+7 -7
View File
@@ -18,7 +18,7 @@ const connectWithRetry = async (dbName: string, dbConnString: string): Promise<v
await mongoose.connect(dbConnString, options);
} catch (error) {
console.error("Failed to connect to MongoDB. Retrying in 5 seconds...", error);
await new Promise<void>(resolve => setTimeout(resolve, 5000));
await new Promise<void>((resolve) => setTimeout(resolve, 5000));
return connectWithRetry(dbName, dbConnString);
}
};
@@ -34,19 +34,19 @@ export async function connectToDatabase(dbName: string, dbConnString: string): P
return;
}
mongoose.connection.on('connected', () => {
mongoose.connection.on("connected", () => {
isConnected = true;
console.log('Mongoose connected to DB.');
console.log("Mongoose connected to DB.");
});
mongoose.connection.on('disconnected', () => {
mongoose.connection.on("disconnected", () => {
isConnected = false;
console.warn('Mongoose disconnected from DB. Attempting to reconnect...');
console.warn("Mongoose disconnected from DB. Attempting to reconnect...");
});
mongoose.connection.on('error', (err: Error) => {
mongoose.connection.on("error", (err: Error) => {
isConnected = false;
console.error('Mongoose connection error:', err);
console.error("Mongoose connection error:", err);
});
await connectWithRetry(dbName, dbConnString);
+2 -2
View File
@@ -1,4 +1,4 @@
import OpenWeatherAPI from "openweather-api-node"
import OpenWeatherAPI from "openweather-api-node";
function getWeatherInstance(): OpenWeatherAPI {
return new OpenWeatherAPI({
@@ -9,6 +9,6 @@ function getWeatherInstance(): OpenWeatherAPI {
export async function getCurrentWeather(location: string) {
return getWeatherInstance().getCurrent({
locationName: location,
units: "metric"
units: "metric",
});
}
+11 -7
View File
@@ -2,10 +2,12 @@ import {
S3Client,
CreateBucketCommand,
PutObjectCommand,
GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand
GetObjectCommand,
ListObjectsV2Command,
DeleteObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from 'crypto';
import { randomUUID } from "crypto";
export interface S3ClientConfig {
endpoint: string;
@@ -51,7 +53,7 @@ export class S3Service {
await this.client.send(new CreateBucketCommand({ Bucket: this.bucketName }));
console.log(`Bucket "${this.bucketName}" created successfully or already existed.`);
} catch (err: any) {
if (err.name === 'BucketAlreadyOwnedByYou' || err.name === 'BucketAlreadyExists') {
if (err.name === "BucketAlreadyOwnedByYou" || err.name === "BucketAlreadyExists") {
console.log(`Bucket "${this.bucketName}" already exists.`);
} else {
throw err;
@@ -60,7 +62,7 @@ export class S3Service {
}
async uploadFile(file: Express.Multer.File, userId: string): Promise<string> {
const fileExtension = file.originalname.split('.').pop();
const fileExtension = file.originalname.split(".").pop();
const objectKey = `user-${userId}/${randomUUID()}.${fileExtension}`;
const command = new PutObjectCommand({
@@ -74,7 +76,7 @@ export class S3Service {
return objectKey;
}
async listFilesForUser(userId: string): Promise<{ key: string, lastModified: Date }[]> {
async listFilesForUser(userId: string): Promise<{ key: string; lastModified: Date }[]> {
const command = new ListObjectsV2Command({
Bucket: this.bucketName,
Prefix: `user-${userId}/`,
@@ -82,10 +84,12 @@ export class S3Service {
const response = await this.client.send(command);
return response.Contents?.map(item => ({
return (
response.Contents?.map((item) => ({
key: item.Key!,
lastModified: item.LastModified!,
})) || [];
})) || []
);
}
async deleteFile(objectKey: string): Promise<void> {
+4 -4
View File
@@ -1,5 +1,5 @@
import axios from "axios";
import {CurrentlyPlaying} from "../interfaces/CurrentlyPlaying";
import { CurrentlyPlaying } from "../interfaces/CurrentlyPlaying";
export class SpotifyApiService {
private readonly apiUrl = "https://api.spotify.com/v1";
@@ -8,11 +8,11 @@ export class SpotifyApiService {
try {
const response = await axios.get<CurrentlyPlaying>(`${this.apiUrl}/me/player/currently-playing`, {
headers: {
Authorization: `Bearer ${accessToken}`
Authorization: `Bearer ${accessToken}`,
},
params: {
additional_types: "episode"
}
additional_types: "episode",
},
});
if (response.status === 204) {
+6 -8
View File
@@ -2,8 +2,8 @@ import { appEventBus, SPOTIFY_STATE_UPDATED_EVENT } from "../utils/eventBus";
import { SpotifyApiService } from "./spotifyApiService";
import { IUser } from "../db/models/user";
import { AxiosError } from "axios";
import {UserService} from "./db/UserService";
import {SpotifyTokenService} from "./spotifyTokenService";
import { UserService } from "./db/UserService";
import { SpotifyTokenService } from "./spotifyTokenService";
const userStateCache = new Map<string, any>();
const activePolls = new Map<string, NodeJS.Timeout>();
@@ -12,7 +12,7 @@ export class SpotifyPollingService {
constructor(
private readonly userService: UserService,
private readonly spotifyApiService: SpotifyApiService,
private readonly spotifyTokenService: SpotifyTokenService,
private readonly spotifyTokenService: SpotifyTokenService
) {}
public startPollingForUser(user: IUser): void {
@@ -65,11 +65,10 @@ export class SpotifyPollingService {
userStateCache.set(uuid, currentState);
appEventBus.emit(SPOTIFY_STATE_UPDATED_EVENT, { uuid, state: currentState });
}
} catch (error) {
if (error instanceof AxiosError && error.response) {
if (error.response.status === 429) {
const retryAfter = Number(error.response.headers['retry-after'] || 5);
const retryAfter = Number(error.response.headers["retry-after"] || 5);
console.warn(`[SpotifyPolling] Rate limit for ${uuid}. Pausing for ${retryAfter}s.`);
this._pausePolling(uuid, retryAfter * 1000);
} else if (error.response.status === 401) {
@@ -86,8 +85,7 @@ export class SpotifyPollingService {
if (!currentState && !lastState) return false;
if (!currentState || !lastState) return true;
return lastState.item?.id !== currentState.item?.id ||
lastState.is_playing !== currentState.is_playing;
return lastState.item?.id !== currentState.item?.id || lastState.is_playing !== currentState.is_playing;
}
private _pausePolling(uuid: string, durationMs: number): void {
@@ -96,7 +94,7 @@ export class SpotifyPollingService {
activePolls.delete(uuid);
setTimeout(() => {
console.log(`[SpotifyPolling] Resuming polling for ${uuid}.`);
this.userService.getUserByUUID(uuid).then(user => {
this.userService.getUserByUUID(uuid).then((user) => {
if (user) this.startPollingForUser(user);
});
}, durationMs);
+12 -18
View File
@@ -1,43 +1,37 @@
import axios from "axios";
import {OAuthTokenResponse} from "../interfaces/OAuthTokenResponse";
import { OAuthTokenResponse } from "../interfaces/OAuthTokenResponse";
const url = "https://accounts.spotify.com/api/token";
export class SpotifyTokenService {
constructor(private readonly clientId: string, private readonly clientSecret: string) {
}
constructor(
private readonly clientId: string,
private readonly clientSecret: string
) {}
public async refreshToken(refreshToken: string) {
console.log("refreshToken")
const response = await axios.post(
url,
`grant_type=refresh_token&refresh_token=${refreshToken}`,
{
console.log("refreshToken");
const response = await axios.post(url, `grant_type=refresh_token&refresh_token=${refreshToken}`, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(
`${this.clientId}:${this.clientSecret}`,
).toString("base64")}`,
Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64")}`,
},
},
);
});
return response.data as OAuthTokenResponse;
}
public async generateToken(authorizationCode: string, redirectUri: string) {
console.log("generateToken")
console.log("generateToken");
const response = await axios.post(
url,
`grant_type=authorization_code&code=${authorizationCode}&redirect_uri=${redirectUri}`,
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(
`${this.clientId}:${this.clientSecret}`,
).toString("base64")}`,
},
Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64")}`,
},
}
);
console.log(response.data);
+3 -5
View File
@@ -1,10 +1,8 @@
import {appEventBus, USER_UPDATED_EVENT, WEATHER_STATE_UPDATED_EVENT} from "../utils/eventBus";
import { appEventBus, USER_UPDATED_EVENT, WEATHER_STATE_UPDATED_EVENT } from "../utils/eventBus";
import { getCurrentWeather } from "./owmApiService";
import {IUser} from "../db/models/user";
import { IUser } from "../db/models/user";
export class WeatherPollingService {
private readonly activeLocationPolls: Map<string, NodeJS.Timeout>;
private readonly locationSubscriptions: Map<string, Set<string>>;
private readonly weatherCache: Map<string, any>;
@@ -53,7 +51,7 @@ export class WeatherPollingService {
this.weatherCache.delete(location);
}
}
this.userLocationCache.delete(uuid)
this.userLocationCache.delete(uuid);
}
private _startPollingForLocation(location: string): void {
+1 -1
View File
@@ -1,4 +1,4 @@
import {DecodedToken} from "../interfaces/decodedToken";
import { DecodedToken } from "../interfaces/decodedToken";
declare global {
declare namespace Express {
+4 -4
View File
@@ -1,6 +1,6 @@
import { EventEmitter } from 'events';
import { EventEmitter } from "events";
export const appEventBus = new EventEmitter();
export const USER_UPDATED_EVENT = 'user:updated';
export const SPOTIFY_STATE_UPDATED_EVENT = 'spotify:updated';
export const WEATHER_STATE_UPDATED_EVENT = 'weather:updated';
export const USER_UPDATED_EVENT = "user:updated";
export const SPOTIFY_STATE_UPDATED_EVENT = "spotify:updated";
export const WEATHER_STATE_UPDATED_EVENT = "weather:updated";
+7 -10
View File
@@ -6,9 +6,7 @@ export type ValidationResult = {
};
export class PasswordUtils {
private constructor() {
}
private constructor() {}
public static async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
@@ -26,26 +24,25 @@ export class PasswordUtils {
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
if (password.length < minLength) {
return {valid: false, message: `Passwort muss mindestens ${minLength} Zeichen lang sein.`};
return { valid: false, message: `Passwort muss mindestens ${minLength} Zeichen lang sein.` };
}
if (!hasUpperCase) {
return {valid: false, message: "Passwort muss mindestens einen Großbuchstaben enthalten."};
return { valid: false, message: "Passwort muss mindestens einen Großbuchstaben enthalten." };
}
if (!hasLowerCase) {
return {valid: false, message: "Passwort muss mindestens einen Kleinbuchstaben enthalten."};
return { valid: false, message: "Passwort muss mindestens einen Kleinbuchstaben enthalten." };
}
if (!hasNumber) {
return {valid: false, message: "Passwort muss mindestens eine Zahl enthalten."};
return { valid: false, message: "Passwort muss mindestens eine Zahl enthalten." };
}
if (!hasSpecialChar) {
return {valid: false, message: "Passwort muss mindestens ein Sonderzeichen enthalten."};
return { valid: false, message: "Passwort muss mindestens ein Sonderzeichen enthalten." };
}
return {valid: true, message: "Passwort ist gültig."};
return { valid: true, message: "Passwort ist gültig." };
}
}
+6 -12
View File
@@ -1,13 +1,13 @@
import "dotenv/config";
import {IncomingMessage} from "node:http";
import {ExtendedIncomingMessage} from "../interfaces/extendedIncomingMessage";
import {JwtAuthenticator} from "./jwtAuthenticator";
import { IncomingMessage } from "node:http";
import { ExtendedIncomingMessage } from "../interfaces/extendedIncomingMessage";
import { JwtAuthenticator } from "./jwtAuthenticator";
export function verifyClient(
request: IncomingMessage,
jwtAuthenticator: JwtAuthenticator,
callback: (res: boolean, code?: number, message?: string) => void,
callback: (res: boolean, code?: number, message?: string) => void
) {
const token = jwtAuthenticator.verifyToken(request.headers["authorization"]?.slice("Bearer ".length));
if (!token) {
@@ -18,13 +18,7 @@ export function verifyClient(
}
}
const reject = (
request: IncomingMessage,
callback: (res: boolean, code?: number, message?: string) => void,
) => {
console.log(
"Connection refused",
`${request.socket.remoteAddress}:${request.socket.remotePort}`,
);
const reject = (request: IncomingMessage, callback: (res: boolean, code?: number, message?: string) => void) => {
console.log("Connection refused", `${request.socket.remoteAddress}:${request.socket.remotePort}`);
callback(false, 401, "Unauthorized");
};
@@ -1,3 +1 @@
export interface NoData{
}
export interface NoData {}
@@ -1,4 +1,4 @@
import {ExtendedWebSocket} from "../../../interfaces/extendedWebsocket";
import { ExtendedWebSocket } from "../../../interfaces/extendedWebsocket";
export abstract class CustomWebsocketEvent<T = any> {
abstract event: string;
@@ -8,5 +8,4 @@ export abstract class CustomWebsocketEvent<T = any> {
public constructor(ws: ExtendedWebSocket) {
this.ws = ws;
}
}
@@ -1,10 +1,10 @@
import {WebsocketEventType} from "./websocketEventType";
import {CustomWebsocketEvent} from "./customWebsocketEvent";
import {ExtendedWebSocket} from "../../../interfaces/extendedWebsocket";
import { WebsocketEventType } from "./websocketEventType";
import { CustomWebsocketEvent } from "./customWebsocketEvent";
import { ExtendedWebSocket } from "../../../interfaces/extendedWebsocket";
interface ErrorData {
message: string;
traceback: string
traceback: string;
}
export class ErrorEvent extends CustomWebsocketEvent<ErrorData> {
@@ -17,5 +17,5 @@ export class ErrorEvent extends CustomWebsocketEvent<ErrorData> {
handler = async (data: ErrorData) => {
console.warn("Error message received", data.message);
console.warn("Traceback", data.traceback);
}
};
}
@@ -1,18 +1,20 @@
import {CustomWebsocketEvent} from "./customWebsocketEvent";
import {WebsocketEventType} from "./websocketEventType";
import {NoData} from "./NoData";
import { CustomWebsocketEvent } from "./customWebsocketEvent";
import { WebsocketEventType } from "./websocketEventType";
import { NoData } from "./NoData";
export class GetSettingsEvent extends CustomWebsocketEvent<NoData> {
event = WebsocketEventType.GET_SETTINGS;
handler = async () => {
console.log("Getting settings");
this.ws.send(JSON.stringify({
this.ws.send(
JSON.stringify({
type: "SETTINGS",
payload: {
timezone: this.ws.user.timezone,
},
}), {binary: false});
}
}),
{ binary: false }
);
};
}
@@ -1,14 +1,16 @@
import { SpotifyPollingService } from "../../../services/spotifyPollingService";
import {WebsocketEventType} from "./websocketEventType";
import {NoData} from "./NoData";
import {ExtendedWebSocket} from "../../../interfaces/extendedWebsocket";
import {CustomWebsocketEvent} from "./customWebsocketEvent";
import { WebsocketEventType } from "./websocketEventType";
import { NoData } from "./NoData";
import { ExtendedWebSocket } from "../../../interfaces/extendedWebsocket";
import { CustomWebsocketEvent } from "./customWebsocketEvent";
export class GetSpotifyUpdatesEvent extends CustomWebsocketEvent<NoData> {
event = WebsocketEventType.GET_SPOTIFY_UPDATE;
constructor(ws: ExtendedWebSocket, private spotifyPollingService: SpotifyPollingService) {
constructor(
ws: ExtendedWebSocket,
private spotifyPollingService: SpotifyPollingService
) {
super(ws);
}
@@ -17,5 +19,5 @@ export class GetSpotifyUpdatesEvent extends CustomWebsocketEvent<NoData> {
if (this.ws.user) {
this.spotifyPollingService.startPollingForUser(this.ws.user);
}
}
};
}
@@ -1,6 +1,6 @@
import {CustomWebsocketEvent} from "./customWebsocketEvent";
import {WebsocketEventType} from "./websocketEventType";
import {NoData} from "./NoData";
import { CustomWebsocketEvent } from "./customWebsocketEvent";
import { WebsocketEventType } from "./websocketEventType";
import { NoData } from "./NoData";
export class GetStateEvent extends CustomWebsocketEvent<NoData> {
event = WebsocketEventType.GET_STATE;
@@ -11,6 +11,6 @@ export class GetStateEvent extends CustomWebsocketEvent<NoData> {
type: "STATE",
payload: this.ws.user.lastState,
};
this.ws.send(JSON.stringify(messageToSend), {binary: false});
}
this.ws.send(JSON.stringify(messageToSend), { binary: false });
};
}
@@ -1,15 +1,14 @@
import {CustomWebsocketEvent} from "./customWebsocketEvent";
import {WebsocketEventType} from "./websocketEventType";
import {NoData} from "./NoData";
import {WeatherPollingService} from "../../../services/weatherPollingService";
import {ExtendedWebSocket} from "../../../interfaces/extendedWebsocket";
import { CustomWebsocketEvent } from "./customWebsocketEvent";
import { WebsocketEventType } from "./websocketEventType";
import { NoData } from "./NoData";
import { WeatherPollingService } from "../../../services/weatherPollingService";
import { ExtendedWebSocket } from "../../../interfaces/extendedWebsocket";
export class GetWeatherUpdatesEvent extends CustomWebsocketEvent<NoData> {
event = WebsocketEventType.GET_WEATHER_UPDATES;
private readonly weatherPollingService: WeatherPollingService;
constructor(ws: ExtendedWebSocket, weatherPollingService:WeatherPollingService) {
constructor(ws: ExtendedWebSocket, weatherPollingService: WeatherPollingService) {
super(ws);
this.weatherPollingService = weatherPollingService;
}
@@ -19,6 +18,5 @@ export class GetWeatherUpdatesEvent extends CustomWebsocketEvent<NoData> {
if (user?.location && user.uuid) {
this.weatherPollingService.subscribeUser(user.uuid, user.location);
}
}
};
}
@@ -1,11 +1,10 @@
import {CustomWebsocketEvent} from "./customWebsocketEvent";
import {WebsocketEventType} from "./websocketEventType";
import {NoData} from "./NoData";
import {SpotifyPollingService} from "../../../services/spotifyPollingService";
import {ExtendedWebSocket} from "../../../interfaces/extendedWebsocket";
import { CustomWebsocketEvent } from "./customWebsocketEvent";
import { WebsocketEventType } from "./websocketEventType";
import { NoData } from "./NoData";
import { SpotifyPollingService } from "../../../services/spotifyPollingService";
import { ExtendedWebSocket } from "../../../interfaces/extendedWebsocket";
export class StopSpotifyUpdatesEvent extends CustomWebsocketEvent<NoData> {
event = WebsocketEventType.STOP_SPOTIFY_UPDATES;
private readonly spotifyPollingService: SpotifyPollingService;
@@ -25,6 +24,5 @@ export class StopSpotifyUpdatesEvent extends CustomWebsocketEvent<NoData> {
} else {
console.warn("Could not stop Spotify polling: No UUID found on WebSocket payload.");
}
}
};
}
@@ -1,15 +1,14 @@
import {CustomWebsocketEvent} from "./customWebsocketEvent";
import {WebsocketEventType} from "./websocketEventType";
import {NoData} from "./NoData";
import {ExtendedWebSocket} from "../../../interfaces/extendedWebsocket";
import {WeatherPollingService} from "../../../services/weatherPollingService";
import { CustomWebsocketEvent } from "./customWebsocketEvent";
import { WebsocketEventType } from "./websocketEventType";
import { NoData } from "./NoData";
import { ExtendedWebSocket } from "../../../interfaces/extendedWebsocket";
import { WeatherPollingService } from "../../../services/weatherPollingService";
export class StopWeatherUpdatesEvent extends CustomWebsocketEvent<NoData> {
event = WebsocketEventType.STOP_WEATHER_UPDATES;
private readonly weatherPollingService: WeatherPollingService;
constructor(ws:ExtendedWebSocket, weatherPollingService:WeatherPollingService) {
constructor(ws: ExtendedWebSocket, weatherPollingService: WeatherPollingService) {
super(ws);
this.weatherPollingService = weatherPollingService;
}
@@ -20,6 +19,5 @@ export class StopWeatherUpdatesEvent extends CustomWebsocketEvent<NoData> {
if (user?.location && user.uuid) {
this.weatherPollingService.unsubscribeUser(user.uuid, user.location);
}
}
};
}
@@ -1,16 +1,15 @@
import {WebsocketEventType} from "./websocketEventType";
import {CustomWebsocketEvent} from "./customWebsocketEvent";
import {IUser} from "../../../db/models/user";
import { WebsocketEventType } from "./websocketEventType";
import { CustomWebsocketEvent } from "./customWebsocketEvent";
import { IUser } from "../../../db/models/user";
export class UpdateUserSingleEvent extends CustomWebsocketEvent<IUser> {
event = WebsocketEventType.UPDATE_USER_SINGLE;
handler = async (data: IUser) => {
console.log("Updating user")
console.log("Updating user");
if (data) {
this.ws.user = data;
console.log("User updated")
}
console.log("User updated");
}
};
}
@@ -1,17 +1,21 @@
import {ExtendedWebSocket} from "../../../interfaces/extendedWebsocket";
import {GetSettingsEvent} from "./getSettingsEvent";
import {ErrorEvent} from "./errorEvent";
import {GetSpotifyUpdatesEvent} from "./getSpotifyUpdatesEvent";
import {GetStateEvent} from "./getStateEvent";
import {GetWeatherUpdatesEvent} from "./getWeatherUpdatesEvent";
import {StopSpotifyUpdatesEvent} from "./stopSpotifyUpdatesEvent";
import {StopWeatherUpdatesEvent} from "./stopWeatherUpdatesEvent";
import { UpdateUserSingleEvent} from "./updateUserEvent";
import {CustomWebsocketEvent} from "./customWebsocketEvent";
import {SpotifyPollingService} from "../../../services/spotifyPollingService";
import {WeatherPollingService} from "../../../services/weatherPollingService";
import { ExtendedWebSocket } from "../../../interfaces/extendedWebsocket";
import { GetSettingsEvent } from "./getSettingsEvent";
import { ErrorEvent } from "./errorEvent";
import { GetSpotifyUpdatesEvent } from "./getSpotifyUpdatesEvent";
import { GetStateEvent } from "./getStateEvent";
import { GetWeatherUpdatesEvent } from "./getWeatherUpdatesEvent";
import { StopSpotifyUpdatesEvent } from "./stopSpotifyUpdatesEvent";
import { StopWeatherUpdatesEvent } from "./stopWeatherUpdatesEvent";
import { UpdateUserSingleEvent } from "./updateUserEvent";
import { CustomWebsocketEvent } from "./customWebsocketEvent";
import { SpotifyPollingService } from "../../../services/spotifyPollingService";
import { WeatherPollingService } from "../../../services/weatherPollingService";
export function getEventListeners(ws: ExtendedWebSocket, spotifyPollingService: SpotifyPollingService, weatherPollingService:WeatherPollingService): CustomWebsocketEvent[] {
export function getEventListeners(
ws: ExtendedWebSocket,
spotifyPollingService: SpotifyPollingService,
weatherPollingService: WeatherPollingService
): CustomWebsocketEvent[] {
return [
new GetStateEvent(ws),
new GetSettingsEvent(ws),
@@ -20,6 +24,6 @@ export function getEventListeners(ws: ExtendedWebSocket, spotifyPollingService:
new GetWeatherUpdatesEvent(ws, weatherPollingService),
new StopWeatherUpdatesEvent(ws, weatherPollingService),
new UpdateUserSingleEvent(ws),
new ErrorEvent(ws)
new ErrorEvent(ws),
];
}
+12 -11
View File
@@ -1,12 +1,15 @@
import {ExtendedWebSocket} from "../../interfaces/extendedWebsocket";
import {CustomWebsocketEvent} from "./websocketCustomEvents/customWebsocketEvent";
import {getEventListeners} from "./websocketCustomEvents/websocketEventUtils";
import {SpotifyPollingService} from "../../services/spotifyPollingService";
import {WeatherPollingService} from "../../services/weatherPollingService";
import { ExtendedWebSocket } from "../../interfaces/extendedWebsocket";
import { CustomWebsocketEvent } from "./websocketCustomEvents/customWebsocketEvent";
import { getEventListeners } from "./websocketCustomEvents/websocketEventUtils";
import { SpotifyPollingService } from "../../services/spotifyPollingService";
import { WeatherPollingService } from "../../services/weatherPollingService";
export class WebsocketEventHandler {
constructor(private webSocket: ExtendedWebSocket, private spotifyPollingService: SpotifyPollingService, private readonly weatherPollingService: WeatherPollingService) {
}
constructor(
private webSocket: ExtendedWebSocket,
private spotifyPollingService: SpotifyPollingService,
private readonly weatherPollingService: WeatherPollingService
) {}
public enableErrorEvent() {
this.webSocket.on("error", console.error);
@@ -33,13 +36,12 @@ export class WebsocketEventHandler {
this.webSocket.on("message", (data) => {
const message = data.toString();
const messageJson = JSON.parse(message);
const {type} = messageJson;
const { type } = messageJson;
console.log("Received message:", message);
// emit event to the custom event handler
this.webSocket.emit(type, messageJson);
}
);
});
}
public registerCustomEvents() {
@@ -50,5 +52,4 @@ export class WebsocketEventHandler {
private registerCustomEvent(customWebsocketEvent: CustomWebsocketEvent) {
this.webSocket.on(customWebsocketEvent.event, customWebsocketEvent.handler.bind(customWebsocketEvent));
}
}
@@ -1,27 +1,29 @@
import {ExtendedWebSocket} from "../../interfaces/extendedWebsocket";
import {ExtendedIncomingMessage} from "../../interfaces/extendedIncomingMessage";
import {Server as WebSocketServer} from "ws";
import {heartbeat} from "./websocketServerHeartbeatInterval";
import {UserService} from "../../services/db/UserService";
import { ExtendedWebSocket } from "../../interfaces/extendedWebsocket";
import { ExtendedIncomingMessage } from "../../interfaces/extendedIncomingMessage";
import { Server as WebSocketServer } from "ws";
import { heartbeat } from "./websocketServerHeartbeatInterval";
import { UserService } from "../../services/db/UserService";
export class WebsocketServerEventHandler {
private readonly heartbeat: () => void;
private readonly userService: UserService;
constructor(private webSocketServer: WebSocketServer, userService: UserService) {
constructor(
private webSocketServer: WebSocketServer,
userService: UserService
) {
this.heartbeat = heartbeat(this.webSocketServer);
this.userService = userService;
}
public enableConnectionEvent(
callback: (ws: ExtendedWebSocket, request: ExtendedIncomingMessage) => void,
) {
this.webSocketServer.on(
"connection",
async (ws: ExtendedWebSocket, request: ExtendedIncomingMessage) => {
public enableConnectionEvent(callback: (ws: ExtendedWebSocket, request: ExtendedIncomingMessage) => void) {
this.webSocketServer.on("connection", async (ws: ExtendedWebSocket, request: ExtendedIncomingMessage) => {
const user = await this.userService.getUserByUUID(request.payload.uuid);
if (!user) { ws.terminate(); return; }
if (!user) {
ws.terminate();
return;
}
ws.user = user;
// first: map the payload from the request to the ws object (is payloed needed anymore?)
@@ -29,11 +31,9 @@ export class WebsocketServerEventHandler {
// second: set the isAlive flag to true
ws.isAlive = true;
// last: call the callback function
callback(ws, request);
},
);
});
}
public enableHeartbeat(interval: number) {
@@ -3,20 +3,12 @@ import { DecodedToken } from "../../interfaces/decodedToken";
export function heartbeat(wss: WebSocketServer) {
return () => {
wss.clients.forEach(
(ws: WebSocket & { isAlive?: boolean; payload?: DecodedToken }) => {
console.log(
new Date().toLocaleString("de-DE") +
":" +
ws.payload?.username +
": isAlive: " +
ws.isAlive,
);
wss.clients.forEach((ws: WebSocket & { isAlive?: boolean; payload?: DecodedToken }) => {
console.log(new Date().toLocaleString("de-DE") + ":" + ws.payload?.username + ": isAlive: " + ws.isAlive);
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
},
);
});
};
}
+39 -25
View File
@@ -1,21 +1,21 @@
import {Server} from "http";
import {Server as WebSocketServer, WebSocket} from "ws";
import {verifyClient} from "./utils/verifyClient";
import {ExtendedWebSocket} from "./interfaces/extendedWebsocket";
import {WebsocketServerEventHandler} from "./utils/websocket/websocketServerEventHandler";
import {WebsocketEventHandler} from "./utils/websocket/websocketEventHandler";
import {WebsocketEventType} from "./utils/websocket/websocketCustomEvents/websocketEventType";
import { Server } from "http";
import { Server as WebSocketServer, WebSocket } from "ws";
import { verifyClient } from "./utils/verifyClient";
import { ExtendedWebSocket } from "./interfaces/extendedWebsocket";
import { WebsocketServerEventHandler } from "./utils/websocket/websocketServerEventHandler";
import { WebsocketEventHandler } from "./utils/websocket/websocketEventHandler";
import { WebsocketEventType } from "./utils/websocket/websocketCustomEvents/websocketEventType";
import {
appEventBus,
SPOTIFY_STATE_UPDATED_EVENT,
USER_UPDATED_EVENT,
WEATHER_STATE_UPDATED_EVENT
WEATHER_STATE_UPDATED_EVENT,
} from "./utils/eventBus";
import {IUser} from "./db/models/user";
import {SpotifyPollingService} from "./services/spotifyPollingService";
import {UserService} from "./services/db/UserService";
import {WeatherPollingService} from "./services/weatherPollingService";
import {JwtAuthenticator} from "./utils/jwtAuthenticator";
import { IUser } from "./db/models/user";
import { SpotifyPollingService } from "./services/spotifyPollingService";
import { UserService } from "./services/db/UserService";
import { WeatherPollingService } from "./services/weatherPollingService";
import { JwtAuthenticator } from "./utils/jwtAuthenticator";
export class ExtendedWebSocketServer {
private readonly _wss: WebSocketServer;
@@ -23,8 +23,13 @@ export class ExtendedWebSocketServer {
private readonly spotifyPollingService: SpotifyPollingService;
private readonly weatherPollingService: WeatherPollingService;
constructor(server: Server, userService: UserService, spotifyPollingService: SpotifyPollingService,
weatherPollingService: WeatherPollingService, jwtAuthenticator: JwtAuthenticator) {
constructor(
server: Server,
userService: UserService,
spotifyPollingService: SpotifyPollingService,
weatherPollingService: WeatherPollingService,
jwtAuthenticator: JwtAuthenticator
) {
this.userService = userService;
this.spotifyPollingService = spotifyPollingService;
this.weatherPollingService = weatherPollingService;
@@ -41,7 +46,7 @@ export class ExtendedWebSocketServer {
public broadcast(message: string): void {
this.getConnectedClients().forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message, {binary: false});
client.send(message, { binary: false });
}
});
}
@@ -49,7 +54,7 @@ export class ExtendedWebSocketServer {
public sendMessageToUser(uuid: string, message: string): void {
const client = this._findClientByUUID(uuid);
if (client && client.readyState === WebSocket.OPEN) {
client.send(message, {binary: false});
client.send(message, { binary: false });
}
}
@@ -73,7 +78,11 @@ export class ExtendedWebSocketServer {
private _onNewClientReady(ws: ExtendedWebSocket): void {
console.log("WebSocket client connected and authenticated");
const socketEventHandler = new WebsocketEventHandler(ws, this.spotifyPollingService, this.weatherPollingService);
const socketEventHandler = new WebsocketEventHandler(
ws,
this.spotifyPollingService,
this.weatherPollingService
);
socketEventHandler.enableErrorEvent();
socketEventHandler.enablePongEvent();
@@ -98,29 +107,34 @@ export class ExtendedWebSocketServer {
}
});
appEventBus.on(SPOTIFY_STATE_UPDATED_EVENT, ({uuid, state}) => {
appEventBus.on(SPOTIFY_STATE_UPDATED_EVENT, ({ uuid, state }) => {
const client = this._findClientByUUID(uuid);
console.log(`Received update for user ${uuid}`);
if (client) {
client.send(JSON.stringify({
client.send(
JSON.stringify({
type: "SPOTIFY_UPDATE",
payload: state,
}), {binary: false});
}),
{ binary: false }
);
}
});
appEventBus.on(WEATHER_STATE_UPDATED_EVENT, ({weatherData, subscribers}) => {
appEventBus.on(WEATHER_STATE_UPDATED_EVENT, ({ weatherData, subscribers }) => {
for (const uuid of subscribers) {
const client = this._findClientByUUID(uuid);
if (client) {
client.send(JSON.stringify({
client.send(
JSON.stringify({
type: "WEATHER_UPDATE",
payload: weatherData,
}), {binary: false});
}),
{ binary: false }
);
}
}
});
}
private _findClientByUUID(uuid: string): ExtendedWebSocket | undefined {