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_PORT=5431 | ||||
| DWENGO_DB_USERNAME=postgres | ||||
| DWENGO_DB_PASSWORD=postgres | ||||
| DWENGO_DB_UPDATE=true | ||||
| 
 | ||||
| # Auth | ||||
| 
 | ||||
| DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student | ||||
| DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo | ||||
| 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! | ||||
| 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_PORT=5432 | ||||
| DWENGO_DB_PORT=5431 | ||||
| 
 | ||||
| # Change this to the actual credentials of the user Dwengo should use in the backend | ||||
| 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 themeRoutes from './routes/themes.js'; | ||||
|  | @ -13,12 +13,14 @@ import submissionRouter from './routes/submissions.js'; | |||
| import classRouter from './routes/classes.js'; | ||||
| import questionRouter from './routes/questions.js'; | ||||
| import authRouter from './routes/auth.js'; | ||||
| 
 | ||||
| import { authenticateUser } from './middleware/auth/auth.js'; | ||||
| import cors from './middleware/cors.js'; | ||||
| import { getLogger, Logger } from './logging/initalize.js'; | ||||
| import { responseTimeLogger } from './logging/responseTimeLogger.js'; | ||||
| import responseTime from 'response-time'; | ||||
| import { EnvVars, getNumericEnvVar } from './util/envvars.js'; | ||||
| import apiRouter from './routes/router.js'; | ||||
| 
 | ||||
| const logger: Logger = getLogger(); | ||||
| 
 | ||||
|  | @ -30,31 +32,13 @@ app.use(express.json()); | |||
| app.use(responseTime(responseTimeLogger)); | ||||
| app.use(authenticateUser); | ||||
| 
 | ||||
| // TODO Replace with Express routes
 | ||||
| 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); | ||||
| app.get('/api', apiRouter); | ||||
| 
 | ||||
| async function startServer() { | ||||
|     await initORM(); | ||||
| 
 | ||||
|     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: | ||||
|     db: | ||||
|         image: postgres:latest | ||||
|         ports: | ||||
|             - '5431:5432' | ||||
|         restart: unless-stopped | ||||
|         volumes: | ||||
|             - dwengo_postgres_data:/var/lib/postgresql/data | ||||
|         environment: | ||||
|             POSTGRES_USER: postgres | ||||
|             POSTGRES_PASSWORD: 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 | ||||
|         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: | ||||
|             - ./idp:/opt/keycloak/data/import | ||||
|             - ./config/idp:/opt/keycloak/data/import | ||||
|         depends_on: | ||||
|             - db | ||||
|         environment: | ||||
|             KC_HOSTNAME: localhost | ||||
|             KC_HOSTNAME_PORT: 7080 | ||||
|  | @ -41,19 +35,18 @@ services: | |||
|             KC_BOOTSTRAP_ADMIN_PASSWORD: admin | ||||
|             KC_HEALTH_ENABLED: 'true' | ||||
|             KC_LOG_LEVEL: info | ||||
|         healthcheck: | ||||
|             test: ['CMD', 'curl', '-f', 'http://localhost:7080/health/ready'] | ||||
|             interval: 15s | ||||
|             timeout: 2s | ||||
|             retries: 15 | ||||
|         command: ['start-dev', '--http-port', '7080', '--https-port', '7443', '--import-realm'] | ||||
| 
 | ||||
|     logging: | ||||
|         image: grafana/loki:latest | ||||
|         ports: | ||||
|             - '7080:7080' | ||||
|             - '7443:7443' | ||||
|         depends_on: | ||||
|             - db | ||||
|             - '9001:3102' | ||||
|             - '9095:9095' | ||||
|         command: -config.file=/etc/loki/config.yaml | ||||
|         restart: unless-stopped | ||||
|         volumes: | ||||
|             - ./config/loki/config.yml:/etc/loki/config.yaml | ||||
|             - dwengo_loki_data:/loki | ||||
| 
 | ||||
| volumes: | ||||
|     dwengo_postgres_data: | ||||
|     dwengo_loki_data: | ||||
|     dwengo_grafana_data: | ||||
|     dwengo_postgres_data: | ||||
|  | @ -620,7 +620,15 @@ | |||
|             "enabled": true, | ||||
|             "alwaysDisplayInConsole": false, | ||||
|             "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": ["+"], | ||||
|             "notBefore": 0, | ||||
|             "bearerOnly": false, | ||||
|  | @ -620,7 +620,15 @@ | |||
|             "enabled": true, | ||||
|             "alwaysDisplayInConsole": false, | ||||
|             "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": ["+"], | ||||
|             "notBefore": 0, | ||||
|             "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 = { | ||||
|     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"; | ||||
|  |  | |||
|  | @ -12,12 +12,13 @@ import apiClient from "@/services/api-client.ts"; | |||
| import router from "@/router"; | ||||
| import type { AxiosError } from "axios"; | ||||
| 
 | ||||
| const authConfig = await loadAuthConfig(); | ||||
| 
 | ||||
| const userManagers: UserManagersForRoles = { | ||||
|     student: new UserManager(authConfig.student), | ||||
|     teacher: new UserManager(authConfig.teacher), | ||||
| }; | ||||
| async function getUserManagers(): Promise<UserManagersForRoles> { | ||||
|     const authConfig = await loadAuthConfig(); | ||||
|     return { | ||||
|         student: new UserManager(authConfig.student), | ||||
|         teacher: new UserManager(authConfig.teacher), | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Load the information about who is currently logged in from the IDP. | ||||
|  | @ -27,7 +28,7 @@ async function loadUser(): Promise<User | null> { | |||
|     if (!activeRole) { | ||||
|         return null; | ||||
|     } | ||||
|     const user = await userManagers[activeRole].getUser(); | ||||
|     const user = await (await getUserManagers())[activeRole].getUser(); | ||||
|     authState.user = user; | ||||
|     authState.accessToken = user?.access_token || null; | ||||
|     authState.activeRole = activeRole || null; | ||||
|  | @ -59,7 +60,7 @@ async function initiateLogin() { | |||
| 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.
 | ||||
|     authStorage.setActiveRole(role); | ||||
|     await userManagers[role].signinRedirect(); | ||||
|     await (await getUserManagers())[role].signinRedirect(); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  | @ -70,7 +71,7 @@ async function handleLoginCallback(): Promise<void> { | |||
|     if (!activeRole) { | ||||
|         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; | ||||
|     } | ||||
|     try { | ||||
|         return await userManagers[activeRole].signinSilent(); | ||||
|         return await (await getUserManagers())[activeRole].signinSilent(); | ||||
|     } catch (error) { | ||||
|         console.log("Can't renew the token:"); | ||||
|         console.log(error); | ||||
|  | @ -98,7 +99,7 @@ async function renewToken() { | |||
| async function logout(): Promise<void> { | ||||
|     const activeRole = authStorage.getActiveRole(); | ||||
|     if (activeRole) { | ||||
|         await userManagers[activeRole].signoutRedirect(); | ||||
|         await (await getUserManagers())[activeRole].signoutRedirect(); | ||||
|         authStorage.deleteActiveRole(); | ||||
|     } | ||||
| } | ||||
|  |  | |||
		Reference in a new issue
	
	 Laure Jablonski
						Laure Jablonski