From 49e857f258c1c3e56a591a79adbeccfd17f0eb69 Mon Sep 17 00:00:00 2001 From: Martin Asprusten Date: Sun, 12 Apr 2026 01:28:25 +0200 Subject: [PATCH] Initial commit, working server --- .env.template | 3 + .gitignore | 3 + Dockerfile | 22 + README.md | 5 + client/.gitignore | 24 + client/index.html | 203 +++ client/package-lock.json | 1124 +++++++++++++++++ client/package.json | 23 + client/public/favicon.svg | 7 + client/src/global.d.ts | 5 + client/src/main.ts | 293 +++++ client/src/message.d.ts | 147 +++ client/src/openid.d.ts | 21 + client/src/style.css | 103 ++ client/tsconfig.json | 23 + docker-compose.yaml | 34 + messages/openid.schema.json | 18 + messages/server-message.schema.json | 180 +++ server/.gitignore | 90 ++ server/.mvn/jvm.config | 0 server/.mvn/maven.config | 0 server/pom.xml | 102 ++ .../sykkelaksjon/PropertiesListener.java | 60 + .../no/asprusten/sykkelaksjon/Server.java | 354 ++++++ .../sykkelaksjon/db/datatypes/Activity.java | 67 + .../db/datatypes/ActivityTemplate.java | 70 + .../db/datatypes/ActivityType.java | 58 + .../sykkelaksjon/db/datatypes/WebUser.java | 88 ++ .../db/repositories/ActivityRepository.java | 7 + .../ActivityTemplateRepository.java | 7 + .../repositories/ActivityTypeRepository.java | 7 + .../db/repositories/WebUserRepository.java | 12 + .../db/services/ActivityService.java | 26 + .../db/services/ActivityTemplateService.java | 26 + .../db/services/ActivityTypeService.java | 31 + .../sykkelaksjon/db/services/UserService.java | 59 + .../sykkelaksjon/security/SecurityConfig.java | 45 + .../security/ServerExceptionHandler.java | 17 + .../src/main/resources/application.properties | 11 + 39 files changed, 3375 insertions(+) create mode 100644 .env.template create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 client/.gitignore create mode 100644 client/index.html create mode 100644 client/package-lock.json create mode 100644 client/package.json create mode 100644 client/public/favicon.svg create mode 100644 client/src/global.d.ts create mode 100644 client/src/main.ts create mode 100644 client/src/message.d.ts create mode 100644 client/src/openid.d.ts create mode 100644 client/src/style.css create mode 100644 client/tsconfig.json create mode 100644 docker-compose.yaml create mode 100644 messages/openid.schema.json create mode 100644 messages/server-message.schema.json create mode 100644 server/.gitignore create mode 100644 server/.mvn/jvm.config create mode 100644 server/.mvn/maven.config create mode 100644 server/pom.xml create mode 100644 server/src/main/java/no/asprusten/sykkelaksjon/PropertiesListener.java create mode 100644 server/src/main/java/no/asprusten/sykkelaksjon/Server.java create mode 100644 server/src/main/java/no/asprusten/sykkelaksjon/db/datatypes/Activity.java create mode 100644 server/src/main/java/no/asprusten/sykkelaksjon/db/datatypes/ActivityTemplate.java create mode 100644 server/src/main/java/no/asprusten/sykkelaksjon/db/datatypes/ActivityType.java create mode 100644 server/src/main/java/no/asprusten/sykkelaksjon/db/datatypes/WebUser.java create mode 100644 server/src/main/java/no/asprusten/sykkelaksjon/db/repositories/ActivityRepository.java create mode 100644 server/src/main/java/no/asprusten/sykkelaksjon/db/repositories/ActivityTemplateRepository.java create mode 100644 server/src/main/java/no/asprusten/sykkelaksjon/db/repositories/ActivityTypeRepository.java create mode 100644 server/src/main/java/no/asprusten/sykkelaksjon/db/repositories/WebUserRepository.java create mode 100644 server/src/main/java/no/asprusten/sykkelaksjon/db/services/ActivityService.java create mode 100644 server/src/main/java/no/asprusten/sykkelaksjon/db/services/ActivityTemplateService.java create mode 100644 server/src/main/java/no/asprusten/sykkelaksjon/db/services/ActivityTypeService.java create mode 100644 server/src/main/java/no/asprusten/sykkelaksjon/db/services/UserService.java create mode 100644 server/src/main/java/no/asprusten/sykkelaksjon/security/SecurityConfig.java create mode 100644 server/src/main/java/no/asprusten/sykkelaksjon/security/ServerExceptionHandler.java create mode 100644 server/src/main/resources/application.properties diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..5f944fd --- /dev/null +++ b/.env.template @@ -0,0 +1,3 @@ +OPENID_DISCOVERY_URI= +OPENID_CLIENT_ID= +SYKKELAKSJON_INITIAL_ADMIN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16d46fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +secrets/** +db-data/** +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..58c08e0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node AS frontend-build +WORKDIR /sources/client +COPY client/package.json /sources/client/package.json +RUN npm install +COPY client/index.html client/tsconfig.json /sources/client/ +COPY client/src/ /sources/client/src/ +COPY client/public/ /sources/client/public/ +COPY messages/ /sources/messages/ +RUN npm run build + +FROM maven:3.9.14-eclipse-temurin-25-alpine AS java-build +WORKDIR /java/server/ +COPY server/pom.xml /java/server +COPY server/src/ /java/server/src/ +COPY messages/ /java/messages/ +RUN mvn package + +FROM eclipse-temurin:25-alpine-3.23 +WORKDIR /server/ +COPY --from=java-build /java/server/target/Sykkelaksjon-1.0-SNAPSHOT.jar /server/ +COPY --from=frontend-build /sources/client/dist/ /server/static +ENTRYPOINT ["java", "-jar", "/server/Sykkelaksjon-1.0-SNAPSHOT.jar"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf6adc7 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# How to run +Create a folder named secrets, and make a file in it called postgrespassword. Put a strong password in this file +Copy .env.template into a file called .env, and set the environment variables in it to something sensible. +Change the port number of the server you wish to expose in the docker compose file +Run docker compose up -d diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..2d5e514 --- /dev/null +++ b/client/index.html @@ -0,0 +1,203 @@ + + + + + + + Sykkelaksjon + + +
+

Laster data...

+
+ + + + diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 0000000..0e8bff6 --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,1124 @@ +{ + "name": "client", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "client", + "version": "0.0.0", + "dependencies": { + "alpinejs": "^3.15.11", + "openid-client": "^6.8.2" + }, + "devDependencies": { + "@types/alpinejs": "^3.13.11", + "json-schema-to-typescript": "^15.0.4", + "typescript": "~6.0.2", + "vite": "^8.0.4" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/alpinejs": { + "version": "3.13.11", + "resolved": "https://registry.npmjs.org/@types/alpinejs/-/alpinejs-3.13.11.tgz", + "integrity": "sha512-3KhGkDixCPiLdL3Z/ok1GxHwLxEWqQOKJccgaQL01wc0EVM2tCTaqlC3NIedmxAXkVzt/V6VTM8qPgnOHKJ1MA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.1.5" + } + }, + "node_modules/@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", + "license": "MIT" + }, + "node_modules/alpinejs": { + "version": "3.15.11", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.11.tgz", + "integrity": "sha512-m26gkTg/MId8O+F4jHKK3vB3SjbFxxk/JHP+qzmw1H6aQrZuPAg4CUoAefnASzzp/eNroBjrRQe7950bNeaBJw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.1.1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-to-typescript": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "is-glob": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "prettier": "^3.2.5", + "tinyglobby": "^0.2.9" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz", + "integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==", + "license": "MIT", + "dependencies": { + "jose": "^6.1.3", + "oauth4webapi": "^3.8.4" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", + "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..8e26c92 --- /dev/null +++ b/client/package.json @@ -0,0 +1,23 @@ +{ + "name": "client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "transpile": "json2ts --input ../messages/server-message.schema.json --output src/message.d.ts", + "transpile-2": "json2ts --input ../messages/openid.schema.json --output src/openid.d.ts", + "dev": "npm run transpile && npm run transpile-2 && vite", + "build": "npm run transpile && npm run transpile-2 && tsc && vite build", + "preview": "npm run transpile && npm run transpile-2 && vite preview" + }, + "devDependencies": { + "@types/alpinejs": "^3.13.11", + "json-schema-to-typescript": "^15.0.4", + "typescript": "~6.0.2", + "vite": "^8.0.4" + }, + "dependencies": { + "alpinejs": "^3.15.11", + "openid-client": "^6.8.2" + } +} diff --git a/client/public/favicon.svg b/client/public/favicon.svg new file mode 100644 index 0000000..de802ea --- /dev/null +++ b/client/public/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/client/src/global.d.ts b/client/src/global.d.ts new file mode 100644 index 0000000..4cb9e4f --- /dev/null +++ b/client/src/global.d.ts @@ -0,0 +1,5 @@ +import { Alpine as AlpineType } from 'alpinejs' + +declare global { + var Alpine: AlpineType +} \ No newline at end of file diff --git a/client/src/main.ts b/client/src/main.ts new file mode 100644 index 0000000..47d7fd6 --- /dev/null +++ b/client/src/main.ts @@ -0,0 +1,293 @@ +import type { Activity, ActivityTemplate, ActivityType, Sykkelaksjon } from './message'; +import type { SykkelaksjonOpenid } from './openid'; +import './style.css' +import Alpine from 'alpinejs' +import * as openidClient from 'openid-client'; + +// ---------------- HTML ELEMENT SETUP ---------------- +// Set the default date to today +let datepicker = document.getElementById("activity-date") as HTMLInputElement; +datepicker.valueAsDate = new Date(); + + +// Override what the activity form does +let activityForm = document.getElementById("activity-form") as HTMLFormElement; +activityForm.onsubmit = () => false; + +// Override what the activity button does +document.getElementById("submit-activity-button")?.addEventListener("click", () => submitFormToUrl(apiUrl + "/submitActivity")); +document.getElementById("submit-activity-template-button")?.addEventListener("click", () => submitFormToUrl(apiUrl + "/submitActivityTemplate")); + +let loadingDiv = document.getElementById("loading-div"); +let contentDiv = document.getElementById("content-div"); + +// ---------------- ALPINE STATE ----------------------- +interface AlpineState { + data: Sykkelaksjon, + registerTemplateActivity: (template: ActivityTemplate) => void, + deleteTemplateActivity: (template: ActivityTemplate) => void, + deleteActivity: (activity: Activity) => void, + deleteActivityType: (activityType: ActivityType) => void, + + makeAdministrator: (userId: number) => void, + removeAdministrator: (userId: number) => void, + deleteUser: (userId: number) => void, + + init: () => void, + setServerMessage: (newMessage: Sykkelaksjon) => void, + getServerMessage: () => Sykkelaksjon, +}; + +let defaultData: Sykkelaksjon = { + name: "ukjent bruker", + isAdmin: false, + unitConversionNecessary: false, + activityTypes: [], + activityTemplates: [], + activities: [], + otherUsers: [] +} + +let alpineState: AlpineState = { + data: defaultData, + registerTemplateActivity: registerTemplateActivity, + deleteTemplateActivity: deleteTemplateActivity, + deleteActivity: deleteActivity, + deleteActivityType: deleteActivityType, + + makeAdministrator: makeAdministrator, + removeAdministrator: removeAdministrator, + deleteUser: deleteUser, + + init() { + this.data = defaultData; + }, + setServerMessage(newMessage: Sykkelaksjon) { + this.data = newMessage; + }, + getServerMessage(): Sykkelaksjon { + return this.data; + } +} + +window.Alpine = Alpine +Alpine.store('state', alpineState); +Alpine.start(); + + +// ------------------- FUNCTIONS TO BE CALLED FROM THE HTML ----------------------- +function registerTemplateActivity(template: ActivityTemplate): void { + let form = new FormData(); + form.set("activity-type", template.activityType.id?.toString() ?? ""); + form.set("activity-description", template.name); + form.set("activity-distance", template.numberOfUnits.toString()); + var currentDate = new Date(); + form.set("activity-date", `${currentDate.getFullYear().toString().padStart(4, '0')}-${(currentDate.getMonth()+1).toString().padStart(2, '0')}-${currentDate.getDate().toString().padStart(2, '0')}`); + submitFormToUrl(apiUrl + "/submitActivity", form); +} + +function deleteTemplateActivity(template: ActivityTemplate): void { + let form = new FormData(); + form.set("activity-template-id", template.id?.toString() ?? ""); + submitFormToUrl(apiUrl + "/deleteActivityTemplate", form, "DELETE"); +} + +function deleteActivity(activity: Activity): void { + let form = new FormData(); + form.set("activity-id", activity.id?.toString() ?? ""); + submitFormToUrl(apiUrl + "/deleteActivity", form, "DELETE"); +} + +function postActivityType(): void { + let activityTypeForm = document.getElementById("activity-type-form") as HTMLFormElement; + let activtyTypeData = new FormData(activityTypeForm); + + submitFormToUrl(apiUrl + "/addActivityType", activtyTypeData); +} + +declare global { + interface Window { postActivityType: () => void } +} +window.postActivityType = postActivityType; + +function deleteActivityType(activityType: ActivityType) { + let form = new FormData(); + form.set("activity-type-id", activityType.id?.toString() ?? ""); + submitFormToUrl(apiUrl + "/deleteActivityType", form, "DELETE"); +} + +function makeAdministrator(userId: number) { + let form = new FormData(); + form.set("user-id", userId.toString()); + submitFormToUrl(apiUrl + "/makeAdmin", form, "PUT") +} + +function removeAdministrator(userId: number) { + let form = new FormData(); + form.set("user-id", userId.toString()); + submitFormToUrl(apiUrl + "/removeAdmin", form, "PUT") +} + +function deleteUser(userId: number) { + let form = new FormData(); + form.set("user-id", userId.toString()); + submitFormToUrl(apiUrl + "/deleteUser", form, "DELETE"); +} + +// ------------------- DECIDE WHERE TO CONNECT ------------- +let apiUrl: string; + +if (import.meta.env.DEV) { + apiUrl = "http://localhost:8080/api"; +} else { + apiUrl = document.location.protocol + "//" + document.location.host + "/api"; +} + +// ------------------- OPENID CONNECT STUFF ---------------- + +let discoveryUri = localStorage.getItem("sykkelaksjon-discovery-uri"); +let clientId = localStorage.getItem("sykkelaksjon-client-id"); + +if (!discoveryUri || !clientId) { + await fetch(apiUrl + "/openid") + .then(response => response.json()) + .then(json => { + let received = json as SykkelaksjonOpenid; + discoveryUri = received.openid_discovery_uri; + clientId = received.client_id; + + localStorage.setItem("sykkelaksjon-discovery-uri", discoveryUri); + localStorage.setItem("sykkelaksjon-client-id", clientId); + }) +} + +let refreshToken: string | null = null; +let config: openidClient.Configuration | null = null; +if (discoveryUri && clientId) { + let server: URL = new URL(discoveryUri); + config = await openidClient.discovery(server, clientId); + + let getCurrentUrl: ((...args: any) => URL) = () => { + return new URL(window.location.href); + } + + let currentUrl = getCurrentUrl(); + let codeVerifier = localStorage.getItem("sykkelaksjon-codeverifier"); + + if (!currentUrl.searchParams.has("session_state") || !currentUrl.searchParams.has("iss") || !currentUrl.searchParams.has("code") || !codeVerifier) { + let redirectUri = window.location.href; + let scope = "openid profile"; + + codeVerifier = openidClient.randomPKCECodeVerifier(); + localStorage.setItem("sykkelaksjon-codeverifier", codeVerifier); + let codeChallenge = await openidClient.calculatePKCECodeChallenge(codeVerifier); + + let state: string; + + let parameters: Record = { + redirect_uri: redirectUri, + scope, + code_challenge: codeChallenge, + code_challenge_method: 'S256' + }; + + if (!config.serverMetadata().supportsPKCE()) { + state = openidClient.randomState(); + parameters.state = state; + localStorage.setItem("sykkelaksjon-state", state); + } + + let redirectTo: URL = openidClient.buildAuthorizationUrl(config, parameters); + window.location.href = redirectTo.toString(); + } else { + window.history.pushState({}, "", window.location.protocol + "//" + window.location.host); + let state = localStorage.getItem("sykkelaksjon-state"); + + let authorizationData: Record = { + pkceCodeVerifier: codeVerifier + }; + if (state) { + authorizationData['state'] = state; + } + + let tokens = await openidClient.authorizationCodeGrant( + config, + currentUrl, + authorizationData + ); + + refreshToken = tokens.refresh_token ?? null; + } +} + +// ------------------- REQUESTING DATA FROM THE API ------------------------------- +let receivedData = false; +const getAccessToken: () => Promise = async () => { + if (!refreshToken || !config) { + return null; + } + let receivedTokens = await openidClient.refreshTokenGrant(config, refreshToken); + refreshToken = receivedTokens.refresh_token ?? null; + return receivedTokens.access_token; +} + +const submitFormToUrl = (url: string, formData?: FormData, method?: string) => { + if (!refreshToken) { + return; + } + + if (!formData) { + formData = new FormData(activityForm); + } + + if (!method) { + method = 'POST'; + } + + getAccessToken().then(token => { + if (token) { + fetch( + url, + { + method: method, + body: formData, + credentials: 'include', + headers: { + "Authorization": `Bearer ${token}` + } + } + ).then(() => refreshData(token)); + } + }); + +} + +const refreshData = async (accessToken?: string) => { + if (!refreshToken) { + return; + } + + if (!accessToken) { + let possibleAccessToken = await getAccessToken(); + if (!possibleAccessToken) { + return; + } + + accessToken = possibleAccessToken; + } + + await fetch(apiUrl, {headers: {"Authorization": `Bearer ${accessToken}`}}) + .then(response => response.json()) + .then(message => { + (Alpine.store('state') as AlpineState).setServerMessage(message) + receivedData = true; + }); +} + +// Finally, refresh data +await refreshData(); +// After data is refreshed, display it +if (receivedData) { + loadingDiv?.setAttribute("style", "display: none"); + contentDiv?.setAttribute("style", "display: block"); +} \ No newline at end of file diff --git a/client/src/message.d.ts b/client/src/message.d.ts new file mode 100644 index 0000000..113463c --- /dev/null +++ b/client/src/message.d.ts @@ -0,0 +1,147 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Server message for users of the Sykkelaksjon web site + */ +export interface Sykkelaksjon { + /** + * The full name of the connected user + */ + name: string; + /** + * Whether the connected user is an administrator + */ + isAdmin: boolean; + /** + * Available types of activities + */ + activityTypes: ActivityType[]; + /** + * Whether there exists activity types that don't have a conversion factor of 1 + */ + unitConversionNecessary: boolean; + /** + * Templates that the user might apply to quickly add a new activity + */ + activityTemplates: ActivityTemplate[]; + /** + * The activities the user has performed + */ + activities: Activity[]; + /** + * Other users of the web site to create a leaderboard, and more detailed information for administrators + */ + otherUsers: { + /** + * The display name of the user + */ + name: string; + /** + * The earliest date the user has performed an activity on + */ + earliestActivity?: string; + /** + * The latest date the user has performed an activity on + */ + latestActivity?: string; + /** + * The total amount of kilometers (including converted activities) the user has covered + */ + totalKilometers?: number; + /** + * Administrator info: ther user id of the user + */ + userId?: number; + /** + * Administrator info: the username of the user + */ + userName?: string; + /** + * Administrator info: whether the user listed is still active + */ + isActive?: boolean; + /** + * Administrator info: whether the user listed is an administrator + */ + isAdmin?: boolean; + /** + * Administrator info: the activity templates this user has defined + */ + activityTemplates?: ActivityTemplate[]; + /** + * Administrator info: a detailed list of activities the user has performed + */ + activities?: Activity[]; + [k: string]: unknown; + }[]; + [k: string]: unknown; +} +/** + * An available activity the user may log + */ +export interface ActivityType { + /** + * The database ID of the activity type + */ + id?: number; + /** + * The name of the activity type + */ + name: string; + /** + * The unit this activity type is measured in + */ + unit: string; + /** + * The conversion factor from the unit the activity is measured in into kilometers + */ + conversionFactor: number; + [k: string]: unknown; +} +/** + * Templates that the user has stored in order to quickly add common activities + */ +export interface ActivityTemplate { + /** + * The database ID of the activity template + */ + id?: number; + activityType: ActivityType; + /** + * The name the user has given to this activity + */ + name: string; + /** + * The length of the activity (in units defined in the activity type) + */ + numberOfUnits: number; + [k: string]: unknown; +} +/** + * An activity a user has performed at some date + */ +export interface Activity { + /** + * The database ID of the activity + */ + id?: number; + activityType: ActivityType; + /** + * The length of the activity (in units defined in the activity type) + */ + numberOfUnits: number; + /** + * The user's description of the activity + */ + description: string; + /** + * The date the user performed the activity + */ + date: string; + [k: string]: unknown; +} diff --git a/client/src/openid.d.ts b/client/src/openid.d.ts new file mode 100644 index 0000000..e52208d --- /dev/null +++ b/client/src/openid.d.ts @@ -0,0 +1,21 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Server message telling where the openId server is + */ +export interface SykkelaksjonOpenid { + /** + * The discovery URI of the OpenID provider + */ + openid_discovery_uri: string; + /** + * The client ID of the sykkelaksjon client + */ + client_id: string; + [k: string]: unknown; +} diff --git a/client/src/style.css b/client/src/style.css new file mode 100644 index 0000000..e39dcc2 --- /dev/null +++ b/client/src/style.css @@ -0,0 +1,103 @@ +body { + text-align: center; +} + +.outer-vertical-flex { + display: flex; + flex-direction: column; + flex-wrap: wrap; + gap: 20px; + width: fit-content; + margin-left: auto; + margin-right: auto; + text-align: left; +} + +.flex-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 20px; + justify-content: center; + flex: auto; +} + +.vertical-flex { + display: flex; + flex-direction: column; + flex-wrap: wrap; + gap: 20px; +} + +.register-activity-div { + border: 1px gray solid; + padding: 10px; + line-height: 1.8; +} + +.register-activity-div input { + width: 200px; +} + +label { + display: inline-block; + width: 200px; +} + +.submit-buttons-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + margin-left: 20px; + margin-right: 20px; +} + +.standard-activities-div { + border: 1px gray solid; + padding: 10px; +} + +.registered-activities-div { + border: 1px gray solid; + padding: 10px; +} + +table { + width: 100%; +} + +table,td,th { + border: 1px solid lightgray; +} + +.registered-activities-div td { + padding-right:10px; +} + +.registered-activities-div td button { + margin-left:50px; + width: 100px; +} + +.other-users-div { + border: 1px gray solid; + padding: 10px; +} + +.admin-div { + border: 1px gray solid; + padding: 10px; +} + +details { + border: 1px solid #aaaaaa; + border-radius: 4px; + padding: 0.5em 0.5em 2px; +} + +summary { + font-weight: bold; + margin: -0.5em -0.5em 0; + padding: 0.5em; +} \ No newline at end of file diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..bc7a940 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "esnext", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..b528e6e --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,34 @@ +services: + database: + image: postgres:18.3 + restart: unless-stopped + volumes: + - ./db-data:/db-data + environment: + POSTGRES_USER: "sykkelaksjon" + POSTGRES_PASSWORD_FILE: "/run/secrets/postgrespassword" + POSTGRES_DB: "sykkelaksjon" + PGDATA: "/db-data" + secrets: + - postgrespassword +# For local debugging and development +# ports: +# - 9876:5432 + + server: + image: sykkelaksjon:latest + build: + dockerfile: ./Dockerfile + ports: + - 8080:8080 + environment: + SPRING_DATASOURCE_URL: "jdbc:postgresql://database/sykkelaksjon" + OPENID_DISCOVERY_URI: "${OPENID_DISCOVERY_URI}" + OPENID_CLIENT_ID: "${OPENID_CLIENT_ID}" + SYKKELAKSJON_INITIAL_ADMIN: "${SYKKELAKSJON_INITIAL_ADMIN}" + secrets: + - postgrespassword + +secrets: + postgrespassword: + file: secrets/postgrespassword diff --git a/messages/openid.schema.json b/messages/openid.schema.json new file mode 100644 index 0000000..6fc020d --- /dev/null +++ b/messages/openid.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "sykkelaksjon-openid", + "title": "Sykkelaksjon openid", + "description": "Server message telling where the openId server is", + "type": "object", + "properties": { + "openid_discovery_uri": { + "type": "string", + "description": "The discovery URI of the OpenID provider" + }, + "client_id": { + "type": "string", + "description": "The client ID of the sykkelaksjon client" + } + }, + "required": ["openid_discovery_uri", "client_id"] +} diff --git a/messages/server-message.schema.json b/messages/server-message.schema.json new file mode 100644 index 0000000..4717fcf --- /dev/null +++ b/messages/server-message.schema.json @@ -0,0 +1,180 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "sykkelaksjon-api", + "title": "Sykkelaksjon", + "description": "Server message for users of the Sykkelaksjon web site", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The full name of the connected user" + }, + "isAdmin": { + "type": "boolean", + "description": "Whether the connected user is an administrator" + }, + "activityTypes": { + "type": "array", + "description": "Available types of activities", + "items": { + "$ref": "#/$defs/activityType" + } + }, + "unitConversionNecessary" : { + "type": "boolean", + "description": "Whether there exists activity types that don't have a conversion factor of 1" + }, + "activityTemplates": { + "type": "array", + "description": "Templates that the user might apply to quickly add a new activity", + "items": { + "$ref": "#/$defs/activityTemplate" + } + }, + "activities": { + "type": "array", + "description": "The activities the user has performed", + "items": { + "$ref": "#/$defs/activity" + } + }, + "otherUsers": { + "type": "array", + "description": "Other users of the web site to create a leaderboard, and more detailed information for administrators", + "items": { + "type": "object", + "description": "Information about other users in the system", + "properties": { + "name": { + "type": "string", + "description": "The display name of the user" + }, + "earliestActivity": { + "$ref": "#/$defs/date", + "description": "The earliest date the user has performed an activity on" + }, + "latestActivity": { + "$ref": "#/$defs/date", + "description": "The latest date the user has performed an activity on" + }, + "totalKilometers": { + "type": "number", + "description": "The total amount of kilometers (including converted activities) the user has covered" + }, + "userId": { + "type": "integer", + "description": "Administrator info: ther user id of the user" + }, + "userName": { + "type": "string", + "description": "Administrator info: the username of the user" + }, + "isActive": { + "type": "boolean", + "description": "Administrator info: whether the user listed is still active" + }, + "isAdmin": { + "type": "boolean", + "description": "Administrator info: whether the user listed is an administrator" + }, + "activityTemplates": { + "type": "array", + "description": "Administrator info: the activity templates this user has defined", + "items": { + "$ref": "#/$defs/activityTemplate" + } + }, + "activities": { + "type": "array", + "description": "Administrator info: a detailed list of activities the user has performed", + "items": { + "$ref": "#/$defs/activity" + } + } + + }, + "required": ["name"] + } + } + }, + "required": ["name", "isAdmin", "activityTypes", "unitConversionNecessary", "activityTemplates", "activities", "otherUsers"], + + "$defs": { + "date": { + "type": "string", + "description": "A date in local time", + "pattern": "[0-9]{4}-[0-9]{2}-[0-9]{4}" + }, + "activityType": { + "type": "object", + "description": "An available activity the user may log", + "properties": { + "id": { + "type": "integer", + "description": "The database ID of the activity type" + }, + "name": { + "type": "string", + "description": "The name of the activity type" + }, + "unit": { + "type": "string", + "description": "The unit this activity type is measured in" + }, + "conversionFactor": { + "type": "number", + "description": "The conversion factor from the unit the activity is measured in into kilometers" + } + }, + "required": ["name", "unit", "conversionFactor"] + }, + "activityTemplate": { + "type": "object", + "description": "Templates that the user has stored in order to quickly add common activities", + "properties": { + "id": { + "type": "integer", + "description": "The database ID of the activity template" + }, + "activityType": { + "$ref": "#/$defs/activityType" + }, + "name": { + "type": "string", + "description": "The name the user has given to this activity" + }, + "numberOfUnits": { + "type": "number", + "description": "The length of the activity (in units defined in the activity type)" + } + }, + "required": ["activityType", "name", "numberOfUnits"] + }, + "activity": { + "type": "object", + "description": "An activity a user has performed at some date", + "properties": { + "id": { + "type": "integer", + "description": "The database ID of the activity" + }, + "activityType": { + "$ref": "#/$defs/activityType" + }, + "numberOfUnits": { + "type": "number", + "description": "The length of the activity (in units defined in the activity type)" + }, + "description": { + "type": "string", + "description": "The user's description of the activity" + }, + "date": { + "$ref": "#/$defs/date", + "description": "The date the user performed the activity" + } + }, + "required": ["activityType", "numberOfUnits", "description", "date"] + } + } +} diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..10c142f --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,90 @@ +# Covers JetBrains IDEs: IntelliJ, GoLand, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +.idea/ + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ +.idea/sonarlint.xml # see https://community.sonarsource.com/t/is-the-file-idea-idea-idea-sonarlint-xml-intended-to-be-under-source-control/121119 + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based HTTP Client +.idea/httpRequests +http-client.private.env.json + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +# Apifox Helper cache +.idea/.cache/.Apifox_Helper +.idea/ApifoxUploaderProjectSetting.xml + +# Github Copilot persisted session migrations, see: https://github.com/microsoft/copilot-intellij-feedback/issues/712#issuecomment-3322062215 +.idea/**/copilot.data.migration.*.xml + +target/ diff --git a/server/.mvn/jvm.config b/server/.mvn/jvm.config new file mode 100644 index 0000000..e69de29 diff --git a/server/.mvn/maven.config b/server/.mvn/maven.config new file mode 100644 index 0000000..e69de29 diff --git a/server/pom.xml b/server/pom.xml new file mode 100644 index 0000000..6b34b8a --- /dev/null +++ b/server/pom.xml @@ -0,0 +1,102 @@ + + + 4.0.0 + + no.asprusten + Sykkelaksjon + 1.0-SNAPSHOT + Sykkelaksjon + + + UTF-8 + 21 + + + + + jakarta.persistence + jakarta.persistence-api + 3.2.0 + + + org.hibernate.orm + hibernate-core + 7.1.0.Final + + + org.springframework.boot + spring-boot-starter-web + 4.0.5 + + + org.springframework.boot + spring-boot-starter-webmvc + 4.0.5 + + + org.springframework.boot + spring-boot-starter-data-jpa + 4.0.5 + + + org.pac4j + spring-webmvc-pac4j + 8.0.2 + + + org.pac4j + pac4j-oidc + 6.4.0 + + + org.pac4j + pac4j-http + 6.4.0 + + + org.postgresql + postgresql + 42.7.4 + + + + + + + + org.jsonschema2pojo + jsonschema2pojo-maven-plugin + 1.3.3 + + ${basedir}/../messages/ + no.asprusten.sykkelaksjon.messages + true + + + + + generate + + + + + + org.springframework.boot + spring-boot-maven-plugin + 4.0.5 + + + + repackage + + + + + no.asprusten.sykkelaksjon.Server + ZIP + + + + + diff --git a/server/src/main/java/no/asprusten/sykkelaksjon/PropertiesListener.java b/server/src/main/java/no/asprusten/sykkelaksjon/PropertiesListener.java new file mode 100644 index 0000000..35e1c71 --- /dev/null +++ b/server/src/main/java/no/asprusten/sykkelaksjon/PropertiesListener.java @@ -0,0 +1,60 @@ +package no.asprusten.sykkelaksjon; + +import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.PropertiesPropertySource; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Properties; +import java.util.Scanner; + +public class PropertiesListener implements ApplicationListener { + + @Override + public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) { + // Make this actually find the postgres password and run when not in a Docker container + // Also make it find the static files + ConfigurableEnvironment environment = event.getEnvironment(); + String passwordSetting = environment.getProperty("postgrespassword"); + if (passwordSetting == null) { + File passwordFile = new File("../secrets/postgrespassword"); + if (passwordFile.canRead()) { + try { + Scanner reader = new Scanner(passwordFile); + if (reader.hasNextLine()) { + String password = reader.nextLine(); + Properties props = new Properties(); + props.put("postgrespassword", password); + environment.getPropertySources().addFirst(new PropertiesPropertySource("passwordProps", props)); + } + } catch (FileNotFoundException e) { + // Do nothing, continue + } + } + } + + String staticResourcesPath = environment.getProperty("spring.web.resources.static-locations"); + File staticResources = new File(staticResourcesPath.replaceFirst("file:", "")); + if (!staticResources.exists()) { + // Look for static resources in the default location + File nonDockerStatic = new File("../client/dist"); + if (nonDockerStatic.exists()) { + Properties staticProps = new Properties(); + try { + staticProps.put("spring.web.resources.static-locations", "file:" + nonDockerStatic.getCanonicalPath()); + environment.getPropertySources().addFirst(new PropertiesPropertySource("staticProps", staticProps)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + } + + @Override + public boolean supportsAsyncExecution() { + return ApplicationListener.super.supportsAsyncExecution(); + } +} diff --git a/server/src/main/java/no/asprusten/sykkelaksjon/Server.java b/server/src/main/java/no/asprusten/sykkelaksjon/Server.java new file mode 100644 index 0000000..785c400 --- /dev/null +++ b/server/src/main/java/no/asprusten/sykkelaksjon/Server.java @@ -0,0 +1,354 @@ +package no.asprusten.sykkelaksjon; + +import no.asprusten.sykkelaksjon.db.services.ActivityService; +import no.asprusten.sykkelaksjon.db.services.ActivityTemplateService; +import no.asprusten.sykkelaksjon.db.services.ActivityTypeService; +import no.asprusten.sykkelaksjon.db.services.UserService; +import no.asprusten.sykkelaksjon.messages.*; +import no.asprusten.sykkelaksjon.security.ServerExceptionHandler; +import org.pac4j.core.profile.ProfileManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Hello world! + */ +@RestController +@SpringBootApplication +public class Server { + @Autowired + private ProfileManager profileManager; + @Autowired + private UserService userService; + @Autowired + private ActivityTypeService activityTypeService; + @Autowired + private ActivityService activityService; + @Autowired + private ActivityTemplateService activityTemplateService; + + @Value("${sykkelaksjon.openid.discoveryURI}") + private String discoveryURI; + + @Value("${sykkelaksjon.openid.clientId}") + private String clientId; + + private ActivityType getActivityTypeMessage(no.asprusten.sykkelaksjon.db.datatypes.ActivityType activityType) { + ActivityType activityTypeMessage = new ActivityType(); + activityTypeMessage.setId(activityType.getId()); + activityTypeMessage.setName(activityType.getActivityType()); + activityTypeMessage.setConversionFactor(activityType.getConversion()); + activityTypeMessage.setUnit(activityType.getUnit()); + return activityTypeMessage; + } + + private ActivityTemplate getActivityTemplateMessage(no.asprusten.sykkelaksjon.db.datatypes.ActivityTemplate activityTemplate) { + ActivityTemplate templateMessage = new ActivityTemplate(); + templateMessage.setId(activityTemplate.getId()); + templateMessage.setActivityType(getActivityTypeMessage(activityTemplate.getActivityType())); + templateMessage.setName(activityTemplate.getName()); + templateMessage.setNumberOfUnits(activityTemplate.getNumberOfUnits()); + return templateMessage; + } + + private Activity getActivityMessage(no.asprusten.sykkelaksjon.db.datatypes.Activity activity) { + Activity activityMessage = new Activity(); + activityMessage.setId(activity.getId()); + activityMessage.setActivityType(getActivityTypeMessage(activity.getActivityType())); + activityMessage.setDescription(activity.getDescription()); + activityMessage.setNumberOfUnits(activity.getNumberOfUnits()); + activityMessage.setDate(activity.getDate().format(DateTimeFormatter.ISO_LOCAL_DATE)); + return activityMessage; + } + + @CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"}) + @GetMapping("/api") + public ServerMessageSchema respondToRequest() throws ServerExceptionHandler.InvalidUserException { + var optionalUserProfile = profileManager.getProfile(); + if (optionalUserProfile.isEmpty()) { + throw new ServerExceptionHandler.InvalidUserException(); + } + + var userProfile = optionalUserProfile.get(); + + var optionalUser = userService.getUser(userProfile.getUsername()); + var user = optionalUser + .orElseGet( + () -> userService.createUser( + userProfile.getUsername(), + userProfile.getAttribute("name").toString() + ) + ); + + var allUsers = userService.list(); + ServerMessageSchema serverMessage = new ServerMessageSchema(); + serverMessage.setName(user.getFullName()); + serverMessage.setIsAdmin(user.isAdmin()); + + boolean unitConversionNecessary = false; + for (var activityType : activityTypeService.list()) { + serverMessage.getActivityTypes().add(getActivityTypeMessage(activityType)); + unitConversionNecessary |= activityType.getConversion() != 1.0; + } + serverMessage.setUnitConversionNecessary(unitConversionNecessary); + + for (var activityTemplate : user.getTemplates()) { + serverMessage.getActivityTemplates().add(getActivityTemplateMessage(activityTemplate)); + } + + List unsortedActivities = new ArrayList<>(); + + for (var activity : user.getActivities()) { + unsortedActivities.add(getActivityMessage(activity)); + } + unsortedActivities.sort(Comparator.comparing(Activity::getDate)); + serverMessage.getActivities().addAll(unsortedActivities.reversed()); + + List unsortedOtherUsers = new ArrayList<>(); + for (var otherUser : allUsers) { + // Don't describe the current user + if (otherUser.getId().equals(user.getId())) { + continue; + } + // If this is not an active user, and the requesting user is not an admin, skip + if (!otherUser.isActive() && !user.isAdmin()) { + continue; + } + + // If the user has not registered anything yet, also skip it (unless admin) + if (otherUser.getActivities().isEmpty() && !user.isAdmin()) { + continue; + } + + OtherUser otherUserMessage = new OtherUser(); + otherUserMessage.setName(otherUser.getFullName()); + + double totalKilometers = otherUser.getActivities() + .stream() + .mapToDouble(activity -> activity.getActivityType().getConversion() * activity.getNumberOfUnits()) + .sum(); + otherUserMessage.setTotalKilometers(totalKilometers); + + otherUser.getActivities().stream() + .map(no.asprusten.sykkelaksjon.db.datatypes.Activity::getDate) + .min(LocalDate::compareTo) + .ifPresent(earliestDate -> + otherUserMessage.setEarliestActivity(earliestDate.format(DateTimeFormatter.ISO_LOCAL_DATE)) + ); + + otherUser.getActivities().stream() + .map(no.asprusten.sykkelaksjon.db.datatypes.Activity::getDate) + .max(LocalDate::compareTo) + .ifPresent(latestDate -> + otherUserMessage.setLatestActivity(latestDate.format(DateTimeFormatter.ISO_LOCAL_DATE)) + ); + + // Additional info is only for administrators + if (user.isAdmin()) { + otherUserMessage.setUserId(otherUser.getId()); + otherUserMessage.setUserName(otherUser.getUsername()); + otherUserMessage.setIsActive(otherUser.isActive()); + otherUserMessage.setIsAdmin(otherUser.isAdmin()); + List userUnsortedActivities = new ArrayList<>(); + for (var activity : otherUser.getActivities()) { + userUnsortedActivities.add(getActivityMessage(activity)); + } + userUnsortedActivities.sort(Comparator.comparing(Activity::getDate)); + otherUserMessage.getActivities().addAll(userUnsortedActivities.reversed()); + + for (var activityTemplate : otherUser.getTemplates()) { + otherUserMessage.getActivityTemplates().add(getActivityTemplateMessage(activityTemplate)); + } + } + + unsortedOtherUsers.add(otherUserMessage); + } + + unsortedOtherUsers.sort(Comparator.comparing(OtherUser::getTotalKilometers)); + serverMessage.getOtherUsers().addAll(unsortedOtherUsers.reversed()); + return serverMessage; + } + + @CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"}) + @PostMapping(path = "/api/submitActivity", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public void submitActivity( + @RequestParam("activity-type") Long activityTypeId, + @RequestParam("activity-distance") double distance, + @RequestParam("activity-description") String description, + @RequestParam("activity-date") String date + ) { + profileManager.getProfile().ifPresent(userProfile -> { + String username = userProfile.getUsername(); + userService.getUser(username).ifPresent(dbUser -> { + activityTypeService.getById(activityTypeId).ifPresent(activityType -> { + no.asprusten.sykkelaksjon.db.datatypes.Activity activity = new no.asprusten.sykkelaksjon.db.datatypes.Activity( + activityType, + dbUser, + distance, + description, + LocalDate.parse(date) + ); + activityService.saveActivity(activity); + }); + }); + }); + } + + @CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"}) + @PostMapping(path = "/api/submitActivityTemplate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public void submitActivityTemplate( + @RequestParam("activity-type") Long activityTypeId, + @RequestParam("activity-distance") double distance, + @RequestParam("activity-description") String description, + @RequestParam("activity-date") String date + ) { + profileManager.getProfile().ifPresent(userProfile -> { + String username = userProfile.getUsername(); + userService.getUser(username).ifPresent(dbUser -> { + activityTypeService.getById(activityTypeId).ifPresent(activityType -> { + no.asprusten.sykkelaksjon.db.datatypes.ActivityTemplate activityTemplate = new no.asprusten.sykkelaksjon.db.datatypes.ActivityTemplate( + dbUser, + activityType, + description, + distance + ); + + activityTemplateService.saveActivityTemplate(activityTemplate); + }); + }); + }); + } + + @CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"}) + @DeleteMapping(path = "/api/deleteActivityTemplate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public void deleteActivityTemplate(@RequestParam("activity-template-id") Long activityTemplateId) { + profileManager.getProfile().ifPresent(userProfile -> { + String username = userProfile.getUsername(); + userService.getUser(username).ifPresent(dbUser -> { + activityTemplateService.findById(activityTemplateId).ifPresent(activityTemplate -> { + if (activityTemplate.getOwner().equals(dbUser)) { + activityTemplateService.deleteActivityTemplate(activityTemplate.getId()); + } + }); + }); + }); + } + + @CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"}) + @DeleteMapping(path = "/api/deleteActivity", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public void deleteActivity(@RequestParam("activity-id") Long activityId) { + profileManager.getProfile().ifPresent(userProfile -> { + String username = userProfile.getUsername(); + userService.getUser(username).ifPresent(dbUser -> { + activityService.findById(activityId).ifPresent(activity -> { + if (activity.getActivityOwner().equals(dbUser) || dbUser.isAdmin()) { + activityService.deleteActivity(activityId); + } + }); + }); + }); + } + + @CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"}) + @PostMapping(path = "/api/addActivityType", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public void createActivityType( + @RequestParam("activity-type-name") String name, + @RequestParam("activity-type-unit") String unit, + @RequestParam("activity-type-conversion-factor") double conversionFactor + ) { + profileManager.getProfile().ifPresent(userProfile -> { + String username = userProfile.getUsername(); + userService.getUser(username).ifPresent(dbUser -> { + if (dbUser.isAdmin()) { + no.asprusten.sykkelaksjon.db.datatypes.ActivityType activityType = new no.asprusten.sykkelaksjon.db.datatypes.ActivityType( + name, unit, conversionFactor + ); + activityTypeService.saveActivityType(activityType); + } + }); + }); + } + + @CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"}) + @DeleteMapping(path = "/api/deleteActivityType", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public void deleteActivityType(@RequestParam("activity-type-id") Long activityTypeId) { + profileManager.getProfile().ifPresent(userProfile -> { + String username = userProfile.getUsername(); + userService.getUser(username).ifPresent(dbUser -> { + if (dbUser.isAdmin()) { + activityTypeService.deleteActivityType(activityTypeId); + } + }); + }); + } + + @CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"}) + @PutMapping(path = "/api/makeAdmin", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public void makeAdmin(@RequestParam("user-id") Long userId) { + profileManager.getProfile().ifPresent(userProfile -> { + String username = userProfile.getUsername(); + userService.getUser(username).ifPresent(dbUser -> { + if (dbUser.isAdmin()) { + userService.getUserById(userId).ifPresent(elevatedUser -> { + elevatedUser.setAdmin(true); + userService.saveUser(elevatedUser); + }); + } + }); + }); + } + + @CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"}) + @PutMapping(path = "/api/removeAdmin", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public void removeAdmin(@RequestParam("user-id") Long userId) { + profileManager.getProfile().ifPresent(userProfile -> { + String username = userProfile.getUsername(); + userService.getUser(username).ifPresent(dbUser -> { + if (dbUser.isAdmin()) { + userService.getUserById(userId).ifPresent(elevatedUser -> { + elevatedUser.setAdmin(false); + userService.saveUser(elevatedUser); + }); + } + }); + }); + } + + @CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"}) + @DeleteMapping(path = "/api/deleteUser", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public void deleteUser(@RequestParam("user-id") Long userId) { + profileManager.getProfile().ifPresent(userProfile -> { + String username = userProfile.getUsername(); + userService.getUser(username).ifPresent(dbUser -> { + if (dbUser.isAdmin()) { + userService.deleteUserById(userId); + } + }); + }); + } + + @CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"}) + @GetMapping(path = "/api/openid") + public OpenidSchema provideOpenidConfig() { + OpenidSchema openidSchema = new OpenidSchema(); + openidSchema.setClientId(clientId); + openidSchema.setOpenidDiscoveryUri(discoveryURI); + return openidSchema; + } + + public static void main(String[] args) { + SpringApplication application = new SpringApplication(Server.class); + application.addListeners(new PropertiesListener()); + application.run(args); + } +} diff --git a/server/src/main/java/no/asprusten/sykkelaksjon/db/datatypes/Activity.java b/server/src/main/java/no/asprusten/sykkelaksjon/db/datatypes/Activity.java new file mode 100644 index 0000000..a73ec98 --- /dev/null +++ b/server/src/main/java/no/asprusten/sykkelaksjon/db/datatypes/Activity.java @@ -0,0 +1,67 @@ +package no.asprusten.sykkelaksjon.db.datatypes; + +import jakarta.persistence.*; + +import java.time.LocalDate; + +@Entity +public class Activity { + @Id + @GeneratedValue + private long id; + + @ManyToOne( optional = false) + private ActivityType activityType; + @ManyToOne(optional = false) + private WebUser activityOwner; + @Column(nullable = false) + private Double numberOfUnits; + @Column(nullable = false) + private String description; + @Column(nullable = false) + private LocalDate date; + + public Activity() { + + } + + public Activity(ActivityType activityType, WebUser activityOwner, Double numberOfUnits, String description, LocalDate date) { + this.activityType = activityType; + this.activityOwner = activityOwner; + this.numberOfUnits = numberOfUnits; + this.description = description; + this.date = date; + } + + public long getId() { + return id; + } + + public ActivityType getActivityType() { + return activityType; + } + + public void setActivityType(ActivityType activityType) { + this.activityType = activityType; + } + + public Double getNumberOfUnits() { + return numberOfUnits; + } + + public void setNumberOfUnits(Double numberOfUnits) { + this.numberOfUnits = numberOfUnits; + } + + public WebUser getActivityOwner() { + return activityOwner; + } + + public String getDescription() { + return description; + } + + public LocalDate getDate() { + return date; + } +} diff --git a/server/src/main/java/no/asprusten/sykkelaksjon/db/datatypes/ActivityTemplate.java b/server/src/main/java/no/asprusten/sykkelaksjon/db/datatypes/ActivityTemplate.java new file mode 100644 index 0000000..e9be399 --- /dev/null +++ b/server/src/main/java/no/asprusten/sykkelaksjon/db/datatypes/ActivityTemplate.java @@ -0,0 +1,70 @@ +package no.asprusten.sykkelaksjon.db.datatypes; + +import jakarta.persistence.*; + +@Entity +@Table(uniqueConstraints = { + @UniqueConstraint(name = "UniqueNamesPerUser", columnNames = { "owner", "name" }) +}) +public class ActivityTemplate { + @Id + @GeneratedValue + private long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "owner", nullable = false) + private WebUser owner; + @ManyToOne(optional = false) + private ActivityType activityType; + @Column(nullable = false) + private String name; + @Column(nullable = false) + private Double numberOfUnits; + + public ActivityTemplate() { + + } + + public ActivityTemplate(WebUser owner, ActivityType activityType, String name, Double numberOfUnits) { + this.owner = owner; + this.activityType = activityType; + this.name = name; + this.numberOfUnits = numberOfUnits; + } + + public Long getId() { + return id; + } + + public WebUser getOwner() { + return owner; + } + + public void setOwner(WebUser owner) { + this.owner = owner; + } + + public ActivityType getActivityType() { + return activityType; + } + + public void setActivityType(ActivityType activityType) { + this.activityType = activityType; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Double getNumberOfUnits() { + return numberOfUnits; + } + + public void setNumberOfUnits(Double numberOfUnits) { + this.numberOfUnits = numberOfUnits; + } +} diff --git a/server/src/main/java/no/asprusten/sykkelaksjon/db/datatypes/ActivityType.java b/server/src/main/java/no/asprusten/sykkelaksjon/db/datatypes/ActivityType.java new file mode 100644 index 0000000..6e510f3 --- /dev/null +++ b/server/src/main/java/no/asprusten/sykkelaksjon/db/datatypes/ActivityType.java @@ -0,0 +1,58 @@ +package no.asprusten.sykkelaksjon.db.datatypes; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; + +@Entity +public class ActivityType { + @Id + @GeneratedValue + private long id; + + @Column(nullable = false, unique = true) + private String activityType; + @Column(nullable = false) + private String unit; + @Column(nullable = false) + private Double conversion; + + public ActivityType() { + + } + + public ActivityType(String activityType, String unit, Double conversion) { + this.activityType = activityType; + this.unit = unit; + this.conversion = conversion; + } + + public long getId() { + return id; + } + + public String getActivityType() { + return activityType; + } + + public String getUnit() { + return unit; + } + + public Double getConversion() { + return conversion; + } + + public void setActivityType(String activityType) { + this.activityType = activityType; + } + + public void setUnit(String unit) { + this.unit = unit; + } + + public void setConversion(Double conversion) { + this.conversion = conversion; + } +} diff --git a/server/src/main/java/no/asprusten/sykkelaksjon/db/datatypes/WebUser.java b/server/src/main/java/no/asprusten/sykkelaksjon/db/datatypes/WebUser.java new file mode 100644 index 0000000..191773a --- /dev/null +++ b/server/src/main/java/no/asprusten/sykkelaksjon/db/datatypes/WebUser.java @@ -0,0 +1,88 @@ +package no.asprusten.sykkelaksjon.db.datatypes; + +import jakarta.persistence.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(uniqueConstraints = { + @UniqueConstraint(name = "OnlyOneActiveWithUsername", columnNames = { "username", "zeroIfActive" }) +}) +public class WebUser { + @Id + @GeneratedValue + private Long id; + @Column(nullable = false) + private String username; + @Column(nullable = false) + private String fullName; + private boolean isAdmin; + @Column(nullable = false) + private Long zeroIfActive; + + @OneToMany(mappedBy = "activityOwner", fetch = FetchType.EAGER) + private List activities = new ArrayList<>(); + + @OneToMany(mappedBy = "owner", fetch = FetchType.EAGER) + private List templates = new ArrayList<>(); + + public WebUser() { + + } + + public WebUser(String username, String fullName, boolean isAdmin) { + this.username = username; + this.fullName = fullName; + this.isAdmin = isAdmin; + this.zeroIfActive = 0L; + } + + public Long getId() { + return id; + } + + public String getUsername() { + return username; + } + + public String getFullName() { + return fullName; + } + + public boolean isAdmin() { + return isAdmin; + } + + public boolean isActive() { + return zeroIfActive == 0L; + } + + public void setUsername(String username) { + this.username = username; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public void setAdmin(boolean admin) { + isAdmin = admin; + } + + public void setActive(boolean active) { + if (active) { + zeroIfActive = 0L; + } else { + zeroIfActive = id; + } + } + + public List getActivities() { + return activities; + } + + public List getTemplates() { + return templates; + } +} diff --git a/server/src/main/java/no/asprusten/sykkelaksjon/db/repositories/ActivityRepository.java b/server/src/main/java/no/asprusten/sykkelaksjon/db/repositories/ActivityRepository.java new file mode 100644 index 0000000..3635d58 --- /dev/null +++ b/server/src/main/java/no/asprusten/sykkelaksjon/db/repositories/ActivityRepository.java @@ -0,0 +1,7 @@ +package no.asprusten.sykkelaksjon.db.repositories; + +import no.asprusten.sykkelaksjon.db.datatypes.Activity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ActivityRepository extends JpaRepository { +} diff --git a/server/src/main/java/no/asprusten/sykkelaksjon/db/repositories/ActivityTemplateRepository.java b/server/src/main/java/no/asprusten/sykkelaksjon/db/repositories/ActivityTemplateRepository.java new file mode 100644 index 0000000..e41cc64 --- /dev/null +++ b/server/src/main/java/no/asprusten/sykkelaksjon/db/repositories/ActivityTemplateRepository.java @@ -0,0 +1,7 @@ +package no.asprusten.sykkelaksjon.db.repositories; + +import no.asprusten.sykkelaksjon.db.datatypes.ActivityTemplate; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ActivityTemplateRepository extends JpaRepository { +} diff --git a/server/src/main/java/no/asprusten/sykkelaksjon/db/repositories/ActivityTypeRepository.java b/server/src/main/java/no/asprusten/sykkelaksjon/db/repositories/ActivityTypeRepository.java new file mode 100644 index 0000000..cc40c2a --- /dev/null +++ b/server/src/main/java/no/asprusten/sykkelaksjon/db/repositories/ActivityTypeRepository.java @@ -0,0 +1,7 @@ +package no.asprusten.sykkelaksjon.db.repositories; + +import no.asprusten.sykkelaksjon.db.datatypes.ActivityType; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ActivityTypeRepository extends JpaRepository { +} diff --git a/server/src/main/java/no/asprusten/sykkelaksjon/db/repositories/WebUserRepository.java b/server/src/main/java/no/asprusten/sykkelaksjon/db/repositories/WebUserRepository.java new file mode 100644 index 0000000..14b0fa1 --- /dev/null +++ b/server/src/main/java/no/asprusten/sykkelaksjon/db/repositories/WebUserRepository.java @@ -0,0 +1,12 @@ +package no.asprusten.sykkelaksjon.db.repositories; + +import no.asprusten.sykkelaksjon.db.datatypes.WebUser; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface WebUserRepository extends JpaRepository { + List findByUsername(String username); +} diff --git a/server/src/main/java/no/asprusten/sykkelaksjon/db/services/ActivityService.java b/server/src/main/java/no/asprusten/sykkelaksjon/db/services/ActivityService.java new file mode 100644 index 0000000..5a0652d --- /dev/null +++ b/server/src/main/java/no/asprusten/sykkelaksjon/db/services/ActivityService.java @@ -0,0 +1,26 @@ +package no.asprusten.sykkelaksjon.db.services; + +import no.asprusten.sykkelaksjon.db.datatypes.Activity; +import no.asprusten.sykkelaksjon.db.repositories.ActivityRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +public class ActivityService { + @Autowired + ActivityRepository activityRepository; + + public void saveActivity(Activity activity) { + activityRepository.save(activity); + } + + public void deleteActivity(Long id) { + activityRepository.deleteById(id); + } + + public Optional findById(long id) { + return activityRepository.findById(id); + } +} diff --git a/server/src/main/java/no/asprusten/sykkelaksjon/db/services/ActivityTemplateService.java b/server/src/main/java/no/asprusten/sykkelaksjon/db/services/ActivityTemplateService.java new file mode 100644 index 0000000..88c024f --- /dev/null +++ b/server/src/main/java/no/asprusten/sykkelaksjon/db/services/ActivityTemplateService.java @@ -0,0 +1,26 @@ +package no.asprusten.sykkelaksjon.db.services; + +import no.asprusten.sykkelaksjon.db.datatypes.ActivityTemplate; +import no.asprusten.sykkelaksjon.db.repositories.ActivityTemplateRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +public class ActivityTemplateService { + @Autowired + private ActivityTemplateRepository activityTemplateRepository; + + public Optional findById(Long id) { + return activityTemplateRepository.findById(id); + } + + public void saveActivityTemplate(ActivityTemplate template) { + activityTemplateRepository.save(template); + } + + public void deleteActivityTemplate(Long id) { + activityTemplateRepository.deleteById(id); + } +} diff --git a/server/src/main/java/no/asprusten/sykkelaksjon/db/services/ActivityTypeService.java b/server/src/main/java/no/asprusten/sykkelaksjon/db/services/ActivityTypeService.java new file mode 100644 index 0000000..db6c5e7 --- /dev/null +++ b/server/src/main/java/no/asprusten/sykkelaksjon/db/services/ActivityTypeService.java @@ -0,0 +1,31 @@ +package no.asprusten.sykkelaksjon.db.services; + +import no.asprusten.sykkelaksjon.db.datatypes.ActivityType; +import no.asprusten.sykkelaksjon.db.repositories.ActivityTypeRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class ActivityTypeService { + @Autowired + private ActivityTypeRepository activityTypeRepository; + + public List list() { + return activityTypeRepository.findAll(); + } + + public Optional getById(Long id) { + return activityTypeRepository.findById(id); + } + + public ActivityType saveActivityType(ActivityType activityType) { + return activityTypeRepository.save(activityType); + } + + public void deleteActivityType(Long id) { + activityTypeRepository.deleteById(id); + } +} diff --git a/server/src/main/java/no/asprusten/sykkelaksjon/db/services/UserService.java b/server/src/main/java/no/asprusten/sykkelaksjon/db/services/UserService.java new file mode 100644 index 0000000..934d4da --- /dev/null +++ b/server/src/main/java/no/asprusten/sykkelaksjon/db/services/UserService.java @@ -0,0 +1,59 @@ +package no.asprusten.sykkelaksjon.db.services; + +import jakarta.persistence.EntityManager; +import no.asprusten.sykkelaksjon.db.datatypes.WebUser; +import no.asprusten.sykkelaksjon.db.repositories.WebUserRepository; +import org.apache.catalina.User; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class UserService { + @Autowired + private WebUserRepository webUserRepository; + @Autowired + @Value("${sykkelaksjon.initial-admin}") + private String initialAdmin; + + public List list() { + return webUserRepository.findAll(); + } + + public long getUserCount() { + return webUserRepository.count(); + } + + public Optional getUser(String username) { + List users = webUserRepository.findByUsername(username); + for (var user : users) { + if (user.isActive()) { + return Optional.of(user); + } + } + + return Optional.empty(); + } + + public Optional getUserById(Long id) { + return webUserRepository.findById(id); + } + + public WebUser createUser(String username, String fullname) { + boolean isAdmin = username.equals(initialAdmin); + WebUser newUser = new WebUser(username, fullname, isAdmin); + newUser = webUserRepository.save(newUser); + return newUser; + } + + public WebUser saveUser(WebUser user) { + return webUserRepository.save(user); + } + + public void deleteUserById(Long id) { + webUserRepository.deleteById(id); + } +} diff --git a/server/src/main/java/no/asprusten/sykkelaksjon/security/SecurityConfig.java b/server/src/main/java/no/asprusten/sykkelaksjon/security/SecurityConfig.java new file mode 100644 index 0000000..0bcd999 --- /dev/null +++ b/server/src/main/java/no/asprusten/sykkelaksjon/security/SecurityConfig.java @@ -0,0 +1,45 @@ +package no.asprusten.sykkelaksjon.security; + +import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod; +import org.pac4j.core.authorization.authorizer.DefaultAuthorizers; +import org.pac4j.core.config.Config; +import org.pac4j.http.client.direct.HeaderClient; +import org.pac4j.oidc.client.OidcClient; +import org.pac4j.oidc.config.OidcConfiguration; +import org.pac4j.springframework.config.Pac4jSecurityConfig; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; + +import java.util.List; + +@Configuration +public class SecurityConfig extends Pac4jSecurityConfig { + + @Value("${sykkelaksjon.openid.discoveryURI}") + private String discoveryURI; + + @Value("${sykkelaksjon.openid.clientId}") + private String clientId; + + @Bean + public Config config() { + final var config = new OidcConfiguration() + .setDiscoveryURI(discoveryURI) + .setClientId(clientId); + + OidcClient client = new OidcClient(config); + client.setCallbackUrl("notused"); + client.init(); + + HeaderClient headerClient = new HeaderClient("Authorization", "Bearer ", client.getProfileCreator()); + return new Config(headerClient); + } + + @Override + public void addInterceptors(final InterceptorRegistry registry) { + addSecurity(registry, "HeaderClient").addPathPatterns("/api/**").excludePathPatterns("/api/openid").excludeHttpMethods(List.of(HttpMethod.OPTIONS)); + } +} diff --git a/server/src/main/java/no/asprusten/sykkelaksjon/security/ServerExceptionHandler.java b/server/src/main/java/no/asprusten/sykkelaksjon/security/ServerExceptionHandler.java new file mode 100644 index 0000000..0c64814 --- /dev/null +++ b/server/src/main/java/no/asprusten/sykkelaksjon/security/ServerExceptionHandler.java @@ -0,0 +1,17 @@ +package no.asprusten.sykkelaksjon.security; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ControllerAdvice +public class ServerExceptionHandler { + public static class InvalidUserException extends Exception {} + + @ResponseStatus(HttpStatus.FORBIDDEN) + @ExceptionHandler(InvalidUserException.class) + public void handleInvalidUser() { + // Do nothing + } +} diff --git a/server/src/main/resources/application.properties b/server/src/main/resources/application.properties new file mode 100644 index 0000000..c7d9db7 --- /dev/null +++ b/server/src/main/resources/application.properties @@ -0,0 +1,11 @@ +spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:9876/sykkelaksjon} +spring.config.import=optional:configtree:/run/secrets/ +spring.datasource.username=sykkelaksjon +spring.datasource.password=${postgrespassword} +spring.jpa.hibernate.ddl-auto=update + +sykkelaksjon.initial-admin=${SYKKELAKSJON_INITIAL_ADMIN:martin} +spring.web.resources.static-locations=./static + +sykkelaksjon.openid.discoveryURI = ${OPENID_DISCOVERY_URI} +sykkelaksjon.openid.clientId = ${OPENID_CLIENT_ID} \ No newline at end of file