AbstractSession basics + Numbers WIP

This commit is contained in:
Patrick Haßel 2024-10-24 14:46:47 +02:00
parent 43bb77e856
commit 4b2a148ddc
81 changed files with 2002 additions and 35 deletions

View File

@ -12,6 +12,7 @@
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties> </properties>
@ -26,6 +27,11 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>

View File

@ -72,6 +72,12 @@
"maximumError": "4kB" "maximumError": "4kB"
} }
], ],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"outputHashing": "all" "outputHashing": "all"
}, },
"development": { "development": {

View File

@ -16,6 +16,8 @@
"@angular/platform-browser": "^18.2.0", "@angular/platform-browser": "^18.2.0",
"@angular/platform-browser-dynamic": "^18.2.0", "@angular/platform-browser-dynamic": "^18.2.0",
"@angular/router": "^18.2.0", "@angular/router": "^18.2.0",
"@stomp/ng2-stompjs": "^8.0.0",
"ngx-cookie-service": "^18.0.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"zone.js": "~0.14.10" "zone.js": "~0.14.10"
@ -4283,6 +4285,39 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@stomp/ng2-stompjs": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@stomp/ng2-stompjs/-/ng2-stompjs-8.0.0.tgz",
"integrity": "sha512-1TTwdK7aUaRLfewG2FOqd3ZeIyIchKwOBzJytvUckjrBbpdeAXLXY0mCBFczoY16fSsxiAnZfJffFicStD+Dug==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "Apache-2.0",
"dependencies": {
"@stomp/rx-stomp": "^1.0.0 >=1.0.1",
"tslib": "^1.9.0"
}
},
"node_modules/@stomp/ng2-stompjs/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/@stomp/rx-stomp": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@stomp/rx-stomp/-/rx-stomp-1.2.0.tgz",
"integrity": "sha512-QLzPe3q0EwLB+cVWdUFEO4z5tyR+kPnXJANKN2UvB7Spz/oViHF959cydmXdQWaK7NHp86VO54TgFfXbHVnSLg==",
"license": "Apache-2.0",
"dependencies": {
"@stomp/stompjs": "^6.0.0 >=6.1.1",
"angular2-uuid": "^1.1.1"
}
},
"node_modules/@stomp/stompjs": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-6.1.2.tgz",
"integrity": "sha512-FHDTrIFM5Ospi4L3Xhj6v2+NzCVAeNDcBe95YjUWhWiRMrBF6uN3I7AUOlRgT6jU/2WQvvYK8ZaIxFfxFp+uHQ==",
"license": "Apache-2.0"
},
"node_modules/@tufjs/canonical-json": { "node_modules/@tufjs/canonical-json": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
@ -4943,6 +4978,12 @@
"ajv": "^8.8.2" "ajv": "^8.8.2"
} }
}, },
"node_modules/angular2-uuid": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/angular2-uuid/-/angular2-uuid-1.1.1.tgz",
"integrity": "sha512-6AXPyii9q8KBFGagybLNVmdGJLPcVZAhmv3odNGSJIA18LuJ3xOe6uN9GvjlQsGfdmYeuxlsGnFEUu7gPhkc+g==",
"license": "MIT"
},
"node_modules/ansi-colors": { "node_modules/ansi-colors": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@ -10100,6 +10141,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ngx-cookie-service": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/ngx-cookie-service/-/ngx-cookie-service-18.0.0.tgz",
"integrity": "sha512-hkkUckzZTXXWtFgvVkT2hg6mwYMLXioXDZWBsVCOy9gYkADjsj0N5VViO7eo2izQ0VcMPd/Etog1trf/T4oZMQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"peerDependencies": {
"@angular/common": "^18.0.0-rc.0",
"@angular/core": "^18.0.0-rc.0"
}
},
"node_modules/nice-napi": { "node_modules/nice-napi": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz",

View File

@ -20,7 +20,9 @@
"@angular/router": "^18.2.0", "@angular/router": "^18.2.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"zone.js": "~0.14.10" "zone.js": "~0.14.10",
"@stomp/ng2-stompjs": "^8.0.0",
"ngx-cookie-service": "^18.0.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^18.2.3", "@angular-devkit/build-angular": "^18.2.3",

View File

@ -0,0 +1,20 @@
import {UserPublic} from "../User/UserPublic";
export abstract class AbstractSession {
protected constructor(
readonly type: string,
readonly typeDisplayName: string,
readonly uuid: string,
readonly owner: UserPublic,
readonly created: Date,
readonly title: string,
readonly password: string,
readonly users: UserPublic[],
readonly initial: boolean,
) {
// -
}
}

View File

@ -0,0 +1,70 @@
import {ApiService} from "../common/api.service";
import {FromJson, Next} from "../common/types";
import {AbstractSession} from "./AbstractSession";
import {Router} from "@angular/router";
import {UserService} from "../User/user.service";
import {validateBoolean} from "../common/validators";
export abstract class AbstractSessionService<SESSION extends AbstractSession> {
protected constructor(
protected readonly api: ApiService,
protected readonly router: Router,
protected readonly userService: UserService,
readonly apiPath: any[],
readonly routerPath: any[],
readonly fromJson: FromJson<SESSION>,
) {
// -
}
canAccess(uuid: string, next: Next<boolean>): void {
this.api.postSingle([...this.apiPath, 'canAccess'], uuid, validateBoolean, next);
}
get(uuid: string, next: Next<SESSION>): void {
this.api.postSingle([...this.apiPath, 'get'], uuid, this.fromJson, next);
}
create(next: Next<SESSION>): void {
this.api.getSingle([...this.apiPath, 'create'], this.fromJson, session => {
next(session);
this.userService.refresh();
});
}
changeTitle(session: SESSION, title: string, next?: Next<SESSION>) {
const data = {
uuid: session.uuid,
title: title,
};
this.api.postSingle([...this.apiPath, 'changeTitle'], data, this.fromJson, next);
}
changePassword(session: SESSION, password: string, next?: Next<SESSION>) {
const data = {
uuid: session.uuid,
password: password,
};
this.api.postSingle([...this.apiPath, 'changePassword'], data, this.fromJson, next);
}
join(uuid: string, password: string, next: Next<SESSION>): void {
const data = {
uuid: uuid,
password: password,
};
this.api.postSingle([...this.apiPath, 'join'], data, this.fromJson, next);
}
leave(session: SESSION, next: Next<void>): void {
this.api.postNone([...this.apiPath, 'leave'], session, next);
}
goto(session: SESSION) {
const url = '/' + this.routerPath.join('/');
this.router.navigate([url, {uuid: session.uuid}]);
}
}

View File

@ -0,0 +1,14 @@
import {AbstractSession} from "./AbstractSession";
import {validateString} from "../common/validators";
import {NumbersSession} from "../tools/Numbers/Session/NumbersSession";
export function sessionFromJsonOrNull(json: any): AbstractSession | null {
const type = validateString(json['type']);
switch (type) {
case 'Numbers':
return NumbersSession.fromJson(json);
default:
console.error("Not implemented: AbstractSession.type=" + type);
return null;
}
}

View File

@ -0,0 +1,24 @@
import {validateListIgnoreNullItems, validateString} from "../common/validators";
import {AbstractSession} from "../Session/AbstractSession";
import {sessionFromJsonOrNull} from "../Session/sessionFromJsonOrNull";
import {UserPublic} from "./UserPublic";
export class UserCommon extends UserPublic {
constructor(
publicUuid: string,
name: string,
readonly commonSessions: AbstractSession[],
) {
super(publicUuid, name);
}
static override fromJson(json: any): UserCommon {
return new UserCommon(
validateString(json['publicUuid']),
validateString(json['name']),
validateListIgnoreNullItems(json['commonSessions'], sessionFromJsonOrNull),
);
}
}

View File

@ -0,0 +1,35 @@
import {validateDate, validateListIgnoreNullItems, validateString} from "../common/validators";
import {AbstractSession} from "../Session/AbstractSession";
import {sessionFromJsonOrNull} from "../Session/sessionFromJsonOrNull";
export class UserPrivate {
constructor(
readonly privateUuid: string,
readonly publicUuid: string,
readonly created: Date,
readonly sessions: AbstractSession[],
readonly name: string,
) {
// -
}
static fromJson(json: any): UserPrivate {
return new UserPrivate(
validateString(json['privateUuid']),
validateString(json['publicUuid']),
validateDate(json['created']),
validateListIgnoreNullItems(json['sessions'], sessionFromJsonOrNull),
validateString(json['name']),
);
}
static fromJsonOrNull(json: any): UserPrivate | null {
if (json === null || json === undefined) {
return null;
}
return UserPrivate.fromJson(json);
}
}

View File

@ -0,0 +1,19 @@
import {validateString} from "../common/validators";
export class UserPublic {
constructor(
readonly publicUuid: string,
readonly name: string,
) {
// -
}
static fromJson(json: any): UserPublic {
return new UserPublic(
validateString(json['publicUuid']),
validateString(json['name']),
);
}
}

View File

@ -0,0 +1,60 @@
import {Injectable} from '@angular/core';
import {ApiService} from "../common/api.service";
import {UserPrivate} from "./UserPrivate";
import {Next} from "../common/types";
import {UserCommon} from "./UserCommon";
import {UserPublic} from "./UserPublic";
import {Router} from "@angular/router";
import {BehaviorSubject, Subscription} from "rxjs";
import {AbstractSession} from "../Session/AbstractSession";
@Injectable({
providedIn: 'root'
})
export class UserService {
private readonly subject: BehaviorSubject<UserPrivate | null> = new BehaviorSubject<UserPrivate | null>(null);
get user(): UserPrivate | null {
return this.subject.value;
}
constructor(
protected readonly router: Router,
protected readonly api: ApiService,
) {
this.refresh();
}
getCommonByUuid(uuid: string, next: Next<UserCommon>): void {
this.api.postSingle(['User', 'getCommonByUuid'], uuid, UserCommon.fromJson, next);
}
delete(next?: Next<void>) {
this.api.getNone(['User', 'delete'], _ => {
this.refresh();
next && next();
});
}
changeName(name: string, next?: Next<UserPrivate>) {
this.api.postSingle(['User', 'changeName'], name, UserPrivate.fromJson, next);
}
goto(user: UserPublic) {
this.router.navigate(['/User', {uuid: user.publicUuid}]);
}
refresh() {
this.api.getSingle(['User', 'whoAmI'], UserPrivate.fromJsonOrNull, user => this.subject.next(user));
}
subscribe(next: Next<UserPrivate | null>): Subscription {
return this.subject.subscribe(next);
}
owns(session: AbstractSession): boolean {
return this.user?.publicUuid === session.owner.publicUuid;
}
}

View File

@ -0,0 +1,4 @@
export enum CrudDirection {
ASC = 'ASC',
DESC = 'DESC',
}

View File

@ -0,0 +1,13 @@
import {CrudDirection} from "./CrudDirection";
export class Order<T> {
constructor(
readonly property: string,
readonly compare: (a: T, b: T) => number,
public direction: CrudDirection,
) {
// -
}
}

View File

@ -0,0 +1,28 @@
import {validateList, validateNumber} from "./validators";
import {FromJson} from "./types";
export class Page<T> {
static readonly EMPTY: Page<any> = new Page(0, 0, 0, 0, []);
constructor(
readonly size: number,
readonly number: number,
readonly totalPages: number,
readonly totalElements: number,
readonly content: T[],
) {
// -
}
static fromJson<T>(fromJson: FromJson<T>): FromJson<Page<T>> {
return (json: any) => new Page<T>(
validateNumber(json.page.size),
validateNumber(json.page.number),
validateNumber(json.page.totalPages),
validateNumber(json.page.totalElements),
validateList(json.content, fromJson),
);
}
}

View File

@ -0,0 +1,65 @@
import {CrudDirection} from "./CrudDirection";
import {Order} from "./Order";
export class Sort<T> {
private list: Order<T>[] = [];
toggle(key: string, compare: (a: T, b: T) => number) {
const index: number = this.indexOf(key);
if (index >= 0) {
const existing: Order<T> = this.list[index];
if (existing.direction === CrudDirection.ASC) {
existing.direction = CrudDirection.DESC;
} else {
this.list.splice(index, 1);
}
} else {
this.list.push(new Order(key, compare, CrudDirection.ASC));
}
}
compare(a: T, b: T): number {
for (const order of this.list) {
const result = order.compare(a, b);
if (result != 0) {
return order.direction === CrudDirection.DESC ? -result : result;
}
}
return 0;
}
isEmpty(): boolean {
return this.list.length === 0;
}
indexOf(key: string): number {
return this.list.findIndex(o => o.property === key);
}
isActive(key: string): boolean {
return this.indexOf(key) >= 0;
}
isAscending(key: string): boolean {
return this.isDirection(key, CrudDirection.ASC);
}
isDescending(key: string): boolean {
return this.isDirection(key, CrudDirection.DESC);
}
isDirection(key: string, direction: CrudDirection): boolean {
const index: number = this.indexOf(key);
if (index < 0) {
return false;
}
return this.list[index].direction === direction;
}
clear(): void {
this.list = [];
}
}

View File

@ -0,0 +1,75 @@
import {Injectable} from '@angular/core';
import {HttpClient} from "@angular/common/http";
import {map, Subscription} from "rxjs";
import {FromJson, getApiUrl, Next} from "./types";
import {Page} from "./Page";
import {StompService} from "@stomp/ng2-stompjs";
@Injectable({
providedIn: 'root',
})
export class ApiService {
private _websocketError: boolean = false;
get websocketError(): boolean {
return this._websocketError;
}
constructor(
private readonly http: HttpClient,
private readonly stompService: StompService,
) {
stompService.connected$.subscribe(() => this._websocketError = false);
stompService.webSocketErrors$.subscribe(() => this._websocketError = true);
}
connected(next: Next<void>): Subscription {
return this.stompService.connected$.subscribe(_ => next());
}
subscribe<T>(topic: any[], fromJson: FromJson<T>, next: Next<T>): Subscription {
console.info("WEBSOCKET SUBSCRIBE", topic)
return this.stompService
.subscribe(topic.join("/"))
.pipe(
map(message => message.body),
map(b => JSON.parse(b)),
map(j => fromJson(j)),
)
.subscribe(next);
}
getNone<T>(path: any[], next?: Next<void>): Subscription {
return this.http.get<void>(getApiUrl('http', path), {withCredentials: true}).subscribe(next);
}
getString(path: any[], next: Next<string>): Subscription {
return this.http.get(getApiUrl('http', path), {responseType: "text", withCredentials: true}).subscribe(next);
}
getSingle<T>(path: any[], fromJson: FromJson<T>, next?: Next<T>): Subscription {
return this.http.get<any>(getApiUrl('http', path), {withCredentials: true}).pipe(map(fromJson)).subscribe(next);
}
getList<T>(path: any[], fromJson: FromJson<T>, next: Next<T[]> | undefined = undefined): Subscription {
return this.http.get<any[]>(getApiUrl('http', path), {withCredentials: true}).pipe(map(list => list.map(fromJson))).subscribe(next);
}
postNone(path: any[], data: any, next?: Next<void>): Subscription {
return this.http.post<void>(getApiUrl('http', path), data, {withCredentials: true}).subscribe(next);
}
postSingle<T>(path: any[], data: any, fromJson: FromJson<T>, next?: Next<T>): Subscription {
return this.http.post<any>(getApiUrl('http', path), data, {withCredentials: true}).pipe(map(fromJson)).subscribe(next);
}
postPage<T>(path: any[], data: any, fromJson: FromJson<T>, next: Next<Page<T>> | undefined = undefined): Subscription {
return this.http.post<any>(getApiUrl('http', path), data, {withCredentials: true}).pipe(map(Page.fromJson(fromJson))).subscribe(next);
}
postList<T>(path: any[], data: any, fromJson: FromJson<T>, next: Next<T[]> | undefined = undefined): Subscription {
return this.http.post<any[]>(getApiUrl('http', path), data, {withCredentials: true}).pipe(map(list => list.map(fromJson))).subscribe(next);
}
}

View File

@ -0,0 +1,9 @@
import {environment} from "../../../environments/environment";
export type FromJson<T> = (json: any) => T;
export type Next<T> = (item: T) => void;
export function getApiUrl(protocol: string, path: any[]): string {
return protocol + (environment.secure ? 's' : '') + '://' + environment.host + ':' + environment.port + '/' + environment.base + path.join('/');
}

View File

@ -0,0 +1,73 @@
import {FromJson} from "./types";
export function validateNumber(json: any): number {
if (!(typeof json === "number")) {
throw new Error("Not a number: " + json + " (" + typeof json + "): " + JSON.stringify(json));
}
return json;
}
export function validateNumberOrNull(json: any): number | null {
if (json === null || json === undefined) {
return null;
}
return validateNumber(json);
}
export function validateBoolean(json: any): boolean {
if (!(typeof json === "boolean")) {
throw new Error("Not a boolean: " + json + " (" + typeof json + "): " + JSON.stringify(json));
}
return json;
}
export function validateBooleanOrNull(json: any): boolean | null {
if (json === null || json === undefined) {
return null;
}
return validateBoolean(json);
}
export function validateString(json: any): string {
if (!(typeof json === "string")) {
throw new Error("Not a string: " + json + " (" + typeof json + "): " + JSON.stringify(json));
}
return json;
}
export function validateStringOrNull(json: any): string | null {
if (json === null || json === undefined) {
return null;
}
return validateString(json);
}
export function validateDate(json: any): Date {
return new Date(Date.parse(validateString(json)));
}
export function validateDateOrNull(json: any): Date | null {
if (json === null || json === undefined) {
return null;
}
return validateDate(json);
}
export function validateListIgnoreNullItems<T>(jsonList: any, fromJson: FromJson<T | null>): T[] {
return jsonList.map(fromJson).filter((item: T | null) => item !== null);
}
export function validateList<T>(jsonList: any, fromJson: FromJson<T>): T[] {
return jsonList.map(fromJson);
}
export function validateLocalDateOrNull(json: any): Date | null {
if (json === null || json === undefined) {
return null;
}
return validateLocalDate(json);
}
export function validateLocalDate(json: any): Date {
return new Date(validateString(json));
}

View File

@ -0,0 +1,17 @@
import {StompService} from "@stomp/ng2-stompjs";
import {getApiUrl} from "./types";
export function stompServiceFactory() {
const stomp = new StompService({
url: getApiUrl('ws', ['ws']),
debug: false,
heartbeat_in: 2000,
heartbeat_out: 2000,
reconnect_delay: 2000,
headers: {},
});
stomp.connected$.subscribe(_ => console.info("WEBSOCKET CONNECTED"));
stomp.webSocketErrors$.subscribe(_ => console.info("WEBSOCKET DISCONNECTED"));
stomp.activate();
return stomp;
}

View File

@ -0,0 +1,40 @@
import {UserPublic} from "../../../User/UserPublic";
import {validateBoolean, validateDate, validateList, validateString} from "../../../common/validators";
import {AbstractSession} from "../../../Session/AbstractSession";
export class NumbersSession extends AbstractSession {
static readonly TYPE = 'Numbers';
static readonly TYPE_DISPLAY_NAME = 'Nummern verteilen';
constructor(
type: string,
uuid: string,
title: string,
owner: UserPublic,
created: Date,
password: string,
users: UserPublic[],
initial: boolean,
) {
if (type !== NumbersSession.TYPE) {
throw new Error();
}
super(NumbersSession.TYPE, NumbersSession.TYPE_DISPLAY_NAME, uuid, owner, created, title, password, users, initial);
}
static fromJson(json: any): NumbersSession {
return new NumbersSession(
validateString(json['type']),
validateString(json['uuid']),
validateString(json['title']),
UserPublic.fromJson(json['owner']),
validateDate(json['created']),
validateString(json['password']),
validateList(json['users'], UserPublic.fromJson),
validateBoolean(json['initial']),
);
}
}

View File

@ -0,0 +1,21 @@
import {Injectable} from '@angular/core';
import {AbstractSessionService} from "../../../Session/AbstractSessionService";
import {NumbersSession} from "./NumbersSession";
import {ApiService} from "../../../common/api.service";
import {Router} from "@angular/router";
import {UserService} from "../../../User/user.service";
@Injectable({
providedIn: 'root'
})
export class NumbersSessionService extends AbstractSessionService<NumbersSession> {
constructor(
api: ApiService,
router: Router,
userService: UserService
) {
super(api, router, userService, ['Numbers'], ['Numbers', 'Session'], NumbersSession.fromJson);
}
}

View File

@ -1,5 +1,10 @@
<div id="mainMenu" *ngIf="menuVisible"> <div id="mainMenu" *ngIf="menuVisible">
<div class="mainMenuItem" routerLinkActive="mainMenuItemActive" routerLink="/Solar">Sonnensystem</div> <div class="mainMenuItem" routerLinkActive="mainMenuItemActive" routerLink="/SolarSystem">Sonnensystem</div>
<div class="mainMenuItem" routerLinkActive="mainMenuItemActive" routerLink="/VoltageDrop">Spannungsabfall</div> <div class="mainMenuItem" routerLinkActive="mainMenuItemActive" routerLink="/VoltageDrop">Spannungsabfall</div>
<div class="mainMenuItem" routerLinkActive="mainMenuItemActive" routerLink="/Numbers">{{ NumbersSession.TYPE_DISPLAY_NAME }}</div>
<ng-container *ngIf="user !== null">
<div class="mainMenuItem mainMenuItemRight" routerLinkActive="mainMenuItemActive" routerLink="/Profile">{{ user.name }}</div>
<div class="mainMenuItem mainMenuItemRight" routerLinkActive="mainMenuItemActive" (click)="delete()">Profil löschen</div>
</ng-container>
</div> </div>
<router-outlet (activate)="onActivate($event)"/> <router-outlet (activate)="onActivate($event)"/>

View File

@ -7,6 +7,12 @@
border-right: 1px solid black; border-right: 1px solid black;
} }
.mainMenuItemRight {
float: right;
border-left: 1px solid black;
border-right: none;
}
.mainMenuItemActive { .mainMenuItemActive {
background-color: lightskyblue; background-color: lightskyblue;
} }

