From a61e8d36dfa158cf64942a62a6a36b336e434b53 Mon Sep 17 00:00:00 2001 From: Ali Amori Kadhim Date: Fri, 10 Jan 2025 13:20:09 +0100 Subject: [PATCH 1/5] chore: add sanitizeHTML util --- apps/overige-objecten-api/src/utils/index.ts | 1 + apps/overige-objecten-api/src/utils/sanitizeHTML.ts | 9 +++++++++ 2 files changed, 10 insertions(+) create mode 100644 apps/overige-objecten-api/src/utils/sanitizeHTML.ts diff --git a/apps/overige-objecten-api/src/utils/index.ts b/apps/overige-objecten-api/src/utils/index.ts index 9c4a140c..390746ab 100644 --- a/apps/overige-objecten-api/src/utils/index.ts +++ b/apps/overige-objecten-api/src/utils/index.ts @@ -18,3 +18,4 @@ export { processData } from './processData'; export { convertSpotlightToHTML } from './convertSpotlightToHTML'; export { convertMultiColumnsButtonToHTML } from './convertMultiColumnsButtonToHTML'; export { createHTMLFiles } from './createHTMLFiles'; +export { sanitizeHTML } from './sanitizeHTML'; diff --git a/apps/overige-objecten-api/src/utils/sanitizeHTML.ts b/apps/overige-objecten-api/src/utils/sanitizeHTML.ts new file mode 100644 index 00000000..ba0f4f34 --- /dev/null +++ b/apps/overige-objecten-api/src/utils/sanitizeHTML.ts @@ -0,0 +1,9 @@ +import DOMPurify from 'dompurify'; +import { JSDOM } from 'jsdom'; +import { memoize } from 'lodash'; // For memoization of sanitize function +const createDOMPurify = memoize(() => { + const { window } = new JSDOM(); + return DOMPurify(window); +}); +const domPurify = createDOMPurify(); +export const sanitizeHTML = (html: string) => domPurify.sanitize(html, { FORBID_ATTR: ['style'] }); From ea1846f1d9134099ee41e71a80dfb07cab6f2dcd Mon Sep 17 00:00:00 2001 From: Ali Amori Kadhim Date: Fri, 10 Jan 2025 13:20:56 +0100 Subject: [PATCH 2/5] chore: create CreateVacResponse type --- apps/overige-objecten-api/src/strapi-product-type.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/overige-objecten-api/src/strapi-product-type.ts b/apps/overige-objecten-api/src/strapi-product-type.ts index d32cce5e..35b3fd3e 100644 --- a/apps/overige-objecten-api/src/strapi-product-type.ts +++ b/apps/overige-objecten-api/src/strapi-product-type.ts @@ -177,3 +177,11 @@ export interface Section { component?: string; internal_field: InternalField; } + +export type CreateVacResponse = { + data: { + createVac: { + data: DataVacItem; + }; + }; +}; From 1f32902fc687d363f448514b72df2ef1dc594bf9 Mon Sep 17 00:00:00 2001 From: Ali Amori Kadhim Date: Fri, 10 Jan 2025 13:40:43 +0100 Subject: [PATCH 3/5] chore: import sanitizeHTML --- apps/overige-objecten-api/src/components/Markdown.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/overige-objecten-api/src/components/Markdown.tsx b/apps/overige-objecten-api/src/components/Markdown.tsx index 8f6829b6..2c21b75c 100644 --- a/apps/overige-objecten-api/src/components/Markdown.tsx +++ b/apps/overige-objecten-api/src/components/Markdown.tsx @@ -1,23 +1,14 @@ import { Markdown as ReactMarkdown } from '@frameless/ui'; -import DOMPurify from 'dompurify'; -import { JSDOM } from 'jsdom'; -import memoize from 'lodash.memoize'; import React from 'react'; import type { Price } from '../strapi-product-type'; +import { sanitizeHTML } from '../utils'; export interface MarkdownProps { children: string; priceData?: Price[]; } -const createDOMPurify = memoize(() => { - const { window } = new JSDOM(); - return DOMPurify(window); -}); - export const Markdown = ({ children: html, priceData }: MarkdownProps) => { - const domPurify = createDOMPurify(); - const sanitizeHTML = memoize((html) => domPurify.sanitize(html)); const DOMPurifyHTML = sanitizeHTML(html); return html ? ( From ae0360a8d54125f081e299df2993df247892a515 Mon Sep 17 00:00:00 2001 From: Ali Amori Kadhim Date: Fri, 10 Jan 2025 13:41:27 +0100 Subject: [PATCH 4/5] chore: import CreateVacResponse --- .../src/controllers/objects/create.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/overige-objecten-api/src/controllers/objects/create.ts b/apps/overige-objecten-api/src/controllers/objects/create.ts index 25b6a732..cc4f48c5 100644 --- a/apps/overige-objecten-api/src/controllers/objects/create.ts +++ b/apps/overige-objecten-api/src/controllers/objects/create.ts @@ -3,7 +3,7 @@ import snakeCase from 'lodash.snakecase'; import slugify from 'slugify'; import { v4 } from 'uuid'; import { CREATE_INTERNAL_FIELD, CREATE_KENNISARTIKEL, CREATE_VAC } from '../../queries'; -import type { CreateInternalField, CreateProduct, DataVacItem } from '../../strapi-product-type'; +import type { CreateInternalField, CreateProduct, CreateVacResponse } from '../../strapi-product-type'; import type { components } from '../../types/openapi'; import { concatenateFieldValues, @@ -13,13 +13,6 @@ import { getTheServerURL, mapContentByCategory, } from '../../utils'; -type VACData = { - data: { - createVac: { - data: DataVacItem; - }; - }; -}; const categoryToKeyMap: { [key: string]: string } = { bewijs: 'bewijs', @@ -66,7 +59,7 @@ export const createVacController: RequestHandler = async (req, res, next) => { trefwoorden: vac?.trefwoorden, }, }; - const { data: responseData } = await fetchData({ + const { data: responseData } = await fetchData({ url: graphqlURL.href, query: CREATE_VAC, variables: { locale, data: vacPayload }, From b51909e42fd746ab31f341cafc2b9e520dedacdc Mon Sep 17 00:00:00 2001 From: Ali Amori Kadhim Date: Tue, 14 Jan 2025 16:13:31 +0100 Subject: [PATCH 5/5] feat(strapi-admin-extensions): enable to import VAC as CSV --- .changeset/rare-cows-hug.md | 5 + .eslintrc.js | 2 + Dockerfile.dev | 6 +- apps/strapi-admin-extensions/README.md | 53 ++++++ apps/strapi-admin-extensions/jest.config.ts | 21 +++ apps/strapi-admin-extensions/package.json | 51 ++++++ .../src/controllers/import/index.ts | 64 +++++++ .../src/controllers/index.ts | 1 + .../src/queries/index.ts | 35 ++++ .../src/routers/import/index.ts | 8 + .../src/routers/index.ts | 2 + apps/strapi-admin-extensions/src/server.ts | 82 +++++++++ .../src/strapi-product-types.ts | 33 ++++ .../src/tests/jest.setup.ts | 3 + .../src/utils/envAvailability.ts | 11 ++ .../src/utils/errorHandler.ts | 16 ++ .../src/utils/fetchData.ts | 161 ++++++++++++++++++ .../src/utils/index.ts | 5 + .../src/utils/processCsvFile.test.ts | 117 +++++++++++++ .../src/utils/processCsvFile.ts | 56 ++++++ .../src/utils/sanitizeHTML.test.ts | 19 +++ .../src/utils/sanitizeHTML.ts | 9 + apps/strapi-admin-extensions/tsconfig.json | 20 +++ .../tsconfig.test.json | 15 ++ docker-compose.pdc.dev.yml | 17 ++ docker-compose.pdc.prod.yml | 17 ++ package.json | 1 + yarn.lock | 46 +++++ 28 files changed, 874 insertions(+), 2 deletions(-) create mode 100644 .changeset/rare-cows-hug.md create mode 100644 apps/strapi-admin-extensions/README.md create mode 100644 apps/strapi-admin-extensions/jest.config.ts create mode 100644 apps/strapi-admin-extensions/package.json create mode 100644 apps/strapi-admin-extensions/src/controllers/import/index.ts create mode 100644 apps/strapi-admin-extensions/src/controllers/index.ts create mode 100644 apps/strapi-admin-extensions/src/queries/index.ts create mode 100644 apps/strapi-admin-extensions/src/routers/import/index.ts create mode 100644 apps/strapi-admin-extensions/src/routers/index.ts create mode 100644 apps/strapi-admin-extensions/src/server.ts create mode 100644 apps/strapi-admin-extensions/src/strapi-product-types.ts create mode 100644 apps/strapi-admin-extensions/src/tests/jest.setup.ts create mode 100644 apps/strapi-admin-extensions/src/utils/envAvailability.ts create mode 100644 apps/strapi-admin-extensions/src/utils/errorHandler.ts create mode 100644 apps/strapi-admin-extensions/src/utils/fetchData.ts create mode 100644 apps/strapi-admin-extensions/src/utils/index.ts create mode 100644 apps/strapi-admin-extensions/src/utils/processCsvFile.test.ts create mode 100644 apps/strapi-admin-extensions/src/utils/processCsvFile.ts create mode 100644 apps/strapi-admin-extensions/src/utils/sanitizeHTML.test.ts create mode 100644 apps/strapi-admin-extensions/src/utils/sanitizeHTML.ts create mode 100644 apps/strapi-admin-extensions/tsconfig.json create mode 100644 apps/strapi-admin-extensions/tsconfig.test.json diff --git a/.changeset/rare-cows-hug.md b/.changeset/rare-cows-hug.md new file mode 100644 index 00000000..451c6e10 --- /dev/null +++ b/.changeset/rare-cows-hug.md @@ -0,0 +1,5 @@ +--- +"@frameless/strapi-admin-extensions": major +--- + +Maak het mogelijk om VAC als een CSV-bestand te importeren via de API. diff --git a/.eslintrc.js b/.eslintrc.js index 6506eec9..c3c16702 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -53,6 +53,8 @@ module.exports = { project: [ './apps/overige-objecten-api/tsconfig.json', './apps/overige-objecten-api/tsconfig.test.json', + './apps/strapi-admin-extensions/tsconfig.json', + './apps/strapi-admin-extensions/tsconfig.test.json', './apps/kennisbank-dashboard/src/admin/tsconfig.json', './apps/kennisbank-dashboard/tsconfig.json', './apps/kennisbank-frontend/tsconfig.json', diff --git a/Dockerfile.dev b/Dockerfile.dev index c59d748f..953d68b7 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -16,6 +16,7 @@ COPY ./apps/vth-dashboard/package.json apps/vth-dashboard/package.json COPY ./apps/vth-frontend/package.json apps/vth-frontend/package.json COPY ./apps/kennisbank-dashboard/package.json apps/kennisbank-dashboard/package.json COPY ./apps/overige-objecten-api/package.json apps/overige-objecten-api/package.json +COPY ./apps/strapi-admin-extensions/package.json apps/strapi-admin-extensions/package.json COPY ./apps/kennisbank-frontend/package.json apps/kennisbank-frontend/package.json COPY ./packages/catalogi-data/package.json packages/catalogi-data/package.json COPY ./packages/preview-button/package.json packages/preview-button/package.json @@ -37,7 +38,7 @@ COPY ./packages/strapi-plugin-language/package.json packages/strapi-plugin-langu FROM build AS dependencies # Install prod dependencies COPY ./patches /opt/app/patches -RUN yarn install +RUN yarn install --frozen-lockfile # Build target builder # ######################## @@ -55,7 +56,8 @@ RUN npm run build --workspace @frameless/upl && \ npm run build --workspace @frameless/strapi-plugin-uuid-field && \ npm run build --workspace @frameless/strapi-plugin-env-label && \ npm run build --workspace @frameless/strapi-plugin-language && \ - npm run build --workspace @frameless/overige-objecten-api + npm run build --workspace @frameless/overige-objecten-api && \ + npm run build --workspace @frameless/strapi-admin-extensions # Build target production # ########################### diff --git a/apps/strapi-admin-extensions/README.md b/apps/strapi-admin-extensions/README.md new file mode 100644 index 00000000..588bc29f --- /dev/null +++ b/apps/strapi-admin-extensions/README.md @@ -0,0 +1,53 @@ +# Strapi Admin Extensions + +This project contains custom extensions for the Strapi admin panel. It depends on another app called Strapi dashboard. + +## Prerequisites + +- Ensure `pdc-dashboard` is installed and set up properly before using `strapi-admin-extensions`. + +## Installation + +1. **Clone the repository:** + + ```bash + git clone git@github.com:frameless/strapi.git + ``` + +2. **Install dependencies:** + Make sure you are in the project root: + + ```bash + yarn install + ``` + +## Usage + +1. Ensure the `pdc-dashboard` app is running: + + ```bash + yarn workspace @frameless/pdc-dashboard dev + ``` + +2. Copy the environment configuration file to the `strapi-admin-extensions` folder: + + ```bash + cp .env.example .env + ``` + +3. Run the development server for `strapi-admin-extensions`: + + ```bash + yarn workspace @frameless/strapi-admin-extensions dev + ``` + +## Contributing + +We welcome contributions! Feel free to: + +- Open an issue to report bugs or suggest new features. +- Submit a pull request with improvements or fixes. + +## License + +This project is licensed under the EUPL-1.2 License. diff --git a/apps/strapi-admin-extensions/jest.config.ts b/apps/strapi-admin-extensions/jest.config.ts new file mode 100644 index 00000000..79c9ed82 --- /dev/null +++ b/apps/strapi-admin-extensions/jest.config.ts @@ -0,0 +1,21 @@ +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + // to obtain access to the matchers. + moduleFileExtensions: ['ts', 'tsx', 'js', 'json', 'node'], + setupFilesAfterEnv: ['/src/tests/jest.setup.ts'], + modulePaths: [''], + testEnvironment: 'node', + roots: ['/src'], + transform: { + '^.+\\.(ts)$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.test.json', + }, + ], + }, +}; + +export default config; diff --git a/apps/strapi-admin-extensions/package.json b/apps/strapi-admin-extensions/package.json new file mode 100644 index 00000000..7359d586 --- /dev/null +++ b/apps/strapi-admin-extensions/package.json @@ -0,0 +1,51 @@ +{ + "name": "@frameless/strapi-admin-extensions", + "version": "0.0.0", + "private": true, + "author": "@frameless", + "description": "Strapi Admin Extensions", + "license": "EUPL-1.2", + "keywords": [], + "scripts": { + "prebuild": "yarn clean", + "build": "npm-run-all --parallel build:*", + "build:server": "tsc -p ./tsconfig.json", + "watch": "tsc -p ./tsconfig.json -w", + "start": "NODE_ENV=production node ./dist/src/server.js", + "dev": "NODE_ENV=development nodemon src/server.ts", + "clean": "rimraf dist src/types tmp", + "test": "STRAPI_ADMIN_EXTENSIONS_PORT=3000 jest --coverage --forceExit --verbose", + "test:watch": "STRAPI_ADMIN_EXTENSIONS_PORT=3000 jest --watch" + }, + "dependencies": { + "cors": "2.8.5", + "csv-parser": "3.0.0", + "dompurify": "3.2.1", + "dotenv": "16.4.5", + "express": "4.21.0", + "lodash.memoize": "4.1.2", + "p-limit": "3.0.0", + "morgan": "1.10.0" + }, + "devDependencies": { + "@types/cors": "2.8.17", + "@types/dompurify": "3.2.0", + "@types/jest": "29.5.12", + "@types/lodash.memoize": "4.1.9", + "@types/supertest": "6.0.2", + "jest": "29.7.0", + "jest-fetch-mock": "3.0.3", + "nodemon": "3.1.7", + "rimraf": "6.0.1", + "supertest": "7.0.0", + "ts-jest": "29.2.3", + "ts-node": "10.9.2", + "typescript": "5.0.4", + "@types/morgan": "1.9.9" + }, + "repository": { + "type": "git+ssh", + "url": "git@github.com:frameless/strapi.git", + "directory": "apps/strapi-admin-extensio0s" + } +} diff --git a/apps/strapi-admin-extensions/src/controllers/import/index.ts b/apps/strapi-admin-extensions/src/controllers/import/index.ts new file mode 100644 index 00000000..80e9480e --- /dev/null +++ b/apps/strapi-admin-extensions/src/controllers/import/index.ts @@ -0,0 +1,64 @@ +import type { NextFunction, Request, Response } from 'express'; +import fs from 'node:fs'; +import pLimit from 'p-limit'; +import { CREATE_VAC } from '../../queries'; +import { CreateVacResponse } from '../../strapi-product-types'; +import { fetchData, processCsvFile } from '../../utils'; + +const limit = pLimit(5); // Limit the number of concurrent file uploads +export const importController = async (req: Request, res: Response, next: NextFunction) => { + const type = req.body.type; + if (req.file) { + const filePath = req.file.path; + const requiredColumns = ['vraag', 'antwoord']; + + try { + // Process the CSV file and sanitize results + const authorizationHeader = req.headers?.authorization || ''; + const [authType, authToken] = authorizationHeader.split(/\s+/); + const tokenAuth = authType === 'Token' ? authToken : authorizationHeader; + const graphqlURL = new URL('/graphql', process.env.STRAPI_PRIVATE_URL); + const sanitizedResults = await processCsvFile(filePath, requiredColumns); + const locale = req.query?.locale || 'nl'; + + if (type === 'vac') { + // Loop through the sanitized results and create entries one by one + const results = await Promise.all( + sanitizedResults.map((entry) => + limit(async () => { + try { + const { data: responseData } = await fetchData({ + url: graphqlURL.href, + query: CREATE_VAC, + variables: { locale, data: entry }, + headers: { + Authorization: `Bearer ${tokenAuth}`, + }, + }); + return responseData; + } catch (error: any) { + next(error); + // eslint-disable-next-line no-console + console.error('Error processing entry:', error); + return { error: error.message, entry }; + } + }), + ), + ); + res.json({ message: 'CSV converted to JSON', data: results }); + // Delete temporary file after processing + await fs.promises.unlink(filePath); + } else { + res.status(400).send('Invalid import type.'); + } + } catch (error) { + await fs.promises.unlink(filePath); // Delete the temporary file in case of error + // Forward any errors to the error handler middleware + next(error); + return null; + } + } else { + res.status(400).send('No file uploaded.'); + } + return null; +}; diff --git a/apps/strapi-admin-extensions/src/controllers/index.ts b/apps/strapi-admin-extensions/src/controllers/index.ts new file mode 100644 index 00000000..4f530a22 --- /dev/null +++ b/apps/strapi-admin-extensions/src/controllers/index.ts @@ -0,0 +1 @@ +export { importController } from './import'; diff --git a/apps/strapi-admin-extensions/src/queries/index.ts b/apps/strapi-admin-extensions/src/queries/index.ts new file mode 100644 index 00000000..f919c706 --- /dev/null +++ b/apps/strapi-admin-extensions/src/queries/index.ts @@ -0,0 +1,35 @@ +const gql = (query: any) => query; + +export const CREATE_VAC = gql(` + mutation createVac($data: VacInput!) { + createVac(data: $data){ + data { + id + attributes { + createdAt + publishedAt + vac { + id + vraag + antwoord(pagination: { start: 0, limit: -1 }) { + content + kennisartikelCategorie + } + status + doelgroep + uuid + toelichting + afdelingen { + afdelingId + afdelingNaam + } + trefwoorden { + id + trefwoord + } + } + } + } + } +} +`); diff --git a/apps/strapi-admin-extensions/src/routers/import/index.ts b/apps/strapi-admin-extensions/src/routers/import/index.ts new file mode 100644 index 00000000..14559826 --- /dev/null +++ b/apps/strapi-admin-extensions/src/routers/import/index.ts @@ -0,0 +1,8 @@ +import express from 'express'; +import multer from 'multer'; +import { importController } from '../../controllers'; +const upload = multer({ dest: 'tmp/uploads/' }); +const router = express.Router({ mergeParams: true }); + +router.post('/import', upload.single('file'), importController); +export default router; diff --git a/apps/strapi-admin-extensions/src/routers/index.ts b/apps/strapi-admin-extensions/src/routers/index.ts new file mode 100644 index 00000000..c6e210f9 --- /dev/null +++ b/apps/strapi-admin-extensions/src/routers/index.ts @@ -0,0 +1,2 @@ +import importRoute from './import'; +export { importRoute }; diff --git a/apps/strapi-admin-extensions/src/server.ts b/apps/strapi-admin-extensions/src/server.ts new file mode 100644 index 00000000..e92be5e4 --- /dev/null +++ b/apps/strapi-admin-extensions/src/server.ts @@ -0,0 +1,82 @@ +import type { CorsOptions } from 'cors'; +import cors from 'cors'; +import { config } from 'dotenv'; +import express from 'express'; +import { NextFunction, Request, Response } from 'express'; +import morgan from 'morgan'; +import { importRoute } from './routers'; +import { envAvailability, ErrorHandler } from './utils'; +config(); + +// Validate environment variables +envAvailability({ + env: process.env, + keys: ['STRAPI_PRIVATE_URL', 'STRAPI_ADMIN_EXTENSIONS_PORT'], +}); + +const whitelist = process.env.STRAPI_ADMIN_EXTENSIONS_CORS?.split(', ') || []; +const corsOption: CorsOptions = { + origin: (origin, callback) => { + if (!origin || whitelist.indexOf(origin) !== -1) { + callback(null, true); + } else { + callback( + new ErrorHandler('Not allowed by CORS', { + statusCode: 403, + }), + ); + } + }, + optionsSuccessStatus: 200, +}; +const app = express(); +// Multer file upload middleware. +// The order is important, so this should be before the express.json() middleware to parse the file. +app.use('/api/v2', importRoute); +// parse application/json +app.use(express.json()); +// parse application/x-www-form-urlencoded +app.use(express.urlencoded({ extended: true })); +// log HTTP requests +app.use(morgan('dev')); + +const port = process.env.STRAPI_ADMIN_EXTENSIONS_PORT; +// Centralized error handler middleware +const globalErrorHandler = (err: ErrorHandler, _req: Request, res: Response, _next: NextFunction) => { + if (err instanceof ErrorHandler || (err as ErrorHandler)?.isOperational) { + // Send the proper error response with status code and message + return res.status(err?.options?.statusCode || 500).json({ + message: err.message, + }); + } + + // If it's an unknown error (not an operational error), log it and send a generic response + // eslint-disable-next-line no-console + console.error('Unexpected error:', err); + return res.status(500).json({ + message: 'An unexpected error occurred.', + }); +}; + +/** + * CORS + * Enable CORS with a whitelist of allowed origins + */ +app.use(cors(corsOption)); +// handle non existing routes +app.use((_req, res) => { + res.status(404).send('Route not found'); +}); +// Use global error handler middleware +app.use(globalErrorHandler); +/** + * Start the server + */ +if (process.env.NODE_ENV !== 'test') { + app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`Overige Objecten app listening on port ${port}!`); + }); +} + +export default app; diff --git a/apps/strapi-admin-extensions/src/strapi-product-types.ts b/apps/strapi-admin-extensions/src/strapi-product-types.ts new file mode 100644 index 00000000..1becbba9 --- /dev/null +++ b/apps/strapi-admin-extensions/src/strapi-product-types.ts @@ -0,0 +1,33 @@ +export interface Antwoord { + content: string; + kennisartikelCategorie?: string; +} +export interface VacItem { + uuid: string; + vraag: string | null; + antwoord: Antwoord[]; + status: string | null; + doelgroep: string | null; + afdelingen: string[]; + toelichting: string | null; + trefwoorden: string[]; +} + +export interface AttributesVacItem { + createdAt: string; + updatedAt: string; + vac: VacItem; +} + +export interface DataVacItem { + attributes: AttributesVacItem; + id: string; +} + +export type CreateVacResponse = { + data: { + createVac: { + data: DataVacItem; + }; + }; +}; diff --git a/apps/strapi-admin-extensions/src/tests/jest.setup.ts b/apps/strapi-admin-extensions/src/tests/jest.setup.ts new file mode 100644 index 00000000..9e92f7c1 --- /dev/null +++ b/apps/strapi-admin-extensions/src/tests/jest.setup.ts @@ -0,0 +1,3 @@ +import fetchMock from 'jest-fetch-mock'; + +fetchMock.enableMocks(); diff --git a/apps/strapi-admin-extensions/src/utils/envAvailability.ts b/apps/strapi-admin-extensions/src/utils/envAvailability.ts new file mode 100644 index 00000000..41e5f2c6 --- /dev/null +++ b/apps/strapi-admin-extensions/src/utils/envAvailability.ts @@ -0,0 +1,11 @@ +interface EnvValidator { + env: any; + keys: string[]; +} +export const envAvailability = ({ env, keys }: EnvValidator) => { + keys?.forEach((key: string) => { + if (!env[key]) { + throw new Error(`Missing required environment variable: ${key}`); + } + }); +}; diff --git a/apps/strapi-admin-extensions/src/utils/errorHandler.ts b/apps/strapi-admin-extensions/src/utils/errorHandler.ts new file mode 100644 index 00000000..800d096b --- /dev/null +++ b/apps/strapi-admin-extensions/src/utils/errorHandler.ts @@ -0,0 +1,16 @@ +export type Options = { + statusCode: number; +}; +export class ErrorHandler extends Error { + isOperational: boolean; // this flag for custom error identification + + constructor( + message?: string, + public options?: Options, + ) { + super(message); + this.name = 'ErrorHandler'; + this.options = options; + this.isOperational = true; // Operational errors should be marked + } +} diff --git a/apps/strapi-admin-extensions/src/utils/fetchData.ts b/apps/strapi-admin-extensions/src/utils/fetchData.ts new file mode 100644 index 00000000..3dc50aea --- /dev/null +++ b/apps/strapi-admin-extensions/src/utils/fetchData.ts @@ -0,0 +1,161 @@ +import { ErrorHandler } from './errorHandler'; + +interface HandleGraphqlRequestProps { + query?: string; + variables: any; + headers?: HeadersInit; +} + +const handleGraphqlRequest = ({ query, variables, headers }: HandleGraphqlRequestProps) => + ({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, // Merge custom headers (including the ability to overwrite 'Content-Type') + }, + body: JSON.stringify({ query, variables }), + cache: 'no-store', + }) as RequestInit; + +export interface FetchDataProps { + url: string; + query?: string; + variables?: any; + method?: string; + headers?: HeadersInit; // Allow custom headers to be passed +} + +/** + * @description Fetches data from the server (GraphQL or REST). + * @param {string} url - The URL to fetch data from. + * @param {string} query - The GraphQL query (if applicable). + * @param {any} variables - The variables to pass to the GraphQL queries. + * @param {string} method - The HTTP method, default is POST for GraphQL. + * @param {HeadersInit} headers - Custom headers to pass to the request. + * @returns {Promise} - The fetched data. + */ +export const fetchData = async ({ + url, + query, + variables, + method = 'POST', + headers = {}, // Default to an empty object if no headers are provided +}: FetchDataProps): Promise => { + // Default headers, which can be overwritten by custom headers (e.g., Content-Type) + const defaultHeaders: HeadersInit = { + 'Content-Type': 'application/json', + }; + + const requestOptions: RequestInit = query + ? handleGraphqlRequest({ query, variables, headers: { ...defaultHeaders, ...headers } }) + : { + method, + cache: 'no-store', + headers: { + ...defaultHeaders, + ...headers, // Merge custom headers with default ones (overwriting defaults if needed) + }, + }; + + try { + const response = await fetch(url, requestOptions); + + // Check for non-successful responses (status not in the 2xx range) + if (!response.ok) { + handleHttpError(response); + } + + const data = await response.json(); + + // Handle GraphQL-specific errors + if (data.errors && data.errors.length > 0) { + data.errors.forEach(handleGraphqlError); // Process each error + } + + return data; + } catch (error: any) { + // Handle and log client-side or unexpected errors + throw new ErrorHandler(error.message || 'Unknown error occurred', { + statusCode: error?.options?.statusCode || 500, + }); + } +}; + +/** + * Handle common HTTP errors and throw the appropriate ErrorHandler + * @param response - Fetch API Response object + */ +const handleHttpError = (response: Response) => { + const status = response.status; + + let errorMessage = response.statusText || 'Unknown error'; + + // Specific error messages based on status codes + switch (status) { + case 400: + errorMessage = 'Bad Request'; + break; + case 401: + errorMessage = 'Unauthorized'; + break; + case 403: + errorMessage = 'Forbidden'; + break; + case 404: + errorMessage = 'Resource Not Found'; + break; + case 422: + errorMessage = 'Unprocessable Entity'; + break; + case 500: + errorMessage = 'Internal Server Error'; + break; + case 503: + errorMessage = 'Service Unavailable'; + break; + case 504: + errorMessage = 'Gateway Timeout'; + break; + case 505: + errorMessage = 'HTTP Version Not Supported'; + break; + default: + errorMessage = `Unexpected error: ${status}`; + break; + } + throw new ErrorHandler(errorMessage, { statusCode: status }); +}; + +/** + * Handle GraphQL-specific errors like 'Forbidden access' + * @param error - The error object returned by GraphQL + */ +const handleGraphqlError = (error: any) => { + const errorMessage = error?.message || 'GraphQL error'; + const errorCode = error?.extensions?.code || 400; // Handle extensions (specific to GraphQL) + // Log the error for debugging purposes + // eslint-disable-next-line no-console + console.error('GraphQL Error:', JSON.stringify(error, null, 2)); + // Handle known GraphQL error messages + if (errorCode === 'BAD_USER_INPUT') { + throw new ErrorHandler('Bad User Input: The provided input is invalid.', { + statusCode: 400, + }); + } else if (errorCode === 'UNAUTHENTICATED') { + throw new ErrorHandler('Unauthenticated: Please log in to access this resource.', { + statusCode: 401, + }); + } else if (errorCode === 'FORBIDDEN') { + throw new ErrorHandler('Forbidden access: You do not have the required permissions.', { + statusCode: 403, + }); + } else if (errorCode === 'INTERNAL_SERVER_ERROR') { + throw new ErrorHandler('Internal Server Error: An unexpected error occurred on the server.', { + statusCode: 500, + }); + } + + throw new ErrorHandler(errorMessage, { + statusCode: errorCode, + }); +}; diff --git a/apps/strapi-admin-extensions/src/utils/index.ts b/apps/strapi-admin-extensions/src/utils/index.ts new file mode 100644 index 00000000..09d2e517 --- /dev/null +++ b/apps/strapi-admin-extensions/src/utils/index.ts @@ -0,0 +1,5 @@ +export { ErrorHandler } from './errorHandler'; +export { fetchData } from './fetchData'; +export { processCsvFile } from './processCsvFile'; +export { envAvailability } from './envAvailability'; +export { sanitizeHTML } from './sanitizeHTML'; diff --git a/apps/strapi-admin-extensions/src/utils/processCsvFile.test.ts b/apps/strapi-admin-extensions/src/utils/processCsvFile.test.ts new file mode 100644 index 00000000..f78eb536 --- /dev/null +++ b/apps/strapi-admin-extensions/src/utils/processCsvFile.test.ts @@ -0,0 +1,117 @@ +import fs from 'node:fs'; +import { processCsvFile, Vac } from './processCsvFile'; // Adjust path as needed + +jest.mock('node:fs'); +jest.mock('csv-parser', () => + jest.fn(() => ({ + on: jest.fn().mockReturnThis(), + pipe: jest.fn().mockReturnThis(), + })), +); +jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') })); + +describe('processCsvFile', () => { + const filePath = 'test.csv'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should resolve with sanitized results when CSV has required columns', async () => { + const mockData = [ + { vraag: 'Question 1', antwoord: '

