Initial commit, working server

This commit is contained in:
Martin Asprusten 2026-04-12 01:28:25 +02:00
commit 49e857f258
No known key found for this signature in database
39 changed files with 3375 additions and 0 deletions

3
.env.template Normal file
View File

@ -0,0 +1,3 @@
OPENID_DISCOVERY_URI=
OPENID_CLIENT_ID=
SYKKELAKSJON_INITIAL_ADMIN=

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
secrets/**
db-data/**
.env

22
Dockerfile Normal file
View File

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

5
README.md Normal file
View File

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

24
client/.gitignore vendored Normal file
View File

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

203
client/index.html Normal file
View File

@ -0,0 +1,203 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sykkelaksjon</title>
</head>
<body>
<div id="loading-div">
<p>Laster data...</p>
</div>
<div x-data="{ unitType: $store.state.data.activityTypes[0]?.unit ?? 'km' }" id="content-div" style="display: none;">
<h1>Velkommen, <span x-text="$store.state.data.name"></span></h1>
<div class="outer-vertical-flex">
<div class="flex-container">
<div class="vertical-flex">
<div class="register-activity-div">
<h3>Registrer ny aktivitet:</h3>
<form id="activity-form">
<div class="activity-type-div">
<label for="activity-type">Type aktivitet: </label>
<template x-if="$store.state.data.activityTypes.length > 1">
<select name="activity-type" id="activity-type" x-on:change="unitType = event.target[event.target.selectedIndex].dataset.unit;">
<template x-for="activityType in $store.state.data.activityTypes" :key="activityType.id">
<option x-text="activityType.name" x-bind:value="activityType.id" x-bind:data-unit="activityType.unit"></option>
</template>
</select>
</template>
<template x-if="$store.state.data.activityTypes.length == 1">
<span><span x-text="$store.state.data.activityTypes[0].name"></span><input type="hidden" name="activity-type" x-bind:value="$store.state.data.activityTypes[0].id" /></span>
</template>
</div>
<div class="distance-div">
<label for="activity-distance">Avstand: </label>
<input id="activity-distance" name="activity-distance" type="number" min="0" step="0.1" />
<span x-text="unitType"></span>
</div>
<div class="description-div">
<label for="activity-description">Beskrivelse:</label>
<input type="text" id="activity-description" name="activity-description" />
</div>
<div class="date-div">
<label for="activity-date">Dato:</label>
<input type="date" id="activity-date" name="activity-date" />
</div>
<div class="submit-buttons-container">
<button id="submit-activity-button">Registrer aktivitet</button>
<button id="submit-activity-template-button">Legg til som standardaktivitet</button>
</div>
</form>
</div>
<div class="registered-activities-div">
<h3>Registrerte aktiviteter:</h3>
<p>Til sammen: <span x-text="$store.state.data.activities.reduce((p, a) => p + a.numberOfUnits * a.activityType.conversionFactor, 0).toFixed(1)"></span> km</p>
<table>
<tr>
<th>Dato</th>
<th>Beskrivelse</th>
<th>Avstand</th>
<template x-if="$store.state.data.unitConversionNecessary">
<th>Ekvivalent</th>
</template>
<th>Fjern registrert aktivitet</th>
</tr>
<template x-for="activity in $store.state.data.activities">
<tr>
<td x-text="activity.date"></td>
<td x-text="activity.description"></td>
<td x-text="`${activity.numberOfUnits} ${activity.activityType.unit}`"></td>
<template x-if="$store.state.data.unitConversionNecessary">
<td x-text="`${activity.numberOfUnits * activity.activityType.conversionFactor} km`"></td>
</template>
<td><button x-on:click="$store.state.deleteActivity(activity)">Fjern</button></td>
</tr>
</template>
</table>
</div>
</div>
<div class="standard-activities-div">
<h3>Standardaktiviteter:</h3>
<template x-for="activityTemplate in $store.state.data.activityTemplates" :key="activityTemplate.id">
<p>
<button x-on:click="$store.state.registerTemplateActivity(activityTemplate)">Registrer</button>
<span x-text="`${activityTemplate.name}, ${activityTemplate.activityType.name.toLowerCase()}, ${activityTemplate.numberOfUnits} ${activityTemplate.activityType.unit}`"></span>
<button x-on:click="$store.state.deleteTemplateActivity(activityTemplate)">Fjern</button>
</p>
</template>
</div>
</div>
<div class="other-users-div">
<h3>Andre aktive:</h3>
<table>
<tr>
<th>Navn</th><th>Total avstand</th><th>Første aktivitet</th><th>Siste aktivitet</th>
</tr>
<template x-if="$store.state.data.otherUsers.length == 0">
<tr>
<td colspan="4">Det er ingen andre deltakere</td>
</tr>
</template>
<template x-if="$store.state.data.otherUsers.length > 0">
<template x-for="otherUser in $store.state.data.otherUsers">
<tr>
<td x-text="otherUser.name"></td>
<td x-text="otherUser.totalKilometers"></td>
<td x-text="otherUser.earliestActivity"></td>
<td x-text="otherUser.latestActivity"></td>
</tr>
</template>
</template>
</table>
</div>
<template x-if="$store.state.data.isAdmin">
<div class="admin-div" >
<h3>Administratorinnstillinger:</h3>
<details>
<summary>Aktivitetstyper</summary>
<h4>Opprett ny aktivitetstype:</h4>
<form id="activity-type-form" onsubmit="return false;">
<div id="activity-type-name-div">
<label for="activity-type-name">Navn:</label>
<input type="text" name="activity-type-name" id="activity-type-name" />
</div>
<div id="activity-type-unit-div">
<label for="activity-type-unit">Enhet:</label>
<input type="text" name="activity-type-unit" id="activity-type-unit" />
</div>
<div id="activity-type-conversion-factor-div">
<label for="activity-type-conversion-factor">Konverteringsfaktor:</label>
<input type="number" name="activity-type-conversion-factor" id="activity-type-conversion-factor" min="0" value="1" />
</div>
<div>
<button onclick="postActivityType();">Legg til</button>
</div>
</form>
<h4>Eksisterende aktivitetstyper:</h4>
<table>
<tr>
<th>Aktivitetstype</th>
<th>Enhet</th>
<th>Konverteringsfaktor til kilometer</th>
<th>Fjern</th>
</tr>
<template x-for="activityType in $store.state.data.activityTypes">
<tr>
<td x-text="activityType.name"></td>
<td x-text="activityType.unit"></td>
<td x-text="activityType.conversionFactor.toString()"></td>
<td><button x-on:click="$store.state.deleteActivityType(activityType)">Fjern</button></td>
</tr>
</template>
</table>
</details>
<details>
<summary>Brukere</summary>
<template x-for="user in $store.state.data.otherUsers">
<details>
<summary x-text="`${user.name} (${user.userName})${user.isAdmin ? ' - Administrator' : ''}`"></summary>
<template x-if="user.isAdmin">
<button x-on:click="$store.state.removeAdministrator(user.userId)">Fjern administrator</button>
</template>
<template x-if="!user.isAdmin">
<button x-on:click="$store.state.makeAdministrator(user.userId)">Gjør til administrator</button>
</template>
<button x-on:click="$store.state.deleteUser(user.userId)">Slett bruker</button>
<h4>Aktiviteter:</h4>
<table>
<tr>
<th>Dato</th>
<th>Beskrivelse</th>
<th>Avstand</th>
<template x-if="$store.state.data.unitConversionNecessary">
<th>Ekvivalent</th>
</template>
<th>Fjern registrert aktivitet</th>
</tr>
<template x-for="activity in user.activities">
<tr>
<td x-text="activity.date"></td>
<td x-text="activity.description"></td>
<td x-text="`${activity.numberOfUnits} ${activity.activityType.unit}`"></td>
<template x-if="$store.state.data.unitConversionNecessary">
<td x-text="`${activity.numberOfUnits * activity.activityType.conversionFactor} km`"></td>
</template>
<td><button x-on:click="$store.state.deleteActivity(activity)">Fjern</button></td>
</tr>
</template>
</table>
</details>
</template>
</details>
</div>
</template>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1124
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
client/package.json Normal file
View File

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

View File

@ -0,0 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="256px" height="256px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
<g id="SVGRepo_iconCarrier"> <path d="M11.5 3C12.3284 3 13 2.32843 13 1.5C13 0.671573 12.3284 0 11.5 0C10.6716 0 10 0.671573 10 1.5C10 2.32843 10.6716 3 11.5 3Z" fill="#000000"/> <path d="M0 12C0 10.4697 1.14578 9.20702 2.62626 9.02304L5.00001 10.6055V14.2361C4.46925 14.7111 3.76836 15 3 15C1.34315 15 0 13.6568 0 12Z" fill="#000000"/> <path d="M11 14.2361C11.5308 14.7111 12.2316 15 13 15C14.6569 15 16 13.6569 16 12C16 10.3431 14.6569 9 13 9C12.2316 9 11.5308 9.28885 11 9.76389V14.2361Z" fill="#000000"/> <path d="M8.28111 5.01444C8.29524 5.00503 8.31184 5 8.32881 5C8.36139 5 8.39117 5.0184 8.40574 5.04754L9.38197 7H13V5H10.618L10.1946 4.15311C9.84124 3.44641 9.11893 3 8.32881 3C7.91699 3 7.51437 3.1219 7.17171 3.35034L4.86132 4.8906C4.32322 5.24934 4 5.85327 4 6.5C4 7.14673 4.32322 7.75066 4.86132 8.1094L7 9.53518V13H9V8.46482L6.05278 6.5L8.28111 5.01444Z" fill="#000000"/> </g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

5
client/src/global.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
import { Alpine as AlpineType } from 'alpinejs'
declare global {
var Alpine: AlpineType
}

293
client/src/main.ts Normal file
View File

@ -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<string, string> = {
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<string, string> = {
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<string | null> = 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");
}

147
client/src/message.d.ts vendored Normal file
View File

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

21
client/src/openid.d.ts vendored Normal file
View File

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

103
client/src/style.css Normal file
View File

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

23
client/tsconfig.json Normal file
View File

@ -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"]
}

34
docker-compose.yaml Normal file
View File

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

View File

@ -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"]
}

View File

@ -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"]
}
}
}

90
server/.gitignore vendored Normal file
View File

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

0
server/.mvn/jvm.config Normal file
View File

0
server/.mvn/maven.config Normal file
View File

102
server/pom.xml Normal file
View File

@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>no.asprusten</groupId>
<artifactId>Sykkelaksjon</artifactId>
<version>1.0-SNAPSHOT</version>
<name>Sykkelaksjon</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
<version>3.2.0</version>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>7.1.0.Final</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>4.0.5</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
<version>4.0.5</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<version>4.0.5</version>
</dependency>
<dependency>
<groupId>org.pac4j</groupId>
<artifactId>spring-webmvc-pac4j</artifactId>
<version>8.0.2</version>
</dependency>
<dependency>
<groupId>org.pac4j</groupId>
<artifactId>pac4j-oidc</artifactId>
<version>6.4.0</version>
</dependency>
<dependency>
<groupId>org.pac4j</groupId>
<artifactId>pac4j-http</artifactId>
<version>6.4.0</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.4</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.jsonschema2pojo</groupId>
<artifactId>jsonschema2pojo-maven-plugin</artifactId>
<version>1.3.3</version>
<configuration>
<sourceDirectory>${basedir}/../messages/</sourceDirectory>
<targetPackage>no.asprusten.sykkelaksjon.messages</targetPackage>
<useLongIntegers>true</useLongIntegers>
</configuration>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>4.0.5</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
<configuration>
<mainClass>no.asprusten.sykkelaksjon.Server</mainClass>
<layout>ZIP</layout>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -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<ApplicationEnvironmentPreparedEvent> {
@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();
}
}

View File

@ -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<Activity> unsortedActivities = new ArrayList<>();
for (var activity : user.getActivities()) {
unsortedActivities.add(getActivityMessage(activity));
}
unsortedActivities.sort(Comparator.comparing(Activity::getDate));
serverMessage.getActivities().addAll(unsortedActivities.reversed());
List<OtherUser> 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<Activity> 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);
}
}

View File

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

View File

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

View File

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

View File

@ -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<Activity> activities = new ArrayList<>();
@OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
private List<ActivityTemplate> 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<Activity> getActivities() {
return activities;
}
public List<ActivityTemplate> getTemplates() {
return templates;
}
}

View File

@ -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<Activity, Long> {
}

View File

@ -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<ActivityTemplate, Long> {
}

View File

@ -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<ActivityType, Long> {
}

View File

@ -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<WebUser, Long> {
List<WebUser> findByUsername(String username);
}

View File

@ -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<Activity> findById(long id) {
return activityRepository.findById(id);
}
}

View File

@ -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<ActivityTemplate> findById(Long id) {
return activityTemplateRepository.findById(id);
}
public void saveActivityTemplate(ActivityTemplate template) {
activityTemplateRepository.save(template);
}
public void deleteActivityTemplate(Long id) {
activityTemplateRepository.deleteById(id);
}
}

View File

@ -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<ActivityType> list() {
return activityTypeRepository.findAll();
}
public Optional<ActivityType> getById(Long id) {
return activityTypeRepository.findById(id);
}
public ActivityType saveActivityType(ActivityType activityType) {
return activityTypeRepository.save(activityType);
}
public void deleteActivityType(Long id) {
activityTypeRepository.deleteById(id);
}
}

View File

@ -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<WebUser> list() {
return webUserRepository.findAll();
}
public long getUserCount() {
return webUserRepository.count();
}
public Optional<WebUser> getUser(String username) {
List<WebUser> users = webUserRepository.findByUsername(username);
for (var user : users) {
if (user.isActive()) {
return Optional.of(user);
}
}
return Optional.empty();
}
public Optional<WebUser> 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);
}
}

View File

@ -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));
}
}

View File

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

View File

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