diff --git a/.eslintrc b/.eslintrc index 6874c4a..c811b02 100644 --- a/.eslintrc +++ b/.eslintrc @@ -21,7 +21,8 @@ "rules": { "prettier/prettier": 2, "no-console": 0, - "no-process-env": 0 + "no-process-env": 0, + "line-comment-position": 0 }, "globals": { "NodeJS": true diff --git a/src/interfaces/authed-request.ts b/src/interfaces/authed-request.ts new file mode 100644 index 0000000..5a2e696 --- /dev/null +++ b/src/interfaces/authed-request.ts @@ -0,0 +1,5 @@ +import { Request } from 'express'; + +export interface AuthedRequest extends Request { + jwt?: string; +} diff --git a/src/interfaces/jwt-payload-with-user.ts b/src/interfaces/jwt-payload-with-user.ts index 902551e..2f554c5 100644 --- a/src/interfaces/jwt-payload-with-user.ts +++ b/src/interfaces/jwt-payload-with-user.ts @@ -3,5 +3,5 @@ import { JwtPayload } from 'jsonwebtoken'; import { User } from './user.interface'; export interface JWTPayloadWithUser extends JwtPayload { - user: User; + user?: User; } diff --git a/src/middleware/ensure-authenticated.ts b/src/middleware/ensure-authenticated.ts index 1492a4a..ac4757f 100644 --- a/src/middleware/ensure-authenticated.ts +++ b/src/middleware/ensure-authenticated.ts @@ -1,27 +1,35 @@ import { RequestHandler } from 'express'; import JWT from 'jsonwebtoken'; +import { AuthedRequest } from '../interfaces/authed-request'; import { JWTPayloadWithUser } from '../interfaces/jwt-payload-with-user'; import { logger } from '../utils/logger'; -export const ensureAuthenticated: RequestHandler = (req, res, next) => { +export const ensureAuthenticated: RequestHandler = (req: AuthedRequest, res, next) => { logger.debug('checking if user is authenticated...'); - if (!req.cookies.jwt) { - logger.error('JWT cookie not found'); - return res.redirect('/auth/login'); - } + try { + if (!req.cookies.jwt) { + throw new Error('JWT cookie not found'); + } - const secret = process.env.JWT_SECRET || ''; - const decoded = JWT.verify(req.cookies.jwt, secret) as JWTPayloadWithUser; + const secret = process.env.JWT_SECRET || ''; + const token = req.cookies.jwt; + const decoded = JWT.verify(token, secret) as JWTPayloadWithUser; - if (decoded.exp && decoded.exp <= Date.now() / 1000) { - logger.error('JWT token has expired'); - return res.redirect('/auth/login'); - } + if (decoded.exp && decoded.exp <= Date.now() / 1000) { + throw new Error('JWT token has expired'); + } - req.user = decoded.user; - logger.info('user is authenticated'); + // store the token string as we need it for the auth header in the API requests + req.jwt = token; + req.user = decoded.user; + logger.info('user is authenticated'); + } catch (err) { + logger.error(err); + res.status(401); + return res.redirect('/auth/login?error=unauthenticated'); + } return next(); }; diff --git a/src/routes/healthcheck.ts b/src/routes/healthcheck.ts index 8e2cd9f..751041b 100644 --- a/src/routes/healthcheck.ts +++ b/src/routes/healthcheck.ts @@ -1,17 +1,18 @@ import { Router } from 'express'; -import { API } from '../services/api'; +import { StatsWalesApi } from '../services/stats-wales-api'; import { logger } from '../utils/logger'; -const APIInstance = new API(); - export const healthcheck = Router(); healthcheck.get('/', async (req, res) => { const lang = req.i18n.language || 'en-GB'; + const APIInstance = new StatsWalesApi(lang); logger.info(`Healthcheck requested in ${lang}`); + const statusMsg = req.t('app-running'); const beConnected = await APIInstance.ping(); + res.json({ status: statusMsg, notes: req.t('health-notes'), diff --git a/src/routes/publish.ts b/src/routes/publish.ts index d9f910e..5d1ef9d 100644 --- a/src/routes/publish.ts +++ b/src/routes/publish.ts @@ -1,22 +1,16 @@ import { Blob } from 'buffer'; import { Request, Response, Router } from 'express'; -import pino from 'pino'; import multer from 'multer'; +import { logger } from '../utils/logger'; import { ViewErrDTO } from '../dtos/view-dto'; import { i18next } from '../middleware/translation'; -import { API } from '../services/api'; +import { StatsWalesApi } from '../services/stats-wales-api'; const t = i18next.t; const storage = multer.memoryStorage(); const upload = multer({ storage }); -const APIInstance = new API(); - -const logger = pino({ - name: 'StatsWales-Alpha-App: Publish', - level: 'debug' -}); export const publish = Router(); @@ -65,6 +59,8 @@ publish.post('/name', upload.none(), (req: Request, res: Response) => { publish.post('/upload', upload.single('csv'), async (req: Request, res: Response) => { const lang = req.i18n.language; + const statsWalesApi = new StatsWalesApi(lang); + if (!req.body?.internal_name) { logger.debug('Internal name was missing on request'); const err: ViewErrDTO = { @@ -76,7 +72,7 @@ publish.post('/upload', upload.single('csv'), async (req: Request, res: Response field: 'internal_name', message: [ { - lang: req.i18n.language, + lang, message: t('errors.name_missing') } ], @@ -91,8 +87,10 @@ publish.post('/upload', upload.single('csv'), async (req: Request, res: Response res.render('publish/name', err); return; } + logger.debug(`Internal name: ${req.body.internal_name}`); const internalName: string = req.body.internal_name; + if (!req.file) { logger.debug('Attached file was missing on this request'); const err: ViewErrDTO = { @@ -104,7 +102,7 @@ publish.post('/upload', upload.single('csv'), async (req: Request, res: Response field: 'csv', message: [ { - lang: req.i18n.language, + lang, message: t('errors.upload.no-csv-data') } ], @@ -122,9 +120,10 @@ publish.post('/upload', upload.single('csv'), async (req: Request, res: Response const fileData = new Blob([req.file?.buffer]); - const processedCSV = await APIInstance.uploadCSV(lang, fileData, internalName); + const processedCSV = await statsWalesApi.uploadCSV(fileData, internalName); + if (processedCSV.success) { - res.redirect(`/${req.i18n.language}/dataset/${processedCSV.dataset?.id}`); + res.redirect(`/${lang}/dataset/${processedCSV.dataset?.id}`); } else { res.status(400); res.render('publish/upload', processedCSV); diff --git a/src/routes/view.ts b/src/routes/view.ts index e865924..a3e1635 100644 --- a/src/routes/view.ts +++ b/src/routes/view.ts @@ -1,24 +1,28 @@ -import { Router, Request, Response } from 'express'; +import { Router, Response } from 'express'; -import { API } from '../services/api'; +import { StatsWalesApi } from '../services/stats-wales-api'; import { FileList } from '../dtos/filelist'; import { ViewErrDTO } from '../dtos/view-dto'; import { i18next } from '../middleware/translation'; import { logger } from '../utils/logger'; +import { AuthedRequest } from '../interfaces/authed-request'; const t = i18next.t; -const APIInstance = new API(); export const view = Router(); -view.get('/', async (req: Request, res: Response) => { +const statsWalesApi = (req: AuthedRequest) => { const lang = req.i18n.language; - const fileList: FileList = await APIInstance.getFileList(lang); + const token = req.jwt; + return new StatsWalesApi(lang, token); +}; + +view.get('/', async (req: AuthedRequest, res: Response) => { + const fileList: FileList = await statsWalesApi(req).getFileList(); logger.debug(`FileList from server = ${JSON.stringify(fileList)}`); res.render('list', fileList); }); -view.get('/:file', async (req: Request, res: Response) => { - const lang = req.i18n.language; +view.get('/:file', async (req: AuthedRequest, res: Response) => { const page_number: number = Number.parseInt(req.query.page_number as string, 10) || 1; const page_size: number = Number.parseInt(req.query.page_size as string, 10) || 100; @@ -49,7 +53,7 @@ view.get('/:file', async (req: Request, res: Response) => { } const file_id = req.params.file; - const file = await APIInstance.getFileData(lang, file_id, page_number, page_size); + const file = await statsWalesApi(req).getFileData(file_id, page_number, page_size); if (!file.success) { res.status((file as ViewErrDTO).status); } diff --git a/src/services/api.ts b/src/services/stats-wales-api.ts similarity index 82% rename from src/services/api.ts rename to src/services/stats-wales-api.ts index 846a5fc..befa3d9 100644 --- a/src/services/api.ts +++ b/src/services/stats-wales-api.ts @@ -18,16 +18,22 @@ class HttpError extends Error { } } -export class API { - private readonly backendUrl: string | undefined; +export class StatsWalesApi { + private readonly backendUrl = process.env.BACKEND_URL || ''; + private readonly authHeader: Record; - constructor() { - this.backendUrl = process.env.BACKEND_URL; + constructor( + private lang: string, + private token?: string + ) { + this.lang = lang; + this.authHeader = token ? { Authorization: `Bearer ${token}` } : {}; } - public async getFileList(lang: string) { - logger.debug(`Fetching file list from ${this.backendUrl}/${lang}/dataset`); - const filelist: FileList = await fetch(`${this.backendUrl}/${lang}/dataset`) + public async getFileList() { + logger.debug(`Fetching file list from ${this.backendUrl}/${this.lang}/dataset`); + + const filelist: FileList = await fetch(`${this.backendUrl}/${this.lang}/dataset`, { headers: this.authHeader }) .then((response) => { if (response.ok) { return response.json(); @@ -46,9 +52,10 @@ export class API { return filelist; } - public async getFileData(lang: string, file_id: string, page_number: number, page_size: number) { + public async getFileData(file_id: string, page_number: number, page_size: number) { const file = await fetch( - `${this.backendUrl}/${lang}/dataset/${file_id}/view?page_number=${page_number}&page_size=${page_size}` + `${this.backendUrl}/${this.lang}/dataset/${file_id}/view?page_number=${page_number}&page_size=${page_size}`, + { headers: this.authHeader } ) .then((response) => { if (response.ok) { @@ -71,7 +78,7 @@ export class API { field: 'file', message: [ { - lang, + lang: this.lang, message: 'errors.dataset_missing' } ], @@ -87,14 +94,15 @@ export class API { return file; } - public async uploadCSV(lang: string, file: Blob, filename: string) { + public async uploadCSV(file: Blob, filename: string) { const formData = new FormData(); formData.append('csv', file, filename); formData.append('internal_name', filename); - const processedCSV = await fetch(`${this.backendUrl}/${lang}/dataset/`, { + const processedCSV = await fetch(`${this.backendUrl}/${this.lang}/dataset/`, { method: 'POST', - body: formData + body: formData, + headers: this.authHeader }) .then((response) => { if (response.ok) { @@ -117,7 +125,7 @@ export class API { field: 'csv', message: [ { - lang, + lang: this.lang, message: 'errors.upload.no-csv-data' } ], diff --git a/test/app.test.ts b/test/app.test.ts index 55b414d..1394e7d 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -15,18 +15,18 @@ jest.mock('../src/middleware/ensure-authenticated', () => ({ })); const server = setupServer( - http.get('http://somehost.com:3001/en-GB/dataset', () => { + http.get('http://example.com:3001/en-GB/dataset', () => { return HttpResponse.json({ filelist: [{ internal_name: 'test-data-1.csv', id: 'bdc40218-af89-424b-b86e-d21710bc92f1' }] }); }), - http.get('http://somehost.com:3001/en-GB/dataset/missing-id/view', () => { + http.get('http://example.com:3001/en-GB/dataset/missing-id/view', () => { return new HttpResponse(null, { status: 404, statusText: '{}' }); }), - http.get('http://somehost.com:3001/en-GB/dataset/fa07be9d-3495-432d-8c1f-d0fc6daae359/view', () => { + http.get('http://example.com:3001/en-GB/dataset/fa07be9d-3495-432d-8c1f-d0fc6daae359/view', () => { return HttpResponse.json({ success: true, dataset: { @@ -61,7 +61,7 @@ const server = setupServer( ] }); }), - http.post('http://somehost.com:3001/en-GB/dataset/', async (req) => { + http.post('http://example.com:3001/en-GB/dataset/', async (req) => { const data = await req.request.formData(); const internalName = data.get('internal_name') as string; if (internalName === 'test-data-3.csv fail test') { @@ -100,7 +100,7 @@ const server = setupServer( } }); }), - http.get('http://somehost.com:3001/healthcheck', () => { + http.get('http://example.com:3001/healthcheck', () => { return HttpResponse.json({ status: 'App is running', notes: 'Expand endpoint to check for database connection and other services.'