Answer 1

' }, + { vraag: 'Question 2', antwoord: '

Answer 2

' }, + ]; + + const mockStream = { + on: jest.fn((event, handler) => { + if (event === 'headers') handler(['vraag', 'antwoord']); + if (event === 'data') mockData.forEach(handler); + if (event === 'end') handler(); + return mockStream; + }), + pipe: jest.fn().mockReturnThis(), + } as any; + + (fs.createReadStream as jest.Mock).mockReturnValue(mockStream); + + const result = await processCsvFile(filePath, ['vraag', 'antwoord']); + const expectedResults: Vac[] = [ + { + vac: { + vraag: 'Question 1', + antwoord: { + content: '

Answer 1

', + }, + doelgroep: null, + uuid: 'mock-uuid', + }, + }, + { + vac: { + vraag: 'Question 2', + antwoord: { + content: '

Answer 2

', + }, + doelgroep: null, + uuid: 'mock-uuid', + }, + }, + ]; + expect(result).toEqual(expectedResults); + expect(fs.createReadStream).toHaveBeenCalledWith(filePath); + }); + + it('should reject when required columns are missing', async () => { + const mockStream = { + on: jest.fn((event, handler) => { + if (event === 'headers') handler(['vraag']); // Missing 'antwoord' + if (event === 'end') handler(); + return mockStream; + }), + pipe: jest.fn().mockReturnThis(), + } as any; + + (fs.createReadStream as jest.Mock).mockReturnValue(mockStream); + + await expect(processCsvFile(filePath, ['vraag', 'antwoord'])).rejects.toEqual({ + error: 'Missing required columns', + missingColumns: ['antwoord'], + }); + }); + + it('should reject on CSV parsing error', async () => { + const mockStream = { + on: jest.fn((event, handler) => { + if (event === 'error') handler(new Error('Parsing error')); + return mockStream; + }), + pipe: jest.fn().mockReturnThis(), + } as any; + + (fs.createReadStream as jest.Mock).mockReturnValue(mockStream); + + await expect(processCsvFile(filePath, ['vraag', 'antwoord'])).rejects.toEqual({ + error: 'Failed to parse CSV', + details: 'Parsing error', + }); + }); + + it('should reject with validation failure if no required columns', async () => { + const mockStream = { + on: jest.fn((event, handler) => { + if (event === 'headers') handler([]); + if (event === 'end') handler(); + return mockStream; + }), + pipe: jest.fn().mockReturnThis(), + } as any; + + (fs.createReadStream as jest.Mock).mockReturnValue(mockStream); + + await expect(processCsvFile(filePath, ['vraag', 'antwoord'])).rejects.toEqual({ + error: 'Missing required columns', + missingColumns: ['vraag', 'antwoord'], + }); + }); +}); diff --git a/apps/strapi-admin-extensions/src/utils/processCsvFile.ts b/apps/strapi-admin-extensions/src/utils/processCsvFile.ts new file mode 100644 index 00000000..bb595981 --- /dev/null +++ b/apps/strapi-admin-extensions/src/utils/processCsvFile.ts @@ -0,0 +1,56 @@ +import csvParser from 'csv-parser'; +import fs from 'node:fs'; +import { v4 } from 'uuid'; +import { sanitizeHTML } from './sanitizeHTML'; + +export type Vac = { vac: { vraag: string; antwoord: { content: string }; doelgroep: string | null; uuid: string } }; + +export const processCsvFile = (filePath: string, requiredColumns: string[]) => { + const results: { vraag: string; antwoord: string; doelgroep?: string | null }[] = []; + let hasRequiredColumns = true; + + // Create read stream for CSV file + return new Promise((resolve, reject) => { + fs.createReadStream(filePath) + .pipe(csvParser()) + .on('headers', (headers) => { + // Check if required columns exist in the CSV file headers + const missingColumns = requiredColumns.filter((col) => !headers.includes(col)); + + if (missingColumns.length > 0) { + // If any required columns are missing, reject with error + hasRequiredColumns = false; + reject({ error: 'Missing required columns', missingColumns }); + } + }) + .on('data', (data) => { + // If the columns are valid, push the data into results + if (hasRequiredColumns) { + results.push({ vraag: data.vraag, antwoord: data.antwoord }); + } + }) + .on('end', () => { + if (hasRequiredColumns) { + const sanitizedResults = results.map((result) => { + const domPurifyHTML = sanitizeHTML(result?.antwoord); + return { + vac: { + vraag: result?.vraag, + antwoord: { + content: domPurifyHTML, + }, + doelgroep: result?.doelgroep || null, + uuid: v4(), + }, + }; + }); + resolve(sanitizedResults); + } else { + reject({ error: 'CSV failed validation' }); + } + }) + .on('error', (err) => { + reject({ error: 'Failed to parse CSV', details: err.message }); + }); + }); +}; diff --git a/apps/strapi-admin-extensions/src/utils/sanitizeHTML.test.ts b/apps/strapi-admin-extensions/src/utils/sanitizeHTML.test.ts new file mode 100644 index 00000000..ac4f514d --- /dev/null +++ b/apps/strapi-admin-extensions/src/utils/sanitizeHTML.test.ts @@ -0,0 +1,19 @@ +import { sanitizeHTML } from './sanitizeHTML'; + +describe('sanitizeHTML', () => { + it('should sanitize HTML', () => { + const html = ''; + const sanitizedHTML = sanitizeHTML(html); + expect(sanitizedHTML).toBe(''); + }); + it('should sanitize HTML with style attribute', () => { + const html = '
test
'; + const sanitizedHTML = sanitizeHTML(html); + expect(sanitizedHTML).toBe('
test
'); + }); + it('should sanitize HTML with style attribute and script tag', () => { + const html = '
test
'; + const sanitizedHTML = sanitizeHTML(html); + expect(sanitizedHTML).toBe('
test
'); + }); +}); diff --git a/apps/strapi-admin-extensions/src/utils/sanitizeHTML.ts b/apps/strapi-admin-extensions/src/utils/sanitizeHTML.ts new file mode 100644 index 00000000..ba0f4f34 --- /dev/null +++ b/apps/strapi-admin-extensions/src/utils/sanitizeHTML.ts @@ -0,0 +1,9 @@ +import DOMPurify from 'dompurify'; +import { JSDOM } from 'jsdom'; +import { memoize } from 'lodash'; // For memoization of sanitize function +const createDOMPurify = memoize(() => { + const { window } = new JSDOM(); + return DOMPurify(window); +}); +const domPurify = createDOMPurify(); +export const sanitizeHTML = (html: string) => domPurify.sanitize(html, { FORBID_ATTR: ['style'] }); diff --git a/apps/strapi-admin-extensions/tsconfig.json b/apps/strapi-admin-extensions/tsconfig.json new file mode 100644 index 00000000..86357fba --- /dev/null +++ b/apps/strapi-admin-extensions/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react", + "moduleResolution": "node", + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "declaration": true, + "module": "commonjs", + "outDir": "dist", + "lib": ["ES2021", "DOM"], + "strict": true + }, + "include": ["src/**/*", "./jest.config.ts"], + "exclude": ["node_modules", "**/*.test.ts", "./src/types/openapi.ts"] +} diff --git a/apps/strapi-admin-extensions/tsconfig.test.json b/apps/strapi-admin-extensions/tsconfig.test.json new file mode 100644 index 00000000..9e13f3d1 --- /dev/null +++ b/apps/strapi-admin-extensions/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "jsx": "react", + "target": "ES2016", + "module": "ES6", + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "strict": true, + "types": ["jest"], + "resolveJsonModule": true + }, + "include": ["**/*.test.tsx", "**/*.test.ts", "tests"] +} diff --git a/docker-compose.pdc.dev.yml b/docker-compose.pdc.dev.yml index a69f8ab7..7ec4d238 100644 --- a/docker-compose.pdc.dev.yml +++ b/docker-compose.pdc.dev.yml @@ -124,6 +124,23 @@ services: depends_on: - pdc_strapi - pdc_strapi_db + strapi-admin-extensions: + container_name: strapi-admin-extensions + build: + context: . + dockerfile: Dockerfile.dev + restart: unless-stopped + command: yarn workspace @frameless/strapi-admin-extensions start + environment: + STRAPI_PRIVATE_URL: ${STRAPI_PRIVATE_URL} + STRAPI_ADMIN_EXTENSIONS_PORT: ${STRAPI_ADMIN_EXTENSIONS_PORT} + ports: + - "4002:4002" + networks: + - pdc_strapi_network + depends_on: + - pdc_strapi + - pdc_strapi_db pdc_strapi_db: container_name: pdc_strapi_db platform: linux/amd64 #for platform error on Apple M1 chips diff --git a/docker-compose.pdc.prod.yml b/docker-compose.pdc.prod.yml index 2330512c..6bcaaabe 100644 --- a/docker-compose.pdc.prod.yml +++ b/docker-compose.pdc.prod.yml @@ -111,6 +111,23 @@ services: depends_on: - pdc_strapi - pdc_strapi_db + strapi-admin-extensions: + container_name: strapi-admin-extensions + build: + context: . + dockerfile: Dockerfile.prod + restart: unless-stopped + command: yarn strapi-admin-extensions + environment: + STRAPI_PRIVATE_URL: ${STRAPI_PRIVATE_URL} + FRONTEND_PUBLIC_URL: ${FRONTEND_PUBLIC_URL} + ports: + - "4002:4002" + networks: + - pdc_strapi_network + depends_on: + - pdc_strapi + - pdc_strapi_db pdc_strapi_db: container_name: pdc_strapi_db platform: linux/amd64 #for platform error on Apple M1 chips diff --git a/package.json b/package.json index ca98bb79..077462e2 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "start:pdc-dashboard": "npm run --workspace @frameless/pdc-dashboard start", "start:kennisbank-frontend": "npm run --workspace @frameless/kennisbank-frontend start", "start:kennisbank-dashboard": "npm run --workspace @frameless/kennisbank-dashboard start", + "strapi-admin-extensions": "npm run --workspace @frameless/strapi-admin-extensions start", "start:pdc-sc": "npm run --workspace @frameless/pdc-sc start", "start:overige-objecten-api": "yarn workspace @frameless/overige-objecten-api start", "build": "npm run --workspaces build --if-present", diff --git a/yarn.lock b/yarn.lock index fe48e7df..4194b34d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7607,6 +7607,13 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e" integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag== +"@types/morgan@1.9.9": + version "1.9.9" + resolved "https://registry.yarnpkg.com/@types/morgan/-/morgan-1.9.9.tgz#d60dec3979e16c203a000159daa07d3fb7270d7f" + integrity sha512-iRYSDKVaC6FkGSpEVVIvrRGw0DfJMiQzIn3qr2G5B3C//AWkulhXgaBd7tS9/J79GWSYMTHGs7PfI5b3Y8m+RQ== + dependencies: + "@types/node" "*" + "@types/ms@*": version "0.7.34" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" @@ -9637,6 +9644,13 @@ base@^0.11.1: mixin-deep "^1.2.0" pascalcase "^0.1.1" +basic-auth@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.1.tgz#b998279bf47ce38344b4f3cf916d4679bbf51e3a" + integrity sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg== + dependencies: + safe-buffer "5.1.2" + batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" @@ -11759,6 +11773,13 @@ csstype@^3.0.2, csstype@^3.1.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== +csv-parser@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/csv-parser/-/csv-parser-3.0.0.tgz#b88a6256d79e090a97a1b56451f9327b01d710e7" + integrity sha512-s6OYSXAK3IdKqYO33y09jhypG/bSDHPuyCme/IdEHfWpLf/jKcpitVFyOC6UemgGk8v7Q5u2XE0vvwmanxhGlQ== + dependencies: + minimist "^1.2.0" + csvtojson@2.0.10: version "2.0.10" resolved "https://registry.yarnpkg.com/csvtojson/-/csvtojson-2.0.10.tgz#11e7242cc630da54efce7958a45f443210357574" @@ -20137,6 +20158,17 @@ monaco-editor@0.33.0: resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.33.0.tgz#842e244f3750a2482f8a29c676b5684e75ff34af" integrity sha512-VcRWPSLIUEgQJQIE0pVT8FcGBIgFoxz7jtqctE+IiCxWugD0DwgyQBcZBhdSrdMC84eumoqMZsGl2GTreOzwqw== +morgan@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7" + integrity sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ== + dependencies: + basic-auth "~2.0.1" + debug "2.6.9" + depd "~2.0.0" + on-finished "~2.3.0" + on-headers "~1.0.2" + mri@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" @@ -21192,6 +21224,13 @@ on-finished@2.4.1, on-finished@^2.3.0: dependencies: ee-first "1.1.1" +on-finished@~2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" + integrity sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww== + dependencies: + ee-first "1.1.1" + on-headers@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" @@ -21395,6 +21434,13 @@ p-is-promise@^3.0.0: resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-3.0.0.tgz#58e78c7dfe2e163cf2a04ff869e7c1dba64a5971" integrity sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ== +p-limit@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.0.0.tgz#8a9da09ee359017af6a3aa6b8ede13f5894224ec" + integrity sha512-2FnzNu8nBx8Se231yrvScYw34Is5J5MtvKOQt7Lii+DGpM89xnCT7kIH/HJwniNkQpjB7zy/O3LckEfMVqYvFg== + dependencies: + p-try "^2.0.0" + p-limit@3.1.0, p-limit@^3.0.2, p-limit@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"