diff --git a/Dockerfile b/Dockerfile index 1692b05bc..f487a8ccb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,59 +1,37 @@ -# Build -FROM node:22-alpine AS build - +# Dependencies stage +FROM node:22-alpine AS deps WORKDIR /app - COPY package*.json ./ -RUN npm install +RUN npm ci --no-audit --no-fund +# Build stage (SSR build output) +FROM deps AS build COPY . . +RUN NG_BUILD_OPTIMIZE_CHUNKS=1 npx ng build --configuration=ssr --verbose -RUN npm link @angular/cli -RUN NG_BUILD_OPTIMIZE_CHUNKS=1 ng build --verbose - -# Dist -FROM node:22-alpine AS dist - -WORKDIR /code - -COPY --from=build /app/dist /code/dist - -# SSR -FROM node:22-alpine AS ssr - +# SSR runtime stage +FROM build AS ssr WORKDIR /app - -COPY package*.json ./ -RUN npm install - -COPY . . - -RUN npm link @angular/cli -RUN NG_BUILD_OPTIMIZE_CHUNKS=1 ng build --configuration=ssr --verbose - -RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund - +RUN npm prune --omit=dev --no-audit --no-fund EXPOSE 4000 - ENV PORT=4000 - CMD ["node", "dist/osf/server/server.mjs"] -# Dev - run only -FROM build AS dev +# Static dist artifact stage +FROM node:22-alpine AS dist +WORKDIR /code +COPY --from=build /app/dist /code/dist +# Dev server stage +FROM deps AS dev +COPY . . EXPOSE 4200 +CMD ["npx", "ng", "serve", "--host", "0.0.0.0"] -CMD ["ng", "serve"] - -# Local Development - coding +# Local development stage FROM node:22-alpine AS local-dev WORKDIR /app - -# Install deps in the image (kept in container) COPY package*.json ./ -# COPY package-lock.docker.json ./package-lock.json RUN npm ci --no-audit --no-fund - -# Expose Angular dev server EXPOSE 4200 +CMD ["npx", "ng", "serve", "--host", "0.0.0.0"] diff --git a/src/app/app.config.server.ts b/src/app/app.config.server.ts index 1f4702fcd..e74363385 100644 --- a/src/app/app.config.server.ts +++ b/src/app/app.config.server.ts @@ -2,11 +2,37 @@ import { ApplicationConfig, mergeApplicationConfig } from '@angular/core'; import { provideServerRendering } from '@angular/platform-server'; import { provideServerRouting } from '@angular/ssr'; +import { SSR_CONFIG } from '@core/constants/ssr-config.token'; +import { ConfigModel } from '@core/models/config.model'; + import { appConfig } from './app.config'; import { serverRoutes } from './app.routes.server'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function loadSsrConfig(): ConfigModel { + const serverDistFolder = dirname(fileURLToPath(import.meta.url)); + const configPath = resolve(serverDistFolder, '../browser/assets/config/config.json'); + + if (existsSync(configPath)) { + try { + return JSON.parse(readFileSync(configPath, 'utf-8')); + } catch { + return {} as ConfigModel; + } + } + + return {} as ConfigModel; +} + const serverConfig: ApplicationConfig = { - providers: [provideServerRendering(), provideServerRouting(serverRoutes)], + providers: [ + provideServerRendering(), + provideServerRouting(serverRoutes), + { provide: SSR_CONFIG, useFactory: loadSsrConfig }, + ], }; export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/src/app/core/constants/ssr-config.token.ts b/src/app/core/constants/ssr-config.token.ts new file mode 100644 index 000000000..4a1d25df9 --- /dev/null +++ b/src/app/core/constants/ssr-config.token.ts @@ -0,0 +1,5 @@ +import { InjectionToken } from '@angular/core'; + +import { ConfigModel } from '@core/models/config.model'; + +export const SSR_CONFIG = new InjectionToken('SSR_CONFIG'); diff --git a/src/app/core/interceptors/auth.interceptor.spec.ts b/src/app/core/interceptors/auth.interceptor.spec.ts index 62e9a5f9c..777d1d639 100644 --- a/src/app/core/interceptors/auth.interceptor.spec.ts +++ b/src/app/core/interceptors/auth.interceptor.spec.ts @@ -4,35 +4,32 @@ import { MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; import { HttpRequest } from '@angular/common/http'; -import { runInInjectionContext } from '@angular/core'; +import { PLATFORM_ID, runInInjectionContext } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { EnvironmentModel } from '@osf/shared/models/environment.model'; + import { authInterceptor } from './auth.interceptor'; describe('authInterceptor', () => { let cookieService: CookieService; let mockHandler: jest.Mock; - beforeEach(() => { - mockHandler = jest.fn(); - + const setup = (platformId = 'browser', environmentOverrides: Partial = {}) => { TestBed.configureTestingModule({ providers: [ - MockProvider(CookieService, { - get: jest.fn(), - }), - { - provide: 'PLATFORM_ID', - useValue: 'browser', - }, - { - provide: 'REQUEST', - useValue: null, - }, + MockProvider(CookieService, { get: jest.fn() }), + MockProvider(PLATFORM_ID, platformId), + MockProvider(ENVIRONMENT, { throttleToken: '', ...environmentOverrides } as EnvironmentModel), ], }); cookieService = TestBed.inject(CookieService); + }; + + beforeEach(() => { + mockHandler = jest.fn(); jest.clearAllMocks(); }); @@ -49,6 +46,7 @@ describe('authInterceptor', () => { }; it('should skip CrossRef funders API requests', () => { + setup(); const request = createRequest('/api.crossref.org/funders/10.13039/100000001'); const handler = createHandler(); @@ -60,6 +58,7 @@ describe('authInterceptor', () => { }); it('should set Accept header to */* for text response type', () => { + setup(); const request = createRequest('/api/v2/projects/', { responseType: 'text' }); const handler = createHandler(); @@ -71,6 +70,7 @@ describe('authInterceptor', () => { }); it('should set Accept header to API version for json response type', () => { + setup(); const request = createRequest('/api/v2/projects/', { responseType: 'json' }); const handler = createHandler(); @@ -82,6 +82,7 @@ describe('authInterceptor', () => { }); it('should set Content-Type header when not present', () => { + setup(); const request = createRequest('/api/v2/projects/'); const handler = createHandler(); @@ -93,6 +94,7 @@ describe('authInterceptor', () => { }); it('should not override existing Content-Type header', () => { + setup(); const request = createRequest('/api/v2/projects/'); const requestWithHeaders = request.clone({ setHeaders: { 'Content-Type': 'application/json' }, @@ -107,6 +109,7 @@ describe('authInterceptor', () => { }); it('should add CSRF token and withCredentials in browser platform', () => { + setup(); jest.spyOn(cookieService, 'get').mockReturnValue('csrf-token-123'); const request = createRequest('/api/v2/projects/'); @@ -122,6 +125,7 @@ describe('authInterceptor', () => { }); it('should not add CSRF token when not available in browser platform', () => { + setup(); jest.spyOn(cookieService, 'get').mockReturnValue(''); const request = createRequest('/api/v2/projects/'); @@ -135,4 +139,37 @@ describe('authInterceptor', () => { expect(modifiedRequest.headers.has('X-CSRFToken')).toBe(false); expect(modifiedRequest.withCredentials).toBe(true); }); + + it('should not add X-Throttle-Token on browser platform', () => { + setup('browser', { throttleToken: 'test-token' }); + const request = createRequest('/api/v2/projects/'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => authInterceptor(request, handler)); + + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.headers.has('X-Throttle-Token')).toBe(false); + }); + + it('should add X-Throttle-Token on server platform when token is present', () => { + setup('server', { throttleToken: 'test-token' }); + const request = createRequest('/api/v2/projects/'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => authInterceptor(request, handler)); + + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.headers.get('X-Throttle-Token')).toBe('test-token'); + }); + + it('should not add X-Throttle-Token on server platform when token is empty', () => { + setup('server', { throttleToken: '' }); + const request = createRequest('/api/v2/projects/'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => authInterceptor(request, handler)); + + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.headers.has('X-Throttle-Token')).toBe(false); + }); }); diff --git a/src/app/core/interceptors/auth.interceptor.ts b/src/app/core/interceptors/auth.interceptor.ts index 5d192b8e8..cc7c9e178 100644 --- a/src/app/core/interceptors/auth.interceptor.ts +++ b/src/app/core/interceptors/auth.interceptor.ts @@ -2,8 +2,11 @@ import { CookieService } from 'ngx-cookie-service'; import { Observable } from 'rxjs'; +import { isPlatformServer } from '@angular/common'; import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http'; -import { inject } from '@angular/core'; +import { inject, PLATFORM_ID } from '@angular/core'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; export const authInterceptor: HttpInterceptorFn = ( req: HttpRequest, @@ -13,6 +16,7 @@ export const authInterceptor: HttpInterceptorFn = ( return next(req); } + const platformId = inject(PLATFORM_ID); const cookieService = inject(CookieService); const csrfToken = cookieService.get('api-csrf'); @@ -28,6 +32,14 @@ export const authInterceptor: HttpInterceptorFn = ( headers['X-CSRFToken'] = csrfToken; } + if (isPlatformServer(platformId)) { + const environment = inject(ENVIRONMENT); + + if (environment.throttleToken) { + headers['X-Throttle-Token'] = environment.throttleToken; + } + } + const authReq = req.clone({ setHeaders: headers, withCredentials: true }); return next(authReq); diff --git a/src/app/core/services/osf-config.service.spec.ts b/src/app/core/services/osf-config.service.spec.ts index 2c899ab48..c2c87234d 100644 --- a/src/app/core/services/osf-config.service.spec.ts +++ b/src/app/core/services/osf-config.service.spec.ts @@ -1,65 +1,108 @@ -import { HttpTestingController } from '@angular/common/http/testing'; +import { MockProvider } from 'ng-mocks'; + +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { PLATFORM_ID } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { SSR_CONFIG } from '@core/constants/ssr-config.token'; import { ConfigModel } from '@core/models/config.model'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { EnvironmentModel } from '@osf/shared/models/environment.model'; import { OSFConfigService } from './osf-config.service'; -import { OSFTestingModule } from '@testing/osf.testing.module'; - -describe('Service: Config', () => { +describe('OSFConfigService', () => { let service: OSFConfigService; - let httpMock: HttpTestingController; let environment: EnvironmentModel; const mockConfig: ConfigModel = { + sentryDsn: 'https://sentry.example.com/123', + googleTagManagerId: 'GTM-TEST', + googleFilePickerApiKey: '', + googleFilePickerAppId: 0, apiDomainUrl: 'https://api.example.com', - production: true, - } as any; // Cast to any if index signature isn’t added + }; - beforeEach(async () => { - jest.clearAllMocks(); - await TestBed.configureTestingModule({ - imports: [OSFTestingModule], - providers: [OSFConfigService], - }).compileComponents(); + const setupBrowser = () => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), MockProvider(PLATFORM_ID, 'browser')], + }); service = TestBed.inject(OSFConfigService); - httpMock = TestBed.inject(HttpTestingController); environment = TestBed.inject(ENVIRONMENT); - }); + }; - it('should return a value with get()', async () => { - let loadPromise = service.load(); - const request = httpMock.expectOne('/assets/config/config.json'); - request.flush(mockConfig); - await loadPromise; - expect(environment.apiDomainUrl).toBe('https://api.example.com'); - expect(environment.production).toBeTruthy(); - loadPromise = service.load(); + const setupServer = (ssrConfig: ConfigModel | null = null) => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + MockProvider(PLATFORM_ID, 'server'), + ...(ssrConfig ? [{ provide: SSR_CONFIG, useValue: ssrConfig }] : []), + ], + }); + + service = TestBed.inject(OSFConfigService); + environment = TestBed.inject(ENVIRONMENT); + }; + + it('should load config via HTTP on browser and merge into ENVIRONMENT', async () => { + setupBrowser(); + const httpMock = TestBed.inject(HttpTestingController); + + const loadPromise = service.load(); + httpMock.expectOne('/assets/config/config.json').flush(mockConfig); await loadPromise; expect(environment.apiDomainUrl).toBe('https://api.example.com'); - expect(environment.production).toBeTruthy(); - - expect(httpMock.verify()).toBeUndefined(); + expect(environment.sentryDsn).toBe('https://sentry.example.com/123'); + httpMock.verify(); }); - it('should return a value with ahs()', async () => { - let loadPromise = service.load(); - const request = httpMock.expectOne('/assets/config/config.json'); - request.flush(mockConfig); - await loadPromise; + it('should only fetch config once on repeated load calls', async () => { + setupBrowser(); + const httpMock = TestBed.inject(HttpTestingController); + + const firstLoad = service.load(); + httpMock.expectOne('/assets/config/config.json').flush(mockConfig); + await firstLoad; + + await service.load(); + httpMock.expectNone('/assets/config/config.json'); + expect(environment.apiDomainUrl).toBe('https://api.example.com'); - expect(environment.production).toBeTruthy(); + httpMock.verify(); + }); - loadPromise = service.load(); + it('should fallback to empty config on HTTP error', async () => { + setupBrowser(); + const httpMock = TestBed.inject(HttpTestingController); + const originalUrl = environment.apiDomainUrl; + + const loadPromise = service.load(); + httpMock.expectOne('/assets/config/config.json').error(new ProgressEvent('error')); await loadPromise; + + expect(environment.apiDomainUrl).toBe(originalUrl); + httpMock.verify(); + }); + + it('should load config from SSR_CONFIG on server and merge into ENVIRONMENT', async () => { + setupServer(mockConfig); + + await service.load(); + expect(environment.apiDomainUrl).toBe('https://api.example.com'); - expect(environment.production).toBeTruthy(); + expect(environment.sentryDsn).toBe('https://sentry.example.com/123'); + }); + + it('should fallback to empty config on server when SSR_CONFIG is not provided', async () => { + setupServer(); + const originalUrl = environment.apiDomainUrl; + + await service.load(); - expect(httpMock.verify()).toBeUndefined(); + expect(environment.apiDomainUrl).toBe(originalUrl); }); }); diff --git a/src/app/core/services/osf-config.service.ts b/src/app/core/services/osf-config.service.ts index 7034d30b0..d9975a1f7 100644 --- a/src/app/core/services/osf-config.service.ts +++ b/src/app/core/services/osf-config.service.ts @@ -4,44 +4,18 @@ import { isPlatformBrowser } from '@angular/common'; import { HttpClient } from '@angular/common/http'; import { inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { SSR_CONFIG } from '@core/constants/ssr-config.token'; import { ConfigModel } from '@core/models/config.model'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -/** - * Service for loading and accessing configuration values - * from the static JSON file at `/assets/config/config.json`. - * - * This service ensures that the configuration is only fetched once - * and made available application-wide via promise-based access. - * - * Consumers must call `get()` or `has()` using `await` to ensure - * that config values are available after loading completes. - */ @Injectable({ providedIn: 'root' }) export class OSFConfigService { - /** - * Angular's HttpClient used to fetch the configuration JSON. - * Injected via Angular's dependency injection system. - */ private http: HttpClient = inject(HttpClient); private platformId = inject(PLATFORM_ID); - - /** - * Injected instance of the application environment configuration. - * */ private environment = inject(ENVIRONMENT); - - /** - * Stores the loaded configuration object after it is fetched from the server. - * Remains `null` until `load()` is successfully called. - */ + private ssrConfig = inject(SSR_CONFIG, { optional: true }); private config: ConfigModel | null = null; - /** - * Loads the configuration from the JSON file if not already loaded. - * Ensures that only one request is made. - * On the server, this is skipped as config is only needed in the browser. - */ async load(): Promise { if (this.config) return; @@ -53,8 +27,7 @@ export class OSFConfigService { ) ); } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.config = ((globalThis as any).__SSR_CONFIG__ ?? {}) as ConfigModel; + this.config = (this.ssrConfig ?? {}) as ConfigModel; } for (const [key, value] of Object.entries(this.config)) { diff --git a/src/app/shared/models/environment.model.ts b/src/app/shared/models/environment.model.ts index 9b399bbed..dfefa3712 100644 --- a/src/app/shared/models/environment.model.ts +++ b/src/app/shared/models/environment.model.ts @@ -63,4 +63,5 @@ export interface EnvironmentModel { * @example 123456789012 */ googleFilePickerAppId: number; + throttleToken: string; } diff --git a/src/assets/config/template.json b/src/assets/config/template.json index 55891f4a1..6da594e3f 100644 --- a/src/assets/config/template.json +++ b/src/assets/config/template.json @@ -26,5 +26,6 @@ "newRelicLoaderConfigTrustKey": "", "newRelicLoaderConfigAgentID": "", "newRelicLoaderConfigLicenseKey": "", - "newRelicLoaderConfigApplicationID": "" + "newRelicLoaderConfigApplicationID": "", + "throttleToken": "" } diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index c4230b680..f0ea82587 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -71,4 +71,5 @@ export const environment = { newRelicLoaderConfigAgentID: '', newRelicLoaderConfigLicenseKey: '', newRelicLoaderConfigApplicationID: '', + throttleToken: '', }; diff --git a/src/environments/environment.docker.ts b/src/environments/environment.docker.ts index 92a84f2ee..187583b6a 100644 --- a/src/environments/environment.docker.ts +++ b/src/environments/environment.docker.ts @@ -28,4 +28,5 @@ export const environment = { newRelicLoaderConfigAgentID: '', newRelicLoaderConfigLicenseKey: '', newRelicLoaderConfigApplicationID: '', + throttleToken: '', }; diff --git a/src/environments/environment.staging.ts b/src/environments/environment.staging.ts index a7725102f..bcbdc2133 100644 --- a/src/environments/environment.staging.ts +++ b/src/environments/environment.staging.ts @@ -71,4 +71,5 @@ export const environment = { newRelicLoaderConfigAgentID: '', newRelicLoaderConfigLicenseKey: '', newRelicLoaderConfigApplicationID: '', + throttleToken: '', }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index cbffe140e..c9352644b 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -71,4 +71,5 @@ export const environment = { newRelicLoaderConfigAgentID: '', newRelicLoaderConfigLicenseKey: '', newRelicLoaderConfigApplicationID: '', + throttleToken: '', }; diff --git a/src/server.ts b/src/server.ts index 272569de9..1a7ffc292 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,25 +6,12 @@ import { } from '@angular/ssr/node'; import express from 'express'; -import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const serverDistFolder = dirname(fileURLToPath(import.meta.url)); const browserDistFolder = resolve(serverDistFolder, '../browser'); -const configPath = resolve(browserDistFolder, 'assets/config/config.json'); - -if (existsSync(configPath)) { - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).__SSR_CONFIG__ = JSON.parse(readFileSync(configPath, 'utf-8')); - } catch { - // eslint-disable-next-line no-console - console.warn('Failed to parse SSR config at', configPath); - } -} - const app = express(); const angularApp = new AngularNodeAppEngine();