Merge branch 'dev' into feat/service-layer
This commit is contained in:
commit
e487dfff5c
20 changed files with 647 additions and 74 deletions
7
.dockerignore
Normal file
7
.dockerignore
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
**/node_modules/
|
||||||
|
**/dist
|
||||||
|
.git
|
||||||
|
npm-debug.log
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.env
|
|
@ -1,10 +1,16 @@
|
||||||
DWENGO_PORT=3000
|
#
|
||||||
|
# Basic configuration
|
||||||
|
#
|
||||||
|
|
||||||
|
DWENGO_PORT=3000 # The port the backend will listen on
|
||||||
DWENGO_DB_HOST=localhost
|
DWENGO_DB_HOST=localhost
|
||||||
DWENGO_DB_PORT=5431
|
DWENGO_DB_PORT=5431
|
||||||
DWENGO_DB_USERNAME=postgres
|
DWENGO_DB_USERNAME=postgres
|
||||||
DWENGO_DB_PASSWORD=postgres
|
DWENGO_DB_PASSWORD=postgres
|
||||||
DWENGO_DB_UPDATE=true
|
DWENGO_DB_UPDATE=true
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
|
||||||
DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student
|
DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student
|
||||||
DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo
|
DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo
|
||||||
DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/openid-connect/certs
|
DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/openid-connect/certs
|
||||||
|
@ -14,3 +20,9 @@ DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/
|
||||||
|
|
||||||
# Allow Vite dev-server to access the backend (for testing purposes). Don't forget to remove this in production!
|
# Allow Vite dev-server to access the backend (for testing purposes). Don't forget to remove this in production!
|
||||||
DWENGO_CORS_ALLOWED_ORIGINS=http://localhost:5173
|
DWENGO_CORS_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
|
#
|
||||||
|
# Advanced configuration
|
||||||
|
#
|
||||||
|
|
||||||
|
# LOKI_HOST=http://localhost:9001 # The address of the Loki instance, used for logging
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
DWENGO_PORT=3000 # The port the backend will listen on
|
DWENGO_PORT=3000 # The port the backend will listen on
|
||||||
DWENGO_DB_HOST=domain-or-ip-of-database
|
DWENGO_DB_HOST=domain-or-ip-of-database
|
||||||
DWENGO_DB_PORT=5432
|
DWENGO_DB_PORT=5431
|
||||||
|
|
||||||
# Change this to the actual credentials of the user Dwengo should use in the backend
|
# Change this to the actual credentials of the user Dwengo should use in the backend
|
||||||
DWENGO_DB_USERNAME=postgres
|
DWENGO_DB_USERNAME=postgres
|
||||||
|
|
28
backend/.env.production.example
Normal file
28
backend/.env.production.example
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
DWENGO_PORT=3000 # The port the backend will listen on
|
||||||
|
DWENGO_DB_HOST=db # Name of the database container
|
||||||
|
DWENGO_DB_PORT=5431
|
||||||
|
|
||||||
|
# Change this to the actual credentials of the user Dwengo should use in the backend
|
||||||
|
DWENGO_DB_NAME=postgres
|
||||||
|
DWENGO_DB_USERNAME=postgres
|
||||||
|
DWENGO_DB_PASSWORD=postgres
|
||||||
|
|
||||||
|
# Set this to true when the database scheme needs to be updated. In that case, take a backup first.
|
||||||
|
DWENGO_DB_UPDATE=false
|
||||||
|
|
||||||
|
# Data for the identity provider via which the students authenticate.
|
||||||
|
DWENGO_AUTH_STUDENT_URL=https://sel2-1.ugent.be/idp/realms/student
|
||||||
|
DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo
|
||||||
|
DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://idp:7080/idp/realms/student/protocol/openid-connect/certs # Name of the idp container
|
||||||
|
# Data for the identity provider via which the teachers authenticate.
|
||||||
|
DWENGO_AUTH_TEACHER_URL=https://sel2-1.ugent.be/idp/realms/teacher
|
||||||
|
DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo
|
||||||
|
DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://idp:7080/idp/realms/teacher/protocol/openid-connect/certs # Name of the idp container
|
||||||
|
|
||||||
|
#
|
||||||
|
# Advanced configuration
|
||||||
|
#
|
||||||
|
|
||||||
|
# Logging and monitoring
|
||||||
|
|
||||||
|
# LOKI_HOST=http://logging:3102 # The address of the Loki instance, used for logging
|
35
backend/Dockerfile
Normal file
35
backend/Dockerfile
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
FROM node:22 AS build-stage
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY backend/package.json ./backend/
|
||||||
|
|
||||||
|
RUN npm install --silent
|
||||||
|
|
||||||
|
# Build the backend
|
||||||
|
|
||||||
|
# Root tsconfig.json
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
COPY backend ./
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22 AS production-stage
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package-lock.json backend/package.json ./
|
||||||
|
|
||||||
|
RUN npm install --silent --only=production
|
||||||
|
|
||||||
|
COPY --from=build-stage /app/backend/dist ./dist/
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "--env-file=.env", "dist/app.js"]
|
|
@ -1,4 +1,4 @@
|
||||||
import express, { Express, Response } from 'express';
|
import express, { Express } from 'express';
|
||||||
import { initORM } from './orm.js';
|
import { initORM } from './orm.js';
|
||||||
|
|
||||||
import themeRoutes from './routes/themes.js';
|
import themeRoutes from './routes/themes.js';
|
||||||
|
@ -13,12 +13,14 @@ import submissionRouter from './routes/submissions.js';
|
||||||
import classRouter from './routes/classes.js';
|
import classRouter from './routes/classes.js';
|
||||||
import questionRouter from './routes/questions.js';
|
import questionRouter from './routes/questions.js';
|
||||||
import authRouter from './routes/auth.js';
|
import authRouter from './routes/auth.js';
|
||||||
|
|
||||||
import { authenticateUser } from './middleware/auth/auth.js';
|
import { authenticateUser } from './middleware/auth/auth.js';
|
||||||
import cors from './middleware/cors.js';
|
import cors from './middleware/cors.js';
|
||||||
import { getLogger, Logger } from './logging/initalize.js';
|
import { getLogger, Logger } from './logging/initalize.js';
|
||||||
import { responseTimeLogger } from './logging/responseTimeLogger.js';
|
import { responseTimeLogger } from './logging/responseTimeLogger.js';
|
||||||
import responseTime from 'response-time';
|
import responseTime from 'response-time';
|
||||||
import { EnvVars, getNumericEnvVar } from './util/envvars.js';
|
import { EnvVars, getNumericEnvVar } from './util/envvars.js';
|
||||||
|
import apiRouter from './routes/router.js';
|
||||||
|
|
||||||
const logger: Logger = getLogger();
|
const logger: Logger = getLogger();
|
||||||
|
|
||||||
|
@ -30,31 +32,13 @@ app.use(express.json());
|
||||||
app.use(responseTime(responseTimeLogger));
|
app.use(responseTime(responseTimeLogger));
|
||||||
app.use(authenticateUser);
|
app.use(authenticateUser);
|
||||||
|
|
||||||
// TODO Replace with Express routes
|
app.get('/api', apiRouter);
|
||||||
app.get('/', (_, res: Response) => {
|
|
||||||
logger.debug('GET /');
|
|
||||||
res.json({
|
|
||||||
message: 'Hello Dwengo!🚀',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use('/student', studentRouter);
|
|
||||||
app.use('/teacher', teacherRouter);
|
|
||||||
app.use('/group', groupRouter);
|
|
||||||
app.use('/assignment', assignmentRouter);
|
|
||||||
app.use('/submission', submissionRouter);
|
|
||||||
app.use('/class', classRouter);
|
|
||||||
app.use('/question', questionRouter);
|
|
||||||
app.use('/auth', authRouter);
|
|
||||||
app.use('/theme', themeRoutes);
|
|
||||||
app.use('/learningPath', learningPathRoutes);
|
|
||||||
app.use('/learningObject', learningObjectRoutes);
|
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
await initORM();
|
await initORM();
|
||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
logger.info(`Server is running at http://localhost:${port}`);
|
logger.info(`Server is running at http://localhost:${port}/api`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
35
backend/src/routes/router.ts
Normal file
35
backend/src/routes/router.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { Response, Router } from 'express';
|
||||||
|
import studentRouter from './student';
|
||||||
|
import groupRouter from './group';
|
||||||
|
import assignmentRouter from './assignment';
|
||||||
|
import submissionRouter from './submission';
|
||||||
|
import classRouter from './class';
|
||||||
|
import questionRouter from './question';
|
||||||
|
import authRouter from './auth';
|
||||||
|
import themeRoutes from './themes';
|
||||||
|
import learningPathRoutes from './learning-paths';
|
||||||
|
import learningObjectRoutes from './learning-objects';
|
||||||
|
import { getLogger, Logger } from '../logging/initalize';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const logger: Logger = getLogger();
|
||||||
|
|
||||||
|
router.get('/', (_, res: Response) => {
|
||||||
|
logger.debug('GET /');
|
||||||
|
res.json({
|
||||||
|
message: 'Hello Dwengo!🚀',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use('/student', studentRouter);
|
||||||
|
router.use('/group', groupRouter);
|
||||||
|
router.use('/assignment', assignmentRouter);
|
||||||
|
router.use('/submission', submissionRouter);
|
||||||
|
router.use('/class', classRouter);
|
||||||
|
router.use('/question', questionRouter);
|
||||||
|
router.use('/auth', authRouter);
|
||||||
|
router.use('/theme', themeRoutes);
|
||||||
|
router.use('/learningPath', learningPathRoutes);
|
||||||
|
router.use('/learningObject', learningObjectRoutes);
|
||||||
|
|
||||||
|
export default router;
|
117
backend/tests/service/learning-objects.test.ts
Normal file
117
backend/tests/service/learning-objects.test.ts
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { LearningObjectMetadata, LearningPath } from '../../src/interfaces/learningPath';
|
||||||
|
import { fetchWithLogging } from '../../src/util/apiHelper';
|
||||||
|
import { getLearningObjectById, getLearningObjectsFromPath } from '../../src/services/learningObjects';
|
||||||
|
import { fetchLearningPaths } from '../../src/services/learningPaths';
|
||||||
|
|
||||||
|
// Mock API functions
|
||||||
|
vi.mock('../../src/util/apiHelper', () => ({
|
||||||
|
fetchWithLogging: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/services/learningPaths', () => ({
|
||||||
|
fetchLearningPaths: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('getLearningObjectById', () => {
|
||||||
|
const hruid = 'test-object';
|
||||||
|
const language = 'en';
|
||||||
|
const mockMetadata: LearningObjectMetadata = {
|
||||||
|
hruid,
|
||||||
|
_id: '123',
|
||||||
|
uuid: 'uuid-123',
|
||||||
|
version: 1,
|
||||||
|
title: 'Test Object',
|
||||||
|
language,
|
||||||
|
difficulty: 5,
|
||||||
|
estimated_time: 120,
|
||||||
|
available: true,
|
||||||
|
teacher_exclusive: false,
|
||||||
|
educational_goals: [{ source: 'source', id: 'id' }],
|
||||||
|
keywords: ['robotics'],
|
||||||
|
description: 'A test object',
|
||||||
|
target_ages: [10, 12],
|
||||||
|
content_type: 'markdown',
|
||||||
|
content_location: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('✅ Should return a filtered learning object when API provides data', async () => {
|
||||||
|
vi.mocked(fetchWithLogging).mockResolvedValueOnce(mockMetadata);
|
||||||
|
|
||||||
|
const result = await getLearningObjectById(hruid, language);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
key: hruid,
|
||||||
|
_id: '123',
|
||||||
|
uuid: 'uuid-123',
|
||||||
|
version: 1,
|
||||||
|
title: 'Test Object',
|
||||||
|
htmlUrl: expect.stringContaining('/learningObject/getRaw?hruid=test-object&language=en'),
|
||||||
|
language,
|
||||||
|
difficulty: 5,
|
||||||
|
estimatedTime: 120,
|
||||||
|
available: true,
|
||||||
|
teacherExclusive: false,
|
||||||
|
educationalGoals: [{ source: 'source', id: 'id' }],
|
||||||
|
keywords: ['robotics'],
|
||||||
|
description: 'A test object',
|
||||||
|
targetAges: [10, 12],
|
||||||
|
contentType: 'markdown',
|
||||||
|
contentLocation: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('⚠️ Should return null if API returns no metadata', async () => {
|
||||||
|
vi.mocked(fetchWithLogging).mockResolvedValueOnce(null);
|
||||||
|
const result = await getLearningObjectById(hruid, language);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLearningObjectsFromPath', () => {
|
||||||
|
const hruid = 'test-path';
|
||||||
|
const language = 'en';
|
||||||
|
|
||||||
|
it('✅ Should not give error or warning', async () => {
|
||||||
|
const mockPathResponse: LearningPath[] = [
|
||||||
|
{
|
||||||
|
_id: 'path-1',
|
||||||
|
hruid,
|
||||||
|
language,
|
||||||
|
title: 'Test Path',
|
||||||
|
description: '',
|
||||||
|
num_nodes: 1,
|
||||||
|
num_nodes_left: 0,
|
||||||
|
nodes: [],
|
||||||
|
keywords: '',
|
||||||
|
target_ages: [],
|
||||||
|
min_age: 10,
|
||||||
|
max_age: 12,
|
||||||
|
__order: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(fetchLearningPaths).mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
source: 'Test Source',
|
||||||
|
data: mockPathResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getLearningObjectsFromPath(hruid, language);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('⚠️ Should give a warning', async () => {
|
||||||
|
vi.mocked(fetchLearningPaths).mockResolvedValueOnce({ success: false, source: 'Test Source', data: [] });
|
||||||
|
|
||||||
|
const result = await getLearningObjectsFromPath(hruid, language);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('❌ Should give an error', async () => {
|
||||||
|
vi.mocked(fetchLearningPaths).mockRejectedValueOnce(new Error('API Error'));
|
||||||
|
|
||||||
|
const result = await getLearningObjectsFromPath(hruid, language);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
90
backend/tests/service/learning-paths.test.ts
Normal file
90
backend/tests/service/learning-paths.test.ts
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { fetchLearningPaths, searchLearningPaths } from '../../src/services/learningPaths';
|
||||||
|
import { fetchWithLogging } from '../../src/util/apiHelper';
|
||||||
|
import { LearningPathResponse } from '../../src/interfaces/learningPath';
|
||||||
|
|
||||||
|
// Mock the fetchWithLogging module using vi
|
||||||
|
vi.mock('../../src/util/apiHelper', () => ({
|
||||||
|
fetchWithLogging: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('fetchLearningPaths', () => {
|
||||||
|
// Mock data and response
|
||||||
|
const mockHruids = ['pn_werking', 'art1'];
|
||||||
|
const language = 'en';
|
||||||
|
const source = 'Test Source';
|
||||||
|
const mockResponse = [{ title: 'Test Path', hruids: mockHruids }];
|
||||||
|
|
||||||
|
it('✅ Should return a successful response when HRUIDs are provided', async () => {
|
||||||
|
// Mock the function to return mockResponse
|
||||||
|
vi.mocked(fetchWithLogging).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result: LearningPathResponse = await fetchLearningPaths(mockHruids, language, source);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual(mockResponse);
|
||||||
|
expect(result.source).toBe(source);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('⚠️ Should return an error when no HRUIDs are provided', async () => {
|
||||||
|
vi.mocked(fetchWithLogging).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result: LearningPathResponse = await fetchLearningPaths([], language, source);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.data).toBeNull();
|
||||||
|
expect(result.message).toBe(`No HRUIDs provided for ${source}.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('⚠️ Should return a failure response when no learning paths are found', async () => {
|
||||||
|
// Mock fetchWithLogging to return an empty array
|
||||||
|
vi.mocked(fetchWithLogging).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result: LearningPathResponse = await fetchLearningPaths(mockHruids, language, source);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.data).toEqual([]);
|
||||||
|
expect(result.message).toBe(`No learning paths found for ${source}.`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchLearningPaths', () => {
|
||||||
|
const query =
|
||||||
|
'https://dwengo.org/backend/api/learningPath/getPathsFromIdList?pathIdList=%7B%22hruids%22:%5B%22pn_werking%22,%22un_artificiele_intelligentie%22%5D%7D&language=nl';
|
||||||
|
const language = 'nl';
|
||||||
|
|
||||||
|
it('✅ Should return search results when API responds with data', async () => {
|
||||||
|
const mockResults = [
|
||||||
|
{
|
||||||
|
_id: '67b4488c9dadb305c4104618',
|
||||||
|
language: 'nl',
|
||||||
|
hruid: 'pn_werking',
|
||||||
|
title: 'Werken met notebooks',
|
||||||
|
description: 'Een korte inleiding tot Python notebooks. Hoe ga je gemakkelijk en efficiënt met de notebooks aan de slag?',
|
||||||
|
num_nodes: 0,
|
||||||
|
num_nodes_left: 0,
|
||||||
|
nodes: [],
|
||||||
|
keywords: 'Python KIKS Wiskunde STEM AI',
|
||||||
|
target_ages: [14, 15, 16, 17, 18],
|
||||||
|
min_age: 14,
|
||||||
|
max_age: 18,
|
||||||
|
__order: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock fetchWithLogging to return search results
|
||||||
|
vi.mocked(fetchWithLogging).mockResolvedValue(mockResults);
|
||||||
|
|
||||||
|
const result = await searchLearningPaths(query, language);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResults);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('⚠️ Should return an empty array when API returns no results', async () => {
|
||||||
|
vi.mocked(fetchWithLogging).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await searchLearningPaths(query, language);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
72
compose.override.yml
Normal file
72
compose.override.yml
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
#
|
||||||
|
# Use this configuration to test the production configuration locally.
|
||||||
|
#
|
||||||
|
# This configuration builds the frontend and backend services as Docker images,
|
||||||
|
# and uses the paths for the services, instead of ports.
|
||||||
|
#
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
ports:
|
||||||
|
- '8080:8080/tcp'
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- 'traefik.http.routers.web.rule=PathPrefix(`/`)'
|
||||||
|
- 'traefik.http.services.web.loadbalancer.server.port=8080'
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
ports:
|
||||||
|
- '3000:3000/tcp'
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./backend/.env:/app/.env
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- logging
|
||||||
|
labels:
|
||||||
|
- 'traefik.http.routers.api.rule=PathPrefix(`/api`)'
|
||||||
|
- 'traefik.http.services.api.loadbalancer.server.port=3000'
|
||||||
|
|
||||||
|
idp:
|
||||||
|
# Also see compose.yml
|
||||||
|
labels:
|
||||||
|
- 'traefik.http.routers.idp.rule=PathPrefix(`/idp`)'
|
||||||
|
- 'traefik.http.services.idp.loadbalancer.server.port=7080'
|
||||||
|
environment:
|
||||||
|
PROXY_ADDRESS_FORWARDING: 'true'
|
||||||
|
KC_HTTP_RELATIVE_PATH: '/idp'
|
||||||
|
|
||||||
|
reverse-proxy:
|
||||||
|
image: traefik:v3.3
|
||||||
|
command:
|
||||||
|
# Enable web UI
|
||||||
|
- '--api.insecure=true'
|
||||||
|
|
||||||
|
# Add Docker provider
|
||||||
|
- '--providers.docker=true'
|
||||||
|
- '--providers.docker.exposedbydefault=true'
|
||||||
|
|
||||||
|
# Add web entrypoint
|
||||||
|
- '--entrypoints.web.address=:80/tcp'
|
||||||
|
ports:
|
||||||
|
- '9000:8080'
|
||||||
|
- '80:80/tcp'
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
|
||||||
|
dashboards:
|
||||||
|
image: grafana/grafana:latest
|
||||||
|
ports:
|
||||||
|
- '9002:3000'
|
||||||
|
volumes:
|
||||||
|
- dwengo_grafana_data:/var/lib/grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
dwengo_grafana_data:
|
112
compose.prod.yml
Normal file
112
compose.prod.yml
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
#
|
||||||
|
# This file is used to define the production environment for the project.
|
||||||
|
# It is used to deploy the project on a server.
|
||||||
|
# Should not be used for local development.
|
||||||
|
#
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- dwengo-1
|
||||||
|
labels:
|
||||||
|
- 'traefik.enable=true'
|
||||||
|
- 'traefik.http.routers.web.rule=PathPrefix(`/`)'
|
||||||
|
- 'traefik.http.services.web.loadbalancer.server.port=8080'
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
# TODO Replace with environment keys
|
||||||
|
- ./backend/.env:/app/.env
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- logging
|
||||||
|
networks:
|
||||||
|
- dwengo-1
|
||||||
|
labels:
|
||||||
|
- 'traefik.enable=true'
|
||||||
|
- 'traefik.http.routers.api.rule=PathPrefix(`/api`)'
|
||||||
|
- 'traefik.http.services.api.loadbalancer.server.port=3000'
|
||||||
|
|
||||||
|
db:
|
||||||
|
# Also see compose.yml
|
||||||
|
networks:
|
||||||
|
- dwengo-1
|
||||||
|
|
||||||
|
idp:
|
||||||
|
# Also see compose.yml
|
||||||
|
# TODO Replace with proper production command
|
||||||
|
command: ['start-dev', '--http-port', '7080', '--https-port', '7443', '--import-realm']
|
||||||
|
networks:
|
||||||
|
- dwengo-1
|
||||||
|
labels:
|
||||||
|
- 'traefik.enable=true'
|
||||||
|
- 'traefik.http.routers.idp.rule=PathPrefix(`/idp`)'
|
||||||
|
- 'traefik.http.services.idp.loadbalancer.server.port=7080'
|
||||||
|
env_file:
|
||||||
|
- ./config/idp/.env
|
||||||
|
environment:
|
||||||
|
KC_HOSTNAME: 'sel2-1.ugent.be'
|
||||||
|
PROXY_ADDRESS_FORWARDING: 'true'
|
||||||
|
KC_PROXY_HEADERS: 'xforwarded'
|
||||||
|
KC_HTTP_ENABLED: 'true'
|
||||||
|
KC_HTTP_RELATIVE_PATH: '/idp'
|
||||||
|
|
||||||
|
reverse-proxy:
|
||||||
|
image: traefik:v3.3
|
||||||
|
ports:
|
||||||
|
- '80:80/tcp'
|
||||||
|
- '443:443/tcp'
|
||||||
|
command:
|
||||||
|
# Add Docker provider
|
||||||
|
- '--providers.docker=true'
|
||||||
|
- '--providers.docker.exposedbydefault=false'
|
||||||
|
|
||||||
|
# Add web entrypoint
|
||||||
|
- '--entrypoints.web.address=:80/tcp'
|
||||||
|
- '--entrypoints.web.http.redirections.entryPoint.to=websecure'
|
||||||
|
- '--entrypoints.web.http.redirections.entryPoint.scheme=https'
|
||||||
|
|
||||||
|
# Add websecure entrypoint
|
||||||
|
- '--entrypoints.websecure.address=:443/tcp'
|
||||||
|
- '--entrypoints.websecure.http.tls=true'
|
||||||
|
- '--entrypoints.websecure.http.tls.certResolver=letsencrypt'
|
||||||
|
- '--entrypoints.websecure.http.tls.domains[0].main=sel2-1.ugent.be'
|
||||||
|
|
||||||
|
# Certificates
|
||||||
|
- '--certificatesresolvers.letsencrypt.acme.httpchallenge=true'
|
||||||
|
- '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web'
|
||||||
|
- '--certificatesresolvers.letsencrypt.acme.email=timo.demeyst@ugent.be'
|
||||||
|
- '--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json'
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- dwengo_letsencrypt:/letsencrypt
|
||||||
|
networks:
|
||||||
|
- dwengo-1
|
||||||
|
|
||||||
|
logging:
|
||||||
|
# Also see compose.yml
|
||||||
|
networks:
|
||||||
|
- dwengo-1
|
||||||
|
|
||||||
|
dashboards:
|
||||||
|
image: grafana/grafana:latest
|
||||||
|
ports:
|
||||||
|
- '9002:3000'
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- dwengo_grafana_data:/var/lib/grafana
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
dwengo_grafana_data:
|
||||||
|
dwengo_letsencrypt:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dwengo-1:
|
|
@ -1,38 +1,32 @@
|
||||||
|
#
|
||||||
|
# Use this configuration during development.
|
||||||
|
#
|
||||||
|
# This configuration is suitable to access the services using their ports.
|
||||||
|
#
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:latest
|
image: postgres:latest
|
||||||
|
ports:
|
||||||
|
- '5431:5432'
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- dwengo_postgres_data:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: postgres
|
POSTGRES_DB: postgres
|
||||||
ports:
|
|
||||||
- '5431:5432'
|
|
||||||
volumes:
|
|
||||||
- dwengo_postgres_data:/var/lib/postgresql/data
|
|
||||||
|
|
||||||
logging:
|
|
||||||
image: grafana/loki:latest
|
|
||||||
ports:
|
|
||||||
- '3102:3102'
|
|
||||||
- '9095:9095'
|
|
||||||
volumes:
|
|
||||||
- ./config/loki/config.yml:/etc/loki/config.yaml
|
|
||||||
- dwengo_loki_data:/loki
|
|
||||||
command: -config.file=/etc/loki/config.yaml
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
dashboards:
|
|
||||||
image: grafana/grafana:latest
|
|
||||||
ports:
|
|
||||||
- '3100:3000'
|
|
||||||
volumes:
|
|
||||||
- dwengo_grafana_data:/var/lib/grafana
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
idp: # Based on: https://medium.com/@fingervinicius/easy-running-keycloak-with-docker-compose-b0d7a4ee2358
|
idp: # Based on: https://medium.com/@fingervinicius/easy-running-keycloak-with-docker-compose-b0d7a4ee2358
|
||||||
image: quay.io/keycloak/keycloak:latest
|
image: quay.io/keycloak/keycloak:latest
|
||||||
|
ports:
|
||||||
|
- '7080:7080'
|
||||||
|
# - '7443:7443'
|
||||||
|
command: ['start-dev', '--http-port', '7080', '--https-port', '7443', '--import-realm']
|
||||||
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./idp:/opt/keycloak/data/import
|
- ./config/idp:/opt/keycloak/data/import
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
environment:
|
environment:
|
||||||
KC_HOSTNAME: localhost
|
KC_HOSTNAME: localhost
|
||||||
KC_HOSTNAME_PORT: 7080
|
KC_HOSTNAME_PORT: 7080
|
||||||
|
@ -41,19 +35,18 @@ services:
|
||||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||||
KC_HEALTH_ENABLED: 'true'
|
KC_HEALTH_ENABLED: 'true'
|
||||||
KC_LOG_LEVEL: info
|
KC_LOG_LEVEL: info
|
||||||
healthcheck:
|
|
||||||
test: ['CMD', 'curl', '-f', 'http://localhost:7080/health/ready']
|
logging:
|
||||||
interval: 15s
|
image: grafana/loki:latest
|
||||||
timeout: 2s
|
|
||||||
retries: 15
|
|
||||||
command: ['start-dev', '--http-port', '7080', '--https-port', '7443', '--import-realm']
|
|
||||||
ports:
|
ports:
|
||||||
- '7080:7080'
|
- '9001:3102'
|
||||||
- '7443:7443'
|
- '9095:9095'
|
||||||
depends_on:
|
command: -config.file=/etc/loki/config.yaml
|
||||||
- db
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./config/loki/config.yml:/etc/loki/config.yaml
|
||||||
|
- dwengo_loki_data:/loki
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
dwengo_postgres_data:
|
|
||||||
dwengo_loki_data:
|
dwengo_loki_data:
|
||||||
dwengo_grafana_data:
|
dwengo_postgres_data:
|
|
@ -620,7 +620,15 @@
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"alwaysDisplayInConsole": false,
|
"alwaysDisplayInConsole": false,
|
||||||
"clientAuthenticatorType": "client-jwt",
|
"clientAuthenticatorType": "client-jwt",
|
||||||
"redirectUris": ["urn:ietf:wg:oauth:2.0:oob", "http://localhost:5173/*", "http://localhost:5173"],
|
"redirectUris": [
|
||||||
|
"urn:ietf:wg:oauth:2.0:oob",
|
||||||
|
"http://localhost:5173/*",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost/*",
|
||||||
|
"http://localhost",
|
||||||
|
"https://sel2-1.ugent.be/*",
|
||||||
|
"https://sel2-1.ugent.be"
|
||||||
|
],
|
||||||
"webOrigins": ["+"],
|
"webOrigins": ["+"],
|
||||||
"notBefore": 0,
|
"notBefore": 0,
|
||||||
"bearerOnly": false,
|
"bearerOnly": false,
|
|
@ -620,7 +620,15 @@
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"alwaysDisplayInConsole": false,
|
"alwaysDisplayInConsole": false,
|
||||||
"clientAuthenticatorType": "client-secret",
|
"clientAuthenticatorType": "client-secret",
|
||||||
"redirectUris": ["urn:ietf:wg:oauth:2.0:oob", "http://localhost:5173/*", "http://localhost:5173"],
|
"redirectUris": [
|
||||||
|
"urn:ietf:wg:oauth:2.0:oob",
|
||||||
|
"http://localhost:5173/*",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost/*",
|
||||||
|
"http://localhost",
|
||||||
|
"https://sel2-1.ugent.be/*",
|
||||||
|
"https://sel2-1.ugent.be"
|
||||||
|
],
|
||||||
"webOrigins": ["+"],
|
"webOrigins": ["+"],
|
||||||
"notBefore": 0,
|
"notBefore": 0,
|
||||||
"bearerOnly": false,
|
"bearerOnly": false,
|
32
config/nginx/nginx.conf
Normal file
32
config/nginx/nginx.conf
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
types {
|
||||||
|
application/javascript mjs;
|
||||||
|
text/css;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
36
frontend/Dockerfile
Normal file
36
frontend/Dockerfile
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
FROM node:22 AS build-stage
|
||||||
|
|
||||||
|
# install simple http server for serving static content
|
||||||
|
RUN npm install -g http-server
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY ./frontend/package.json ./frontend/
|
||||||
|
|
||||||
|
RUN npm install --silent
|
||||||
|
|
||||||
|
# Build the frontend
|
||||||
|
|
||||||
|
# Root tsconfig.json
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY assets ./assets/
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
COPY frontend ./
|
||||||
|
|
||||||
|
RUN npx vite build
|
||||||
|
|
||||||
|
FROM nginx:stable AS production-stage
|
||||||
|
|
||||||
|
COPY config/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
COPY --from=build-stage /app/assets /usr/share/nginx/html/assets
|
||||||
|
COPY --from=build-stage /app/frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
|
@ -1,5 +1,8 @@
|
||||||
export const apiConfig = {
|
export const apiConfig = {
|
||||||
baseUrl: window.location.hostname == "localhost" ? "http://localhost:3000" : window.location.origin,
|
baseUrl:
|
||||||
|
window.location.hostname === "localhost" && !(window.location.port === "80" || window.location.port === "")
|
||||||
|
? "http://localhost:3000/api"
|
||||||
|
: window.location.origin + "/api",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loginRoute = "/login";
|
export const loginRoute = "/login";
|
||||||
|
|
|
@ -12,12 +12,13 @@ import apiClient from "@/services/api-client.ts";
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
|
|
||||||
const authConfig = await loadAuthConfig();
|
async function getUserManagers(): Promise<UserManagersForRoles> {
|
||||||
|
const authConfig = await loadAuthConfig();
|
||||||
const userManagers: UserManagersForRoles = {
|
return {
|
||||||
student: new UserManager(authConfig.student),
|
student: new UserManager(authConfig.student),
|
||||||
teacher: new UserManager(authConfig.teacher),
|
teacher: new UserManager(authConfig.teacher),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the information about who is currently logged in from the IDP.
|
* Load the information about who is currently logged in from the IDP.
|
||||||
|
@ -27,7 +28,7 @@ async function loadUser(): Promise<User | null> {
|
||||||
if (!activeRole) {
|
if (!activeRole) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const user = await userManagers[activeRole].getUser();
|
const user = await (await getUserManagers())[activeRole].getUser();
|
||||||
authState.user = user;
|
authState.user = user;
|
||||||
authState.accessToken = user?.access_token || null;
|
authState.accessToken = user?.access_token || null;
|
||||||
authState.activeRole = activeRole || null;
|
authState.activeRole = activeRole || null;
|
||||||
|
@ -59,7 +60,7 @@ async function initiateLogin() {
|
||||||
async function loginAs(role: Role): Promise<void> {
|
async function loginAs(role: Role): Promise<void> {
|
||||||
// Storing it in local storage so that it won't be lost when redirecting outside of the app.
|
// Storing it in local storage so that it won't be lost when redirecting outside of the app.
|
||||||
authStorage.setActiveRole(role);
|
authStorage.setActiveRole(role);
|
||||||
await userManagers[role].signinRedirect();
|
await (await getUserManagers())[role].signinRedirect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -70,7 +71,7 @@ async function handleLoginCallback(): Promise<void> {
|
||||||
if (!activeRole) {
|
if (!activeRole) {
|
||||||
throw new Error("Login callback received, but the user is not logging in!");
|
throw new Error("Login callback received, but the user is not logging in!");
|
||||||
}
|
}
|
||||||
authState.user = (await userManagers[activeRole].signinCallback()) || null;
|
authState.user = (await (await getUserManagers())[activeRole].signinCallback()) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -84,7 +85,7 @@ async function renewToken() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await userManagers[activeRole].signinSilent();
|
return await (await getUserManagers())[activeRole].signinSilent();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Can't renew the token:");
|
console.log("Can't renew the token:");
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
@ -98,7 +99,7 @@ async function renewToken() {
|
||||||
async function logout(): Promise<void> {
|
async function logout(): Promise<void> {
|
||||||
const activeRole = authStorage.getActiveRole();
|
const activeRole = authStorage.getActiveRole();
|
||||||
if (activeRole) {
|
if (activeRole) {
|
||||||
await userManagers[activeRole].signoutRedirect();
|
await (await getUserManagers())[activeRole].signoutRedirect();
|
||||||
authStorage.deleteActiveRole();
|
authStorage.deleteActiveRole();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue