Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 19 additions & 41 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
28 changes: 27 additions & 1 deletion src/app/app.config.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
5 changes: 5 additions & 0 deletions src/app/core/constants/ssr-config.token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { InjectionToken } from '@angular/core';

import { ConfigModel } from '@core/models/config.model';

export const SSR_CONFIG = new InjectionToken<ConfigModel>('SSR_CONFIG');
67 changes: 52 additions & 15 deletions src/app/core/interceptors/auth.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EnvironmentModel> = {}) => {
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();
});

Expand All @@ -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();

Expand All @@ -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();

Expand All @@ -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();

Expand All @@ -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();

Expand All @@ -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' },
Expand All @@ -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/');
Expand All @@ -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/');
Expand All @@ -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);
});
});
14 changes: 13 additions & 1 deletion src/app/core/interceptors/auth.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>,
Expand All @@ -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');

Expand All @@ -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);
Expand Down
Loading
Loading