diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5fbca23a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +**/node_modules/ +**/dist +.git +npm-debug.log +.coverage +.coverage.* +.env \ No newline at end of file diff --git a/backend/.env.development.example b/backend/.env.development.example index 247ff054..466e1b7b 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -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 diff --git a/backend/.env.example b/backend/.env.example index 105a1654..bd13b54c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/.env.production.example b/backend/.env.production.example new file mode 100644 index 00000000..390409d1 --- /dev/null +++ b/backend/.env.production.example @@ -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 diff --git a/backend/.env.test b/backend/.env.test.example similarity index 100% rename from backend/.env.test rename to backend/.env.test.example diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..5f26847c --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/src/app.ts b/backend/src/app.ts index ede3dddf..b6678af3 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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`); }); } diff --git a/backend/src/routes/router.ts b/backend/src/routes/router.ts new file mode 100644 index 00000000..391f3ab5 --- /dev/null +++ b/backend/src/routes/router.ts @@ -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; diff --git a/backend/tests/service/learning-objects.test.ts b/backend/tests/service/learning-objects.test.ts new file mode 100644 index 00000000..130c237e --- /dev/null +++ b/backend/tests/service/learning-objects.test.ts @@ -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([]); + }); +}); diff --git a/backend/tests/service/learning-paths.test.ts b/backend/tests/service/learning-paths.test.ts new file mode 100644 index 00000000..c002dbac --- /dev/null +++ b/backend/tests/service/learning-paths.test.ts @@ -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([]); + }); +}); diff --git a/compose.override.yml b/compose.override.yml new file mode 100644 index 00000000..5c35441e --- /dev/null +++ b/compose.override.yml @@ -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: diff --git a/compose.prod.yml b/compose.prod.yml new file mode 100644 index 00000000..8825796e --- /dev/null +++ b/compose.prod.yml @@ -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: diff --git a/docker-compose.yml b/compose.yml similarity index 74% rename from docker-compose.yml rename to compose.yml index 4ef03dfb..1276c1af 100644 --- a/docker-compose.yml +++ b/compose.yml @@ -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: diff --git a/idp/README.md b/config/idp/README.md similarity index 100% rename from idp/README.md rename to config/idp/README.md diff --git a/idp/student-realm.json b/config/idp/student-realm.json similarity index 99% rename from idp/student-realm.json rename to config/idp/student-realm.json index 697fda34..32107e4e 100644 --- a/idp/student-realm.json +++ b/config/idp/student-realm.json @@ -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, diff --git a/idp/teacher-realm.json b/config/idp/teacher-realm.json similarity index 99% rename from idp/teacher-realm.json rename to config/idp/teacher-realm.json index fd965e96..b9d29dcb 100644 --- a/idp/teacher-realm.json +++ b/config/idp/teacher-realm.json @@ -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, diff --git a/config/nginx/nginx.conf b/config/nginx/nginx.conf new file mode 100644 index 00000000..dc9317f6 --- /dev/null +++ b/config/nginx/nginx.conf @@ -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; + } + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..9cbb61ea --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 9feb71b3..53d6f253 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -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"; diff --git a/frontend/src/services/auth/auth-service.ts b/frontend/src/services/auth/auth-service.ts index 61032170..53f8cb61 100644 --- a/frontend/src/services/auth/auth-service.ts +++ b/frontend/src/services/auth/auth-service.ts @@ -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 { + 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 { 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 { // 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 { 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 { const activeRole = authStorage.getActiveRole(); if (activeRole) { - await userManagers[activeRole].signoutRedirect(); + await (await getUserManagers())[activeRole].signoutRedirect(); authStorage.deleteActiveRole(); } }