View File

@ -1,6 +1,10 @@
import {Component} from '@angular/core'; import {Component, OnDestroy, OnInit} from '@angular/core';
import {RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router'; import {Router, RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
import {NgIf} from "@angular/common"; import {NgIf} from "@angular/common";
import {UserService} from "./api/User/user.service";
import {NumbersSession} from "./api/tools/Numbers/Session/NumbersSession";
import {UserPrivate} from "./api/User/UserPrivate";
import {Subscription} from "rxjs";
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@ -9,12 +13,42 @@ import {NgIf} from "@angular/common";
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.less' styleUrl: './app.component.less'
}) })
export class AppComponent { export class AppComponent implements OnInit, OnDestroy {
protected readonly NumbersSession = NumbersSession;
private readonly subscriptions: Subscription[] = [];
protected menuVisible = true; protected menuVisible = true;
onActivate(component: any) { protected user: UserPrivate | null = null;
constructor(
protected readonly router: Router,
protected readonly userService: UserService,
) {
// -
}
protected onActivate(component: any) {
this.menuVisible = !("PRINTABLE_ONLY" in component) || !component.PRINTABLE_ONLY; this.menuVisible = !("PRINTABLE_ONLY" in component) || !component.PRINTABLE_ONLY;
} }
ngOnInit(): void {
this.subscriptions.push(this.userService.subscribe(user => {
if (this.user !== null && user === null) {
this.router.navigate(['/']);
}
this.user = user;
}));
}
ngOnDestroy(): void {
this.subscriptions.forEach(subscription => subscription.unsubscribe());
}
delete() {
this.userService.delete();
}
} }

View File

@ -5,6 +5,9 @@ import {routes} from './app.routes';
import {registerLocaleData} from "@angular/common"; import {registerLocaleData} from "@angular/common";
import localeDe from '@angular/common/locales/de'; import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de'; import localeDeExtra from '@angular/common/locales/extra/de';
import {provideHttpClient} from "@angular/common/http";
import {stompServiceFactory} from "./api/common/ws";
import {StompService} from "@stomp/ng2-stompjs";
registerLocaleData(localeDe, 'de-DE', localeDeExtra); registerLocaleData(localeDe, 'de-DE', localeDeExtra);
@ -13,5 +16,7 @@ export const appConfig: ApplicationConfig = {
{provide: LOCALE_ID, useValue: 'de-DE'}, {provide: LOCALE_ID, useValue: 'de-DE'},
provideZoneChangeDetection({eventCoalescing: true}), provideZoneChangeDetection({eventCoalescing: true}),
provideRouter(routes), provideRouter(routes),
provideHttpClient(),
{provide: StompService, useFactory: stompServiceFactory},
] ]
}; };

View File

@ -1,16 +1,28 @@
import {Routes} from '@angular/router'; import {Routes} from '@angular/router';
import {SolarSystemComponent} from './solar-system/solar-system.component'; import {SolarSystemComponent} from './pages/tools/solar-system/solar-system.component';
import {VoltageDropComponent} from "./voltage-drop/voltage-drop.component"; import {VoltageDropComponent} from "./pages/tools/voltage-drop/voltage-drop.component";
import {SolarSystemPrintoutComponent} from "./solar-system/printout/solar-system-printout.component"; import {SolarSystemPrintoutComponent} from "./pages/tools/solar-system/printout/solar-system-printout.component";
import {NumbersOverviewComponent} from "./pages/tools/numbers/overview/numbers-overview.component";
import {ProfileComponent} from "./pages/profile/profile.component";
import {NumbersSessionComponent} from "./pages/tools/numbers/session/numbers-session.component";
import {UserComponent} from "./pages/user/user.component";
export const routes: Routes = [ export const routes: Routes = [
{path: 'SolarSystemPrintout', component: SolarSystemPrintoutComponent}, {path: 'SolarSystemPrintout', component: SolarSystemPrintoutComponent},
{path: 'SolarSystem', component: SolarSystemComponent}, {path: 'SolarSystem', component: SolarSystemComponent},
{path: 'VoltageDrop', component: VoltageDropComponent}, {path: 'VoltageDrop', component: VoltageDropComponent},
{path: 'Numbers', component: NumbersOverviewComponent},
{path: 'Numbers/Session', component: NumbersSessionComponent},
{path: 'User', component: UserComponent},
{path: 'Profile', component: ProfileComponent},
// historic // historic
{path: 'Solar', redirectTo: '/SolarSystem'}, {path: 'Solar', redirectTo: '/SolarSystem'},
// fallback // fallback
{path: '**', redirectTo: '/Solar'}, {path: '**', redirectTo: '/SolarSystem'},
]; ];

View File

@ -0,0 +1,5 @@
<ng-container *ngIf="userService.user !== null">
<h1>Profil</h1>
<app-text [initial]="userService.user.name" (onChange)="userService.changeName($event)"></app-text>
<app-session-list [sessions]="userService.user.sessions"></app-session-list>
</ng-container>

View File

@ -0,0 +1,29 @@
import {Component} from '@angular/core';
import {NgForOf, NgIf} from "@angular/common";
import {UserService} from "../../api/User/user.service";
import {FormsModule} from "@angular/forms";
import {TextComponent} from "../../shared/text/text.component";
import {SessionListComponent} from "../tools/numbers/shared/session-list/session-list.component";
@Component({
selector: 'app-profile',
standalone: true,
imports: [
NgForOf,
NgIf,
FormsModule,
TextComponent,
SessionListComponent
],
templateUrl: './profile.component.html',
styleUrl: './profile.component.less'
})
export class ProfileComponent {
constructor(
protected readonly userService: UserService,
) {
// -
}
}

View File

@ -0,0 +1,2 @@
@import "../../../../tile.less";
@import "../../user/user.less";

View File

@ -0,0 +1,25 @@
<div class="tileContainer">
<div class="tile" *ngIf="userService.user">
<div class="tileInner">
<div class="tileTitle">
Teilnahmen
</div>
<div class="tileContent">
<app-session-list [sessions]="userService.user.sessions"></app-session-list>
</div>
</div>
</div>
<div class="tile">
<div class="tileInner">
<div class="tileTitle">
Neues Spiel erstellen
</div>
<div class="tileContent">
<button (click)="create()">+ Neu</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1 @@
@import "../numbers-common.less";

View File

@ -0,0 +1,32 @@
import {Component} from '@angular/core';
import {UserService} from "../../../../api/User/user.service";
import {DatePipe, NgForOf, NgIf} from "@angular/common";
import {NumbersSessionService} from "../../../../api/tools/Numbers/Session/numbers-session.service";
import {SessionListComponent} from "../shared/session-list/session-list.component";
@Component({
selector: 'app-numbers-overview',
standalone: true,
imports: [
NgForOf,
NgIf,
DatePipe,
SessionListComponent
],
templateUrl: './numbers-overview.component.html',
styleUrl: './numbers-overview.component.less'
})
export class NumbersOverviewComponent {
constructor(
protected readonly userService: UserService,
protected readonly numberSessionService: NumbersSessionService,
) {
// -
}
create() {
this.numberSessionService.create(session => this.numberSessionService.goto(session));
}
}

View File

@ -0,0 +1,38 @@
<ng-container *ngIf="session !== null">
<h1>{{ session.typeDisplayName }}</h1>
<table>
<tr>
<th>Erstellt</th>
<td>{{ session.created | date:'yyyy-MM-dd HH:mm' }}</td>
</tr>
<tr>
<th>Titel</th>
<td>
<app-text *ngIf="userService.owns(session)" [initial]="session.title" (onChange)="changeTitle(session, $event)"></app-text>
<ng-container *ngIf="!userService.owns(session)"></ng-container>
</td>
</tr>
<tr>
<th>Passwort</th>
<td>
<app-text *ngIf="userService.owns(session)" [initial]="session.password" (onChange)="changePassword(session, $event)"></app-text>
<ng-container *ngIf="!userService.owns(session)"></ng-container>
</td>
</tr>
<tr>
<th>Besitzer</th>
<td class="user" (click)="userService.goto(session.owner)">{{ session.owner.name }}</td>
</tr>
<tr>
<th>Teilnehmer</th>
<td>
<div class="user" *ngFor="let user of session.users" (click)="userService.goto(user)">{{ user.name }}</div>
</td>
</tr>
</table>
</ng-container>
<ng-container *ngIf="uuid && !session && accessDenied">
<h1>Passwort</h1>
<input type="text" [(ngModel)]="password" (keydown.enter)="join()">
</ng-container>

View File

@ -0,0 +1 @@
@import "../numbers-common.less";

View File

@ -0,0 +1,76 @@
import {Component, OnInit} from '@angular/core';
import {ActivatedRoute, Router} from "@angular/router";
import {NumbersSession} from "../../../../api/tools/Numbers/Session/NumbersSession";
import {DatePipe, NgForOf, NgIf} from "@angular/common";
import {UserService} from "../../../../api/User/user.service";
import {NumbersSessionService} from "../../../../api/tools/Numbers/Session/numbers-session.service";
import {FormsModule} from "@angular/forms";
import {TextComponent} from "../../../../shared/text/text.component";
@Component({
selector: 'app-numbers-session',
standalone: true,
imports: [
NgForOf,
NgIf,
DatePipe,
FormsModule,
TextComponent
],
templateUrl: './numbers-session.component.html',
styleUrl: './numbers-session.component.less'
})
export class NumbersSessionComponent implements OnInit {
protected session: NumbersSession | null = null;
protected uuid: string | null = null;
protected accessDenied: boolean | null = null;
protected password: string = "";
constructor(
protected readonly router: Router,
protected readonly activatedRoute: ActivatedRoute,
protected readonly numbersSessionService: NumbersSessionService,
protected readonly userService: UserService,
) {
// -
}
ngOnInit(): void {
this.password = "";
this.accessDenied = null;
this.activatedRoute.params.subscribe(params => {
this.uuid = params['uuid'];
if (this.uuid) {
this.numbersSessionService.canAccess(this.uuid, canAccess => {
this.accessDenied = !canAccess;
if (canAccess && this.uuid) {
this.numbersSessionService.get(this.uuid, session => this.setSession(session));
}
});
}
});
}
private setSession(session: NumbersSession): void {
this.session = session;
}
protected join() {
if (this.uuid) {
this.numbersSessionService.join(this.uuid, this.password, session => this.setSession(session));
}
}
protected changeTitle(session: NumbersSession, title: string) {
this.numbersSessionService.changeTitle(session, title, session => this.setSession(session));
}
protected changePassword(session: NumbersSession, password: string) {
this.numbersSessionService.changePassword(session, password, session => this.setSession(session));
}
}

View File

@ -0,0 +1,20 @@
<table>
<tr>
<th>Typ</th>
<th>Titel</th>
<th>Besitzer</th>
<th>Erstellt</th>
<th>Teilnehmer</th>
</tr>
<tr *ngFor="let session of sessions" (click)="numbersSessionService.goto(session)">
<td>{{ session.typeDisplayName }}</td>
<td>{{ session.title }}</td>
<td class="user" (click)="userService.goto(session.owner)">{{ session.owner.name }}</td>
<td>{{ session.created | date:'yyyy-MM-dd hh:mm' }}</td>
<td>
<div class="user" *ngFor="let user of session.users" (click)="userService.goto(user)">
{{ user.name }}
</div>
</td>
</tr>
</table>

View File

@ -0,0 +1 @@
@import "../../numbers-common.less";

View File

@ -0,0 +1,32 @@
import {Component, Input} from '@angular/core';
import {UserService} from "../../../../../api/User/user.service";
import {NumbersSessionService} from "../../../../../api/tools/Numbers/Session/numbers-session.service";
import {AbstractSession} from "../../../../../api/Session/AbstractSession";
import {DatePipe, NgForOf} from "@angular/common";
@Component({
selector: 'app-session-list',
standalone: true,
imports: [
DatePipe,
NgForOf
],
templateUrl: './session-list.component.html',
styleUrl: './session-list.component.less'
})
export class SessionListComponent {
@Input() sessions!: AbstractSession[];
constructor(
protected readonly userService: UserService,
protected readonly numbersSessionService: NumbersSessionService,
) {
// -
}
create() {
this.numbersSessionService.create(session => this.numbersSessionService.goto(session));
}
}

View File

@ -3,7 +3,7 @@ import {BODIES, BODIES_PRINT, EARTH, EARTH_MOON, JUPITER, JUPITER_SCALED_DIAMETE
import {ActivatedRoute, Params} from "@angular/router"; import {ActivatedRoute, Params} from "@angular/router";
import {DecimalPipe, NgForOf, NgIf} from "@angular/common"; import {DecimalPipe, NgForOf, NgIf} from "@angular/common";
import {MIO_KILO, SolarSystemBody} from "../SolarSystemBody"; import {MIO_KILO, SolarSystemBody} from "../SolarSystemBody";
import {Unit} from "../../Unit"; import {Unit} from "../../../../Unit";
function getScale(params: Params) { function getScale(params: Params) {
if ('scale' in params) { if ('scale' in params) {
@ -41,12 +41,10 @@ export class SolarSystemPrintoutComponent implements OnInit {
public readonly PRINTABLE_ONLY: boolean = true; public readonly PRINTABLE_ONLY: boolean = true;
protected readonly BODIES_PRINT = BODIES_PRINT;
protected readonly SolarSystemBody = SolarSystemBody;
protected readonly makePaler = makePaler; protected readonly makePaler = makePaler;
protected readonly BODIES_PRINT = BODIES_PRINT;
protected readonly MIO_KILO = MIO_KILO; protected readonly MIO_KILO = MIO_KILO;
protected readonly EARTH = EARTH; protected readonly EARTH = EARTH;

View File

@ -1,13 +1,4 @@
@import "../../tile.less"; @import "../../../../tile.less";
table {
width: 100%;
}
td {
white-space: nowrap;
border: 0.2em solid white;
}
.name { .name {
text-align: left; text-align: left;

View File

@ -3,7 +3,7 @@ import {DecimalPipe, NgForOf} from "@angular/common";
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
import {MIO_KILO, SolarSystemBody} from "./SolarSystemBody"; import {MIO_KILO, SolarSystemBody} from "./SolarSystemBody";
import {BODIES, JUPITER, JUPITER_SCALED_DIAMETER} from "./SOLAR_SYSTEM"; import {BODIES, JUPITER, JUPITER_SCALED_DIAMETER} from "./SOLAR_SYSTEM";
import {applyPrefixUnit, Unit} from "../Unit"; import {applyPrefixUnit, Unit} from "../../../Unit";
import {RouterLink} from "@angular/router"; import {RouterLink} from "@angular/router";
@Component({ @Component({

View File

@ -1,4 +1,4 @@
@import "../../tile.less"; @import "../../../../tile.less";
#VoltageDropInputs { #VoltageDropInputs {
@ -18,14 +18,14 @@
} }
.shortcuts { .shortcuts {
padding-top: calc(@space / 2); padding-top: @halfSpace;
padding-bottom: calc(@space * 2); padding-bottom: calc(@space * 2);
.shortcut { .shortcut {
float: left; float: left;
font-size: 80%; font-size: 80%;
padding: calc(@space / 2) @space; padding: @halfSpace @space;
margin-right: calc(@space / 2); margin-right: @halfSpace;
background-color: lightskyblue; background-color: lightskyblue;
border-radius: @space; border-radius: @space;
} }

View File

@ -0,0 +1,4 @@
<ng-container *ngIf="user !== null">
<h2>Benutzer: {{ user.name }}</h2>
<app-session-list [sessions]="user.commonSessions"></app-session-list>
</ng-container>

View File

@ -0,0 +1,39 @@
import {Component, OnInit} from '@angular/core';
import {NgForOf, NgIf} from "@angular/common";
import {ActivatedRoute} from "@angular/router";
import {UserService} from "../../api/User/user.service";
import {SessionListComponent} from "../tools/numbers/shared/session-list/session-list.component";
import {UserCommon} from "../../api/User/UserCommon";
@Component({
selector: 'app-user',
standalone: true,
imports: [
NgForOf,
NgIf,
SessionListComponent
],
templateUrl: './user.component.html',
styleUrl: './user.component.less'
})
export class UserComponent implements OnInit {
protected user: UserCommon | null = null;
constructor(
protected readonly activatedRoute: ActivatedRoute,
protected readonly userService: UserService,
) {
// -
}
ngOnInit(): void {
this.activatedRoute.params.subscribe(params => {
const uuid = params['uuid'];
if (uuid) {
this.userService.getCommonByUuid(uuid, user => this.user = user);
}
});
}
}

View File

@ -0,0 +1,10 @@
.user {
float: left;
padding: @halfSpace;
background-color: lightskyblue;
border-radius: @halfSpace;
}
.user:hover {
background-color: dodgerblue;
}

View File

@ -0,0 +1 @@
<input [(ngModel)]="model" (focus)="begin()" (blur)="apply()" (keydown.enter)="apply()" (keydown.escape)="abort()">

View File

@ -0,0 +1,56 @@
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';
import {FormsModule} from "@angular/forms";
@Component({
selector: 'app-text',
standalone: true,
imports: [
FormsModule
],
templateUrl: './text.component.html',
styleUrl: './text.component.less'
})
export class TextComponent implements OnInit {
private _initial: string = "";
protected model: string = this._initial;
protected editing: boolean = false;
@Output()
readonly onChange = new EventEmitter<string>();
@Input()
set initial(value: string) {
this._initial = value;
if (!this.editing) {
this.model = this._initial;
}
}
ngOnInit(): void {
this.model = this._initial;
}
begin() {
this.editing = true;
}
apply() {
if (this.model !== this._initial) {
this.onChange.emit(this.model);
}
this.end();
}
abort() {
this.model = this._initial;
this.end();
}
end() {
this.editing = false;
}
}

View File

@ -1 +1,2 @@
@space: 0.5em; @space: 0.5em;
@halfSpace: calc(@space / 2);

View File

@ -0,0 +1,7 @@
export const environment = {
production: true,
host: window.location.host.split(":")[0],
port: window.location.port,
base: 'Data/',
secure: window.location.protocol === "https:",
};

View File

@ -0,0 +1,8 @@
export const environment = {
production: false,
host: window.location.host.split(":")[0],
port: 8080,
base: '',
secure: window.location.protocol === "https:",
};

View File

@ -1,11 +1,11 @@
@import "config.less"; @import "config.less";
.tileContainer { .tileContainer {
padding: calc(@space / 2); padding: @halfSpace;
.tile { .tile {
width: 100%; width: 100%;
padding: calc(@space / 2); padding: @halfSpace;
.tileInner { .tileInner {
border: 1px solid #ddd; border: 1px solid #ddd;
@ -14,12 +14,22 @@
.tileTitle { .tileTitle {
font-weight: bold; font-weight: bold;
padding: calc(@space / 2) @space; padding: @halfSpace @space;
background-color: lightskyblue; background-color: lightskyblue;
} }
.tileContent { .tileContent {
padding: calc(@space / 2); padding: @halfSpace;
table {
width: 100%;
}
td {
white-space: nowrap;
border: 0.2em solid white;
}
} }
} }

View File

@ -0,0 +1,40 @@
package de.ph87.tools;
import de.ph87.tools.user.User;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import lombok.NonNull;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import java.util.Arrays;
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
public static final String USER_UUID_COOKIE_NAME = "PatrixToolsUserUuid";
@Override
public boolean supportsParameter(@NonNull final MethodParameter parameter) {
return parameter.getParameterType() == User.class;
}
@Override
public User resolveArgument(@NonNull final MethodParameter parameter, final ModelAndViewContainer mavContainer, @NonNull final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) {
if (!(webRequest instanceof final HttpServletRequest request)) {
return null;
}
final String uuid = Arrays.stream(request.getCookies()).filter(cookie -> USER_UUID_COOKIE_NAME.equalsIgnoreCase(cookie.getName())).findFirst().map(Cookie::getValue).orElse(null);
if (uuid == null) {
return null;
}
return null;
}
}

View File

@ -0,0 +1,78 @@
package de.ph87.tools.session;
import de.ph87.tools.user.User;
import de.ph87.tools.web.IWebSocketMessage;
import lombok.Getter;
import lombok.NonNull;
import lombok.Setter;
import lombok.ToString;
import java.time.ZonedDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@Getter
@ToString
public abstract class AbstractSession implements IWebSocketMessage {
@NonNull
public final String uuid = UUID.randomUUID().toString();
@NonNull
public final User owner;
@NonNull
public final ZonedDateTime created = ZonedDateTime.now();
@NonNull
@ToString.Exclude
private final Set<User> users = new HashSet<>();
@Setter
@NonNull
public String title = "Spiel ohne Namen";
@Setter
@NonNull
@ToString.Exclude
private String password = UUID.randomUUID().toString().substring(0, 4);
@Setter
private boolean initial = true;
@NonNull
private ZonedDateTime lastAccess = created;
protected AbstractSession(@NonNull final User user) {
this.owner = user;
this.join(user);
}
public void join(@NonNull final User user) {
synchronized (uuid) {
users.add(user);
touch();
user.join(this);
}
}
public void leave(@NonNull final User user) {
synchronized (uuid) {
users.remove(user);
touch();
user.leave(this);
}
}
private void touch() {
lastAccess = ZonedDateTime.now();
}
@Override
public List<Object> getWebsocketTopic() {
return List.of("Number", uuid);
}
}

View File

@ -0,0 +1,154 @@
package de.ph87.tools.session;
import de.ph87.tools.user.User;
import de.ph87.tools.user.UserService;
import jakarta.annotation.Nullable;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.ZonedDateTime;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import static de.ph87.tools.UserArgumentResolver.USER_UUID_COOKIE_NAME;
@Slf4j
@CrossOrigin
@EnableScheduling
@RequiredArgsConstructor
public abstract class AbstractSessionController<SESSION extends AbstractSession> {
private final Set<SESSION> sessions = new HashSet<>();
private final UserService userService;
private final ApplicationEventPublisher applicationEventPublisher;
private final Class<SESSION> sessionClazz;
@Scheduled(timeUnit = TimeUnit.MINUTES, initialDelay = 5, fixedRate = 5)
public void cleanUp() {
final ZonedDateTime deadline = ZonedDateTime.now().minusDays(1);
synchronized (sessions) {
sessions.removeIf(session -> session.getLastAccess().isBefore(deadline));
}
}
@GetMapping("create")
public AbstractSessionDto create(@CookieValue(name = USER_UUID_COOKIE_NAME, required = false) @Nullable final String userUuid, @NonNull final HttpServletResponse response) {
final User user = userService.getUserByUuidOrElseCreate(userUuid, response);
final Optional<SESSION> existing = user.getSessions().stream().filter(sessionClazz::isInstance).filter(AbstractSession::isInitial).map(sessionClazz::cast).min(Comparator.comparing(AbstractSession::getCreated));
if (existing.isPresent()) {
log.info("User wanted to create new Session but found existing: {}", existing);
return toDto(existing.get(), user);
}
final SESSION session = create(user);
log.info("Session CREATED: {}", session);
synchronized (sessions) {
sessions.add(session);
}
applicationEventPublisher.publishEvent(session);
applicationEventPublisher.publishEvent(user);
return toDto(session, user);
}
@PostMapping("canAccess")
public boolean canAccess(@CookieValue(name = USER_UUID_COOKIE_NAME, required = false) @Nullable final String userUuid, @RequestBody final String sessionUuid) {
if (userUuid == null) {
return false;
}
return userService.findByPrivateUuid(userUuid).flatMap(user -> findUserSession(user, sessionUuid)).isPresent();
}
@PostMapping("get")
public AbstractSessionDto get(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @RequestBody final String sessionUuid) {
final User user = userService.getByPrivateUuidOrThrow(userUuid);
final SESSION session = getUserSession(user, sessionUuid);
return toDto(session, user);
}
@PostMapping("join")
public AbstractSessionDto join(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @RequestBody final SessionJoinInbound inbound) {
final User user = userService.getByPrivateUuidOrThrow(userUuid);
final SESSION session = getSessionByUuid(inbound.uuid);
if (!session.getPassword().equals(inbound.password)) {
log.error("Wrong password: user={}, session={}", user, session);
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
session.join(user);
applicationEventPublisher.publishEvent(session);
applicationEventPublisher.publishEvent(user);
return toDto(session, user);
}
@PostMapping("changeTitle")
public AbstractSessionDto changeTitle(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @RequestBody final SessionChangeTitleInbound inbound) {
final User user = userService.getByPrivateUuidOrThrow(userUuid);
final SESSION session = getUserSession(user, inbound.uuid);
if (session.owner != user) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
session.setTitle(inbound.title);
applicationEventPublisher.publishEvent(session);
return toDto(session, user);
}
@PostMapping("changePassword")
public AbstractSessionDto changePassword(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @RequestBody final SessionChangePasswordInbound inbound) {
final User user = userService.getByPrivateUuidOrThrow(userUuid);
final SESSION session = getUserSession(user, inbound.uuid);
if (session.owner != user) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
session.setPassword(inbound.password);
applicationEventPublisher.publishEvent(session);
return toDto(session, user);
}
@PostMapping("leave")
public void leave(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @RequestBody final String sessionUuid) {
final User user = userService.getByPrivateUuidOrThrow(userUuid);
final SESSION session = getSessionByUuid(sessionUuid);
session.leave(user);
applicationEventPublisher.publishEvent(session);
applicationEventPublisher.publishEvent(user);
}
@NonNull
private SESSION getUserSession(@NonNull final User user, @NonNull final String sessionUuid) {
return findUserSession(user, sessionUuid).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST));
}
@NonNull
private Optional<SESSION> findUserSession(@NonNull final User user, @NonNull final String sessionUuid) {
return user.getSessions().stream().filter(s -> s.uuid.equals(sessionUuid)).filter(sessionClazz::isInstance).map(sessionClazz::cast).findFirst();
}
@NonNull
private SESSION getSessionByUuid(@NonNull final String uuid) {
synchronized (sessions) {
return sessions.stream().filter(u -> u.getUuid().equals(uuid)).findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST));
}
}
@NonNull
protected abstract SESSION create(@NonNull final User user);
@NonNull
protected abstract AbstractSessionDto toDto(@NonNull final SESSION session, @NonNull final User forUser);
}

View File

@ -0,0 +1,60 @@
package de.ph87.tools.session;
import de.ph87.tools.tools.numbers.NumbersSession;
import de.ph87.tools.tools.numbers.NumbersSessionDto;
import de.ph87.tools.user.UserPublicDto;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import java.time.ZonedDateTime;
import java.util.Set;
import java.util.stream.Collectors;
@Getter
@ToString
public abstract class AbstractSessionDto {
@NonNull
private final String type;
@NonNull
public final String uuid;
@NonNull
public final String title;
@NonNull
public final ZonedDateTime created;
@NonNull
public final String password;
@NonNull
public final UserPublicDto owner;
@NonNull
public final Set<UserPublicDto> users;
public final boolean initial;
protected AbstractSessionDto(@NonNull final AbstractSession session, @NonNull final String type) {
this.type = type;
this.uuid = session.uuid;
this.title = session.title;
this.created = session.created;
this.password = session.getPassword();
this.owner = new UserPublicDto(session.owner);
this.users = session.getUsers().stream().map(UserPublicDto::new).collect(Collectors.toSet());
this.initial = session.isInitial();
}
@NonNull
public static AbstractSessionDto toDto(@NonNull final AbstractSession session) {
return switch (session) {
case NumbersSession numbersSession -> new NumbersSessionDto(numbersSession);
default -> throw new IllegalStateException("No DTO mapping for: " + session);
};
}
}

View File

@ -0,0 +1,19 @@
package de.ph87.tools.session;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
@Getter
@ToString
@AllArgsConstructor
public class SessionChangePasswordInbound {
@NonNull
public final String uuid;
@NonNull
public final String password;
}

View File

@ -0,0 +1,19 @@
package de.ph87.tools.session;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
@Getter
@ToString
@AllArgsConstructor
public class SessionChangeTitleInbound {
@NonNull
public final String uuid;
@NonNull
public final String title;
}

View File

@ -0,0 +1,19 @@
package de.ph87.tools.session;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
@Getter
@ToString
@AllArgsConstructor
public class SessionJoinInbound {
@NonNull
public final String uuid;
@NonNull
public final String password;
}

View File

@ -0,0 +1,32 @@
package de.ph87.tools.tools.numbers;
import de.ph87.tools.session.AbstractSessionController;
import de.ph87.tools.session.AbstractSessionDto;
import de.ph87.tools.user.User;
import de.ph87.tools.user.UserService;
import lombok.NonNull;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("Numbers")
public class NumbersController extends AbstractSessionController<NumbersSession> {
public NumbersController(final UserService userService, final ApplicationEventPublisher applicationEventPublisher) {
super(userService, applicationEventPublisher, NumbersSession.class);
}
@NonNull
@Override
protected NumbersSession create(@NonNull final User user) {
return new NumbersSession(user);
}
@NonNull
@Override
protected AbstractSessionDto toDto(@NonNull final NumbersSession session, @NonNull final User forUser) {
return new NumbersSessionDto(session);
}
}

View File

@ -0,0 +1,13 @@
package de.ph87.tools.tools.numbers;
import de.ph87.tools.session.AbstractSession;
import de.ph87.tools.user.User;
import lombok.NonNull;
public class NumbersSession extends AbstractSession {
protected NumbersSession(@NonNull final User user) {
super(user);
}
}

View File

@ -0,0 +1,16 @@
package de.ph87.tools.tools.numbers;
import de.ph87.tools.session.AbstractSessionDto;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
@Getter
@ToString(callSuper = true)
public class NumbersSessionDto extends AbstractSessionDto {
public NumbersSessionDto(@NonNull final NumbersSession session) {
super(session, "Numbers");
}
}

View File

@ -0,0 +1,62 @@
package de.ph87.tools.user;
import com.fasterxml.jackson.annotation.JsonIgnore;
import de.ph87.tools.session.AbstractSession;
import de.ph87.tools.web.IWebSocketMessage;
import lombok.Getter;
import lombok.NonNull;
import lombok.Setter;
import lombok.ToString;
import java.time.ZonedDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@Getter
@ToString
public class User implements IWebSocketMessage {
@NonNull
public final String privateUuid = UUID.randomUUID().toString();
@NonNull
public final String publicUuid = UUID.randomUUID().toString();
public final ZonedDateTime created = ZonedDateTime.now();
@JsonIgnore
@ToString.Exclude
private final Set<AbstractSession> sessions = new HashSet<>();
private ZonedDateTime lastAccess = created;
@Setter
@NonNull
public String name = "unnamed";
private void touch() {
lastAccess = ZonedDateTime.now();
}
public void join(@NonNull final AbstractSession session) {
synchronized (privateUuid) {
sessions.add(session);
touch();
}
}
public void leave(@NonNull final AbstractSession session) {
synchronized (privateUuid) {
sessions.remove(session);
touch();
}
}
@Override
public List<Object> getWebsocketTopic() {
return List.of("User", privateUuid);
}
}

View File

@ -0,0 +1,34 @@
package de.ph87.tools.user;
import de.ph87.tools.session.AbstractSessionDto;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import java.util.Set;
import java.util.stream.Collectors;
@Getter
@ToString
public class UserCommonDto {
@NonNull
public final String publicUuid;
@NonNull
public final String name;
@NonNull
public final Set<AbstractSessionDto> commonSessions;
public UserCommonDto(@NonNull final User target, @Nullable final User principal) {
this.publicUuid = target.getPublicUuid();
this.name = target.getName();
this.commonSessions = target.getSessions().stream()
.filter(session -> session.getUsers().contains(principal))
.map(AbstractSessionDto::toDto)
.collect(Collectors.toSet());
}
}

View File

@ -0,0 +1,44 @@
package de.ph87.tools.user;
import jakarta.annotation.Nullable;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import static de.ph87.tools.UserArgumentResolver.USER_UUID_COOKIE_NAME;
@Slf4j
@CrossOrigin
@RestController
@RequiredArgsConstructor
@RequestMapping("User")
public class UserController {
private final UserService userService;
@Nullable
@GetMapping("whoAmI")
public UserPrivateDto whoAmI(@CookieValue(name = USER_UUID_COOKIE_NAME, required = false) @Nullable final String userUuid, @NonNull final HttpServletResponse response) {
return userService.getUserByUuidOrNull(userUuid, response);
}
@GetMapping("delete")
public void delete(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @NonNull final HttpServletResponse response) {
userService.delete(userUuid, response);
}
@NonNull
@PostMapping("changeName")
public UserPrivateDto changeName(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @NonNull @RequestBody final String name) {
return userService.changeName(userUuid, name);
}
@NonNull
@PostMapping("getCommonByUuid")
public UserCommonDto getCommonByUuid(@CookieValue(name = USER_UUID_COOKIE_NAME, required = false) @Nullable final String userUuid, @NonNull @RequestBody final String targetUuid) {
return userService.getCommonByUuid(userUuid, targetUuid);
}
}

View File

@ -0,0 +1,48 @@
package de.ph87.tools.user;
import de.ph87.tools.session.AbstractSessionDto;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import java.time.ZonedDateTime;
import java.util.Set;
import java.util.stream.Collectors;
@Getter
@ToString
public class UserPrivateDto {
@NonNull
public final String publicUuid;
@NonNull
public final String name;
@NonNull
public final String privateUuid;
@NonNull
public final ZonedDateTime created;
@NonNull
private final Set<AbstractSessionDto> sessions;
public UserPrivateDto(@NonNull final User user) {
this.publicUuid = user.getPublicUuid();
this.name = user.getName();
this.privateUuid = user.privateUuid;
this.created = user.created;
this.sessions = user.getSessions().stream().map(AbstractSessionDto::toDto).collect(Collectors.toSet());
}
@Nullable
public static UserPrivateDto orNull(@Nullable final User user) {
if (user == null) {
return null;
}
return new UserPrivateDto(user);
}
}

View File

@ -0,0 +1,30 @@
package de.ph87.tools.user;
import de.ph87.tools.web.IWebSocketMessage;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import java.util.List;
@Getter
@ToString
public class UserPublicDto implements IWebSocketMessage {
@NonNull
public final String publicUuid;
@NonNull
public final String name;
public UserPublicDto(@NonNull final User user) {
this.publicUuid = user.getPublicUuid();
this.name = user.getName();
}
@Override
public List<Object> getWebsocketTopic() {
return List.of("User", publicUuid);
}
}

View File

@ -0,0 +1,115 @@
package de.ph87.tools.user;
import jakarta.annotation.Nullable;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import static de.ph87.tools.UserArgumentResolver.USER_UUID_COOKIE_NAME;
@Slf4j
@Service
@EnableScheduling
@RequiredArgsConstructor
public class UserService {
private final ApplicationEventPublisher applicationEventPublisher;
private final Set<User> users = new HashSet<>();
@NonNull
public User getUserByUuidOrElseCreate(@Nullable final String uuid, @NonNull final HttpServletResponse response) {
synchronized (users) {
final User user = Optional.ofNullable(uuid).map(this::findByPrivateUuid).filter(Optional::isPresent).map(Optional::get).orElseGet(this::create);
writeUserUuidCookie(response, user);
return user;
}
}
@Nullable
public UserPrivateDto getUserByUuidOrNull(@Nullable final String userUuid, final @NonNull HttpServletResponse response) {
if (userUuid == null || userUuid.isEmpty()) {
return null;
}
final User user = findByPrivateUuid(userUuid).orElse(null);
writeUserUuidCookie(response, user);
return UserPrivateDto.orNull(user);
}
public void delete(@NonNull final String userUuid, final @NonNull HttpServletResponse response) {
synchronized (users) {
final User user = findByPrivateUuid(userUuid).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST));
users.remove(user);
log.info("User DELETED: {}", user);
}
writeUserUuidCookie(response, null);
}
@NonNull
public User getByPrivateUuidOrThrow(@NonNull final String uuid) {
synchronized (users) {
return findByPrivateUuid(uuid).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST));
}
}
@NonNull
public User getByPublicUuid(@NonNull final String uuid) {
synchronized (users) {
return findByPublicUuid(uuid).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST));
}
}
@NonNull
public Optional<User> findByPrivateUuid(@NonNull final String uuid) {
return users.stream().filter(u -> u.getPrivateUuid().equals(uuid)).findFirst();
}
@NonNull
public Optional<User> findByPublicUuid(@NonNull final String uuid) {
return users.stream().filter(u -> u.getPublicUuid().equals(uuid)).findFirst();
}
@NonNull
private User create() {
final User user = new User();
users.add(user);
log.info("User CREATED: {}", user);
return user;
}
private static void writeUserUuidCookie(@NonNull final HttpServletResponse response, @Nullable final User user) {
final Cookie userUuidCookie = new Cookie(USER_UUID_COOKIE_NAME, "");
if (user != null) {
userUuidCookie.setValue(user.privateUuid);
}
userUuidCookie.setPath("/");
response.addCookie(userUuidCookie);
}
@NonNull
public UserPrivateDto changeName(@NonNull final String uuid, @NonNull final String name) {
final User user = getByPrivateUuidOrThrow(uuid);
user.setName(name);
applicationEventPublisher.publishEvent(new UserPublicDto(user));
return new UserPrivateDto(user);
}
@NonNull
public UserCommonDto getCommonByUuid(@Nullable final String principalUuid, @NonNull final String targetUuid) {
final User principal = principalUuid == null ? null : findByPrivateUuid(principalUuid).orElse(null);
final User target = getByPublicUuid(targetUuid);
return new UserCommonDto(target, principal);
}
}

View File

@ -0,0 +1,12 @@
package de.ph87.tools.web;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.List;
public interface IWebSocketMessage {
@JsonIgnore
List<Object> getWebsocketTopic();
}

View File

@ -1,24 +1,32 @@
package de.ph87.tools.web; package de.ph87.tools.web;
import de.ph87.tools.UserArgumentResolver;
import lombok.NonNull; import lombok.NonNull;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver; import org.springframework.web.servlet.resource.PathResourceResolver;
import java.io.IOException; import java.io.IOException;
import java.util.List;
@Configuration @Configuration
@RequiredArgsConstructor @RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer { public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(new UserArgumentResolver());
}
@Override @Override
public void addCorsMappings(CorsRegistry registry) { public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**").allowedOrigins("*").allowedMethods("*"); registry.addMapping("/**").allowCredentials(true).allowedOriginPatterns("*").allowedMethods("*");
} }
@Override @Override

View File

@ -0,0 +1,35 @@
package de.ph87.tools.web;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@CrossOrigin
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
public static final String DESTINATION = "";
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOrigins("*");
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker(DESTINATION).setHeartbeatValue(new long[]{2000, 2000}).setTaskScheduler(heartBeatScheduler());
}
@Bean
public TaskScheduler heartBeatScheduler() {
return new ThreadPoolTaskScheduler();
}
}

View File

@ -0,0 +1,25 @@
package de.ph87.tools.web;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.lang.NonNull;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.stereotype.Service;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class WebSocketService {
private final SimpMessageSendingOperations simpMessageSendingOperations;
@EventListener(IWebSocketMessage.class)
public void send(@NonNull final IWebSocketMessage message) {
final String topic = message.getWebsocketTopic().stream().map(Object::toString).collect(Collectors.joining("/"));
simpMessageSendingOperations.convertAndSend(topic, message);
}
}