diff --git a/.eslintignore b/.eslintignore index c49398bd2..3b250ebc5 100644 --- a/.eslintignore +++ b/.eslintignore @@ -15,6 +15,7 @@ Dockerfile *.png *.jpg *.sh +*.ico test/unit/partials/ types/custom.d.ts docker-examples/ diff --git a/.npmignore b/.npmignore index 09d72ba13..07c682c1f 100644 --- a/.npmignore +++ b/.npmignore @@ -13,8 +13,8 @@ src/ .vscode/ .circleci/ debug/ - - +docker-examples/ +reports/ ## assets and website assets/ diff --git a/.prettierignore b/.prettierignore index 19170b55c..8c251e491 100644 --- a/.prettierignore +++ b/.prettierignore @@ -27,3 +27,6 @@ test/functional/store/* storage_default_storage/* docker-examples/ .prettierignore +.npmignore +.gitignore +*.ico diff --git a/conf/default.yaml b/conf/default.yaml index 5c60919ed..52a6209ad 100644 --- a/conf/default.yaml +++ b/conf/default.yaml @@ -66,6 +66,7 @@ packages: # WORKAROUND: Through given configuration you can workaround following issue https://github.com/verdaccio/verdaccio/issues/301. Set to 0 in case 60 is not enough. server: keepAliveTimeout: 60 + # behindProxy: false middlewares: audit: diff --git a/conf/docker.yaml b/conf/docker.yaml index 95b4569a5..b1d9cf598 100644 --- a/conf/docker.yaml +++ b/conf/docker.yaml @@ -66,6 +66,14 @@ packages: # if package is not available locally, proxy requests to 'npmjs' registry proxy: npmjs +# You can specify HTTP/1.1 server keep alive timeout in seconds for incoming connections. +# A value of 0 makes the http server behave similarly to Node.js versions prior to 8.0.0, which did not have a keep-alive timeout. +# WORKAROUND: Through given configuration you can workaround following issue https://github.com/verdaccio/verdaccio/issues/301. Set to 0 in case 60 is not enough. +server: + keepAliveTimeout: 60 + # enable this if you run behind a proxy + # behindProxy: false + middlewares: audit: enabled: true diff --git a/docker-examples/v4/reverse_proxy/nginx/relative_path/conf/v3/config.yaml b/docker-examples/v4/reverse_proxy/nginx/relative_path/conf/v3/config.yaml deleted file mode 100644 index 3d0ed1b67..000000000 --- a/docker-examples/v4/reverse_proxy/nginx/relative_path/conf/v3/config.yaml +++ /dev/null @@ -1,49 +0,0 @@ -storage: /verdaccio/storage - -web: - enable: true - title: VerdaccioV3 Relative Path - -auth: - htpasswd: - file: /verdaccio/conf/htpasswd -security: - api: - jwt: - sign: - expiresIn: 60d - notBefore: 1 - web: - sign: - expiresIn: 7d - -## IMPORTANT -## -url_prefix: /verdacciov3/ - -uplinks: - npmjs: - url: https://registry.npmjs.org/ - -packages: - '@jota/*': - access: $all - publish: $all - - '@*/*': - # scoped packages - access: $all - publish: $all - proxy: npmjs - - '**': - access: $all - publish: $all - proxy: npmjs - -middlewares: - audit: - enabled: true - -logs: - - { type: stdout, format: pretty, level: trace } diff --git a/docker-examples/v4/reverse_proxy/nginx/relative_path/conf/v3/htpasswd b/docker-examples/v4/reverse_proxy/nginx/relative_path/conf/v3/htpasswd deleted file mode 100644 index be190b2ea..000000000 --- a/docker-examples/v4/reverse_proxy/nginx/relative_path/conf/v3/htpasswd +++ /dev/null @@ -1 +0,0 @@ -test:$6FrCaT/v0dwE:autocreated 2019-05-01T09:29:55.707Z diff --git a/docker-examples/v4/reverse_proxy/nginx/relative_path/conf/v4/config.yaml b/docker-examples/v4/reverse_proxy/nginx/relative_path/conf/v4/config.yaml index 6b02fd54b..25499efc0 100644 --- a/docker-examples/v4/reverse_proxy/nginx/relative_path/conf/v4/config.yaml +++ b/docker-examples/v4/reverse_proxy/nginx/relative_path/conf/v4/config.yaml @@ -19,8 +19,10 @@ security: expiresIn: 7d ## IMPORTANT -## -url_prefix: /verdaccio +## This setup is required for relative path +url_prefix: /verdaccio/ +server: + behindProxy: true uplinks: npmjs: diff --git a/docker-examples/v4/reverse_proxy/nginx/relative_path/conf/v4_root/config.yaml b/docker-examples/v4/reverse_proxy/nginx/relative_path/conf/v4_root/config.yaml deleted file mode 100644 index a990a9cc8..000000000 --- a/docker-examples/v4/reverse_proxy/nginx/relative_path/conf/v4_root/config.yaml +++ /dev/null @@ -1,46 +0,0 @@ -storage: /verdaccio/storage - -web: - enable: true - title: VerdaccioV4 Relative Path - primary_color: red - -auth: - htpasswd: - file: /verdaccio/conf/htpasswd -security: - api: - jwt: - sign: - expiresIn: 60d - notBefore: 1 - web: - sign: - expiresIn: 7d - -uplinks: - npmjs: - url: https://registry.npmjs.org/ - -packages: - '@jota/*': - access: $all - publish: $all - - '@*/*': - # scoped packages - access: $all - publish: $all - proxy: npmjs - - '**': - access: $all - publish: $all - proxy: npmjs - -middlewares: - audit: - enabled: true - -logs: - - { type: stdout, format: pretty, level: trace } diff --git a/docker-examples/v4/reverse_proxy/nginx/relative_path/conf/v4_root/htpasswd b/docker-examples/v4/reverse_proxy/nginx/relative_path/conf/v4_root/htpasswd deleted file mode 100644 index 6464e408d..000000000 --- a/docker-examples/v4/reverse_proxy/nginx/relative_path/conf/v4_root/htpasswd +++ /dev/null @@ -1 +0,0 @@ -jpicado:$6vkdNgRX2npc:autocreated 2017-07-11T18:48:38.003Z diff --git a/docker-examples/v4/reverse_proxy/nginx/relative_path/docker-compose.yml b/docker-examples/v4/reverse_proxy/nginx/relative_path/docker-compose.yml index b90ebd1fe..84083249f 100644 --- a/docker-examples/v4/reverse_proxy/nginx/relative_path/docker-compose.yml +++ b/docker-examples/v4/reverse_proxy/nginx/relative_path/docker-compose.yml @@ -12,45 +12,19 @@ services: container_name: 'nginx' depends_on: - verdaccio - - verdaccio3 - - verdaccio-root verdaccio: - image: verdaccio/verdaccio:4 + image: verdaccio/verdaccio:local container_name: 'verdaccio_relative_path_v4' networks: - node-network environment: - VERDACCIO_PORT=4873 + - DEBUG=verdaccio* ports: - '4873:4873' volumes: - './storage:/verdaccio/storage' - './conf/v4:/verdaccio/conf' - verdaccio-root: - image: verdaccio/verdaccio:4 - container_name: 'verdaccio_relative_path_v4_root' - networks: - - node-network - environment: - - VERDACCIO_PORT=8000 - ports: - - '8000:8000' - volumes: - - './storage:/verdaccio/storage' - - './conf/v4_root:/verdaccio/conf' - verdaccio3: - image: verdaccio/verdaccio:3 - container_name: 'verdaccio_relative_path_latest_v3' - networks: - - node-network - ports: - - '7771:7771' - environment: - - PORT=7771 - volumes: - - './storage:/verdaccio/storage' - - './conf/v3:/verdaccio/conf' - networks: node-network: driver: bridge diff --git a/docker-examples/v4/reverse_proxy/nginx/relative_path/docker-compose_ssl.yml b/docker-examples/v4/reverse_proxy/nginx/relative_path/docker-compose_ssl.yml index 19981fcfe..b92344871 100644 --- a/docker-examples/v4/reverse_proxy/nginx/relative_path/docker-compose_ssl.yml +++ b/docker-examples/v4/reverse_proxy/nginx/relative_path/docker-compose_ssl.yml @@ -17,7 +17,7 @@ services: - verdaccio - verdaccio-root verdaccio: - image: verdaccio/verdaccio:4 + image: verdaccio/verdaccio:local container_name: 'verdaccio_relative_path_v4' networks: - node-network @@ -29,7 +29,7 @@ services: - './storage:/verdaccio/storage' - './conf/v4:/verdaccio/conf' verdaccio-root: - image: verdaccio/verdaccio:4 + image: verdaccio/verdaccio:local container_name: 'verdaccio_relative_path_v4_root' networks: - node-network diff --git a/docker-examples/v4/reverse_proxy/nginx/relative_path/nginx/default.conf b/docker-examples/v4/reverse_proxy/nginx/relative_path/nginx/default.conf index dca1a3ef3..e40578b04 100644 --- a/docker-examples/v4/reverse_proxy/nginx/relative_path/nginx/default.conf +++ b/docker-examples/v4/reverse_proxy/nginx/relative_path/nginx/default.conf @@ -3,31 +3,11 @@ upstream verdaccio_v4 { keepalive 8; } -upstream verdaccio_v4_root { - server verdaccio_relative_path_v4_root:8000; - keepalive 8; -} - -upstream verdaccio_v3 { - server verdaccio_relative_path_latest_v3:7771; - keepalive 8; -} - - server { listen 80 default_server; access_log /var/log/nginx/verdaccio.log; charset utf-8; - location / { - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_set_header X-NginX-Proxy true; - proxy_pass http://verdaccio_v4_root; - proxy_redirect off; - } - location ~ ^/verdaccio/(.*)$ { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -36,14 +16,4 @@ server { proxy_pass http://verdaccio_v4/$1; proxy_redirect off; } - - location ~ ^/verdacciov3/(.*)$ { - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_set_header X-NginX-Proxy true; - - proxy_pass http://verdaccio_v3/$1; - proxy_redirect off; - } } diff --git a/docker-examples/v4/reverse_proxy/nginx/relative_path/storage/@verdaccio/streams/package.json b/docker-examples/v4/reverse_proxy/nginx/relative_path/storage/@verdaccio/streams/package.json old mode 100644 new mode 100755 diff --git a/docker-examples/v4/reverse_proxy/nginx/relative_path/storage/jquery/package.json b/docker-examples/v4/reverse_proxy/nginx/relative_path/storage/jquery/package.json old mode 100644 new mode 100755 diff --git a/docker-examples/v4/reverse_proxy/nginx/relative_path/storage/verdaccio/package.json b/docker-examples/v4/reverse_proxy/nginx/relative_path/storage/verdaccio/package.json old mode 100644 new mode 100755 diff --git a/docs/env.variables.md b/docs/env.variables.md index d0cac248d..dc4bf5209 100644 --- a/docs/env.variables.md +++ b/docs/env.variables.md @@ -8,3 +8,28 @@ internal features. Enables gracefully shutdown, more info [here](https://github.com/verdaccio/verdaccio/pull/2121). This will be enable by default on Verdaccio 5. + +#### VERDACCIO_PUBLIC_URL + +Define a specific public url for your server, it overrules the `Host` and `X-Forwarded-Proto` header if a reverse proxy is being used, it takes in account the `url_prefix` if is defined. + +This is handy in such situations where a dynamic url is required. + +eg: + +``` +VERDACCIO_PUBLIC_URL='https://somedomain.org'; +url_prefix: '/my_prefix' + +// url -> https://somedomain.org/my_prefix/ + +VERDACCIO_PUBLIC_URL='https://somedomain.org'; +url_prefix: '/' + +// url -> https://somedomain.org/ + +VERDACCIO_PUBLIC_URL='https://somedomain.org/first_prefix'; +url_prefix: '/second_prefix' + +// url -> https://somedomain.org/second_prefix/' +``` diff --git a/package.json b/package.json index be08882e7..0aab25c92 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@verdaccio/local-storage": "9.7.5", "@verdaccio/readme": "9.7.5", "@verdaccio/streams": "9.7.2", - "@verdaccio/ui-theme": "1.15.1", + "@verdaccio/ui-theme": "3.0.0", "JSONStream": "1.3.5", "async": "3.2.0", "body-parser": "1.19.0", @@ -32,6 +32,7 @@ "cookies": "0.8.0", "cors": "2.8.5", "dayjs": "1.10.4", + "debug": "^4.3.1", "envinfo": "7.7.4", "express": "4.17.1", "handlebars": "4.7.7", @@ -49,6 +50,7 @@ "pkginfo": "0.4.1", "request": "2.88.0", "semver": "7.3.4", + "validator": "13.5.2", "verdaccio-audit": "9.7.3", "verdaccio-htpasswd": "9.7.2" }, @@ -121,7 +123,9 @@ "jest-junit": "9.0.0", "lint-staged": "8.2.1", "lockfile-lint": "4.3.7", + "lru-cache": "6.0.0", "nock": "12.0.3", + "node-mocks-http": "^1.10.1", "prettier": "2.2.1", "puppeteer": "5.5.0", "rimraf": "3.0.2", @@ -164,10 +168,10 @@ "lint": "yarn run type-check && yarn run lint:ts", "lint:ts": "eslint \"**/*.{js,jsx,ts,tsx}\"", "lint:lockfile": "lockfile-lint --path yarn.lock --type yarn --validate-https --allowed-hosts verdaccio npm yarn", - "dev:start": "yarn babel-node --extensions \".ts,.tsx\" src/lib/cli", + "start": "yarn babel-node --extensions \".ts,.tsx\" src/lib/cli", "code:build": "yarn babel src/ --out-dir build/ --copy-files --extensions \".ts,.tsx\" --source-maps inline", "code:docker-build": "yarn babel src/ --out-dir build/ --copy-files --extensions \".ts,.tsx\"", - "docker": "docker build -t verdaccio/verdaccio:local . --no-cache", + "docker": "docker build -t verdaccio/verdaccio:pr-2122 . --no-cache", "docker:run": "docker run -it --rm -p 4873:4873 verdaccio/verdaccio:local" }, "engines": { @@ -186,8 +190,6 @@ "linters": { "*": [ "eslint .", - "prettier --write", - "detect-secrets-launcher --baseline .secrets-baseline", "git add" ] }, diff --git a/src/api/index.ts b/src/api/index.ts index 49bfc62bc..7ef4b3f7b 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -10,23 +10,21 @@ import Auth from '../lib/auth'; import { ErrorCode } from '../lib/utils'; import { API_ERROR, HTTP_STATUS } from '../lib/constants'; import AppConfig from '../lib/config'; -import { - $ResponseExtend, - $RequestExtend, - $NextFunctionVer, - IStorageHandler, - IAuth -} from '../../types'; +import { $ResponseExtend, $RequestExtend, $NextFunctionVer, IStorageHandler, IAuth } from '../../types'; import { setup, logger } from '../lib/logger'; import webAPI from './web/api'; import web from './web'; import apiEndpoint from './endpoint'; import hookDebug from './debug'; -import { log, final, errorReportingMiddleware } from './middleware'; +import { log, final, errorReportingMiddleware, serveFavicon } from './middleware'; const defineAPI = function (config: IConfig, storage: IStorageHandler): any { const auth: IAuth = new Auth(config); const app: Application = express(); + if (config?.server?.behindProxy === true) { + // app.use('trust proxy'); + } + // run in production mode by default, just in case // it shouldn't make any difference anyway app.set('env', process.env.NODE_ENV || 'production'); @@ -42,13 +40,7 @@ const defineAPI = function (config: IConfig, storage: IStorageHandler): any { app.use(compression()); - app.get( - '/favicon.ico', - function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { - req.url = '/-/static/favicon.png'; - next(); - } - ); + app.get('/-/static/favicon.ico', serveFavicon(config)); // Hook for tests only if (config._debug) { @@ -58,17 +50,12 @@ const defineAPI = function (config: IConfig, storage: IStorageHandler): any { // register middleware plugins const plugin_params = { config: config, - logger: logger + logger: logger, }; - const plugins: IPluginMiddleware[] = loadPlugin( - config, - config.middlewares, - plugin_params, - function (plugin: IPluginMiddleware) { - return plugin.register_middlewares; - } - ); + const plugins: IPluginMiddleware[] = loadPlugin(config, config.middlewares, plugin_params, function (plugin: IPluginMiddleware) { + return plugin.register_middlewares; + }); plugins.forEach((plugin: IPluginMiddleware) => { plugin.register_middlewares(app, auth, storage); }); @@ -91,12 +78,7 @@ const defineAPI = function (config: IConfig, storage: IStorageHandler): any { next(ErrorCode.getNotFound(API_ERROR.FILE_NOT_FOUND)); }); - app.use(function ( - err: HttpError, - req: $RequestExtend, - res: $ResponseExtend, - next: $NextFunctionVer - ) { + app.use(function (err: HttpError, req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer) { if (_.isError(err)) { if (err.code === 'ECONNABORT' && res.statusCode === HTTP_STATUS.NOT_MODIFIED) { return next(); @@ -124,14 +106,9 @@ export default (async function (configHash: any): Promise { // register middleware plugins const plugin_params = { config: config, - logger: logger + logger: logger, }; - const filters = loadPlugin( - config, - config.filters || {}, - plugin_params, - (plugin: IPluginStorageFilter) => plugin.filter_metadata - ); + const filters = loadPlugin(config, config.filters || {}, plugin_params, (plugin: IPluginStorageFilter) => plugin.filter_metadata); const storage: IStorageHandler = new Storage(config); // waits until init calls have been initialized await storage.init(config, filters); diff --git a/src/api/middleware.ts b/src/api/middleware.ts index 6de118314..028f1b546 100644 --- a/src/api/middleware.ts +++ b/src/api/middleware.ts @@ -1,33 +1,21 @@ +import fs from 'fs'; +import path from 'path'; import _ from 'lodash'; +import buildDebug from 'debug'; +import validator from 'validator'; import { Config, Package, RemoteUser } from '@verdaccio/types'; import { VerdaccioError } from '@verdaccio/commons-api'; -import { - validateName as utilValidateName, - validatePackage as utilValidatePackage, - getVersionFromTarball, - isObject, - ErrorCode -} from '../lib/utils'; -import { - API_ERROR, - HEADER_TYPE, - HEADERS, - HTTP_STATUS, - TOKEN_BASIC, - TOKEN_BEARER -} from '../lib/constants'; +import { validateName as utilValidateName, validatePackage as utilValidatePackage, getVersionFromTarball, isObject, ErrorCode } from '../lib/utils'; +import { API_ERROR, HEADER_TYPE, HEADERS, HTTP_STATUS, TOKEN_BASIC, TOKEN_BEARER } from '../lib/constants'; import { stringToMD5 } from '../lib/crypto-utils'; import { $ResponseExtend, $RequestExtend, $NextFunctionVer, IAuth } from '../../types'; import { logger } from '../lib/logger'; +const debug = buildDebug('verdaccio'); + export function match(regexp: RegExp): any { - return function ( - req: $RequestExtend, - res: $ResponseExtend, - next: $NextFunctionVer, - value: string - ): void { + return function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer, value: string): void { if (regexp.exec(value)) { next(); } else { @@ -36,11 +24,52 @@ export function match(regexp: RegExp): any { }; } -export function setSecurityWebHeaders( - req: $RequestExtend, - res: $ResponseExtend, - next: $NextFunctionVer -): void { +export function serveFavicon(config: Config) { + return function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer) { + try { + // @ts-ignore + const logoConf: string = config?.web?.logo as string; + if (logoConf === '') { + debug('favicon disabled'); + res.status(404); + } else if (!_.isEmpty(logoConf)) { + debug('custom favicon'); + if ( + validator.isURL(logoConf, { + require_host: true, + require_valid_protocol: true, + }) + ) { + debug('redirect to %o', logoConf); + res.redirect(logoConf); + } else { + const faviconPath = path.normalize(logoConf); + debug('serving favicon from %o', faviconPath); + fs.access(faviconPath, fs.constants.R_OK, (err) => { + if (err) { + debug('no read permissions to read: %o, reason:', logoConf, err?.message); + return res.status(HTTP_STATUS.NOT_FOUND).end(); + } else { + res.setHeader('Content-Type', 'image/x-icon'); + fs.createReadStream(faviconPath).pipe(res); + return; + } + }); + } + return next(); + } else { + res.setHeader('Content-Type', 'image/x-icon'); + fs.createReadStream(path.join(__dirname, './web/html/favicon.ico')).pipe(res); + debug('rendered ico'); + } + } catch (err) { + debug('error triggered, favicon not found'); + res.status(HTTP_STATUS.NOT_FOUND).end(); + } + }; +} + +export function setSecurityWebHeaders(req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { // disable loading in frames (clickjacking, etc.) res.header(HEADERS.FRAMES_OPTIONS, 'deny'); // avoid stablish connections outside of domain @@ -54,13 +83,7 @@ export function setSecurityWebHeaders( // flow: express does not match properly // flow info https://github.com/flowtype/flow-typed/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+express -export function validateName( - req: $RequestExtend, - res: $ResponseExtend, - next: $NextFunctionVer, - value: string, - name: string -): void { +export function validateName(req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer, value: string, name: string): void { if (value === '-') { // special case in couchdb usually next('route'); @@ -73,13 +96,7 @@ export function validateName( // flow: express does not match properly // flow info https://github.com/flowtype/flow-typed/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+express -export function validatePackage( - req: $RequestExtend, - res: $ResponseExtend, - next: $NextFunctionVer, - value: string, - name: string -): void { +export function validatePackage(req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer, value: string, name: string): void { if (value === '-') { // special case in couchdb usually next('route'); @@ -93,26 +110,14 @@ export function validatePackage( export function media(expect: string | null): any { return function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { if (req.headers[HEADER_TYPE.CONTENT_TYPE] !== expect) { - next( - ErrorCode.getCode( - HTTP_STATUS.UNSUPPORTED_MEDIA, - 'wrong content-type, expect: ' + - expect + - ', got: ' + - req.headers[HEADER_TYPE.CONTENT_TYPE] - ) - ); + next(ErrorCode.getCode(HTTP_STATUS.UNSUPPORTED_MEDIA, 'wrong content-type, expect: ' + expect + ', got: ' + req.headers[HEADER_TYPE.CONTENT_TYPE])); } else { next(); } }; } -export function encodeScopePackage( - req: $RequestExtend, - res: $ResponseExtend, - next: $NextFunctionVer -): void { +export function encodeScopePackage(req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { if (req.url.indexOf('@') !== -1) { // e.g.: /@org/pkg/1.2.3 -> /@org%2Fpkg/1.2.3, /@org%2Fpkg/1.2.3 -> /@org%2Fpkg/1.2.3 req.url = req.url.replace(/^(\/@[^\/%]+)\/(?!$)/, '$1%2F'); @@ -120,11 +125,7 @@ export function encodeScopePackage( next(); } -export function expectJson( - req: $RequestExtend, - res: $ResponseExtend, - next: $NextFunctionVer -): void { +export function expectJson(req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { if (!isObject(req.body)) { return next(ErrorCode.getBadRequest("can't parse incoming json")); } @@ -151,34 +152,23 @@ export function allow(auth: IAuth): Function { return function (action: string): Function { return function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { req.pause(); - const packageName = req.params.scope - ? `@${req.params.scope}/${req.params.package}` - : req.params.package; - const packageVersion = req.params.filename - ? getVersionFromTarball(req.params.filename) - : undefined; + const packageName = req.params.scope ? `@${req.params.scope}/${req.params.package}` : req.params.package; + const packageVersion = req.params.filename ? getVersionFromTarball(req.params.filename) : undefined; const remote: RemoteUser = req.remote_user; - logger.trace( - { action, user: remote.name }, - `[middleware/allow][@{action}] allow for @{user}` - ); + logger.trace({ action, user: remote.name }, `[middleware/allow][@{action}] allow for @{user}`); - auth['allow_' + action]( - { packageName, packageVersion }, - remote, - function (error, allowed): void { - req.resume(); - if (error) { - next(error); - } else if (allowed) { - next(); - } else { - // last plugin (that's our built-in one) returns either - // cb(err) or cb(null, true), so this should never happen - throw ErrorCode.getInternalError(API_ERROR.PLUGIN_ERROR); - } + auth['allow_' + action]({ packageName, packageVersion }, remote, function (error, allowed): void { + req.resume(); + if (error) { + next(error); + } else if (allowed) { + next(); + } else { + // last plugin (that's our built-in one) returns either + // cb(err) or cb(null, true), so this should never happen + throw ErrorCode.getInternalError(API_ERROR.PLUGIN_ERROR); } - ); + }); }; }; } @@ -189,12 +179,7 @@ export interface MiddlewareError { export type FinalBody = Package | MiddlewareError | string; -export function final( - body: FinalBody, - req: $RequestExtend, - res: $ResponseExtend, - next: $NextFunctionVer -): void { +export function final(body: FinalBody, req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { if (res.statusCode === HTTP_STATUS.UNAUTHORIZED && !res.getHeader(HEADERS.WWW_AUTH)) { // they say it's required for 401, so... res.header(HEADERS.WWW_AUTH, `${TOKEN_BASIC}, ${TOKEN_BEARER}`); @@ -214,10 +199,7 @@ export function final( } // don't send etags with errors - if ( - !res.statusCode || - (res.statusCode >= HTTP_STATUS.OK && res.statusCode < HTTP_STATUS.MULTIPLE_CHOICES) - ) { + if (!res.statusCode || (res.statusCode >= HTTP_STATUS.OK && res.statusCode < HTTP_STATUS.MULTIPLE_CHOICES)) { res.header(HEADERS.ETAG, '"' + stringToMD5(body as string) + '"'); } } else { @@ -239,8 +221,7 @@ export function final( res.send(body); } -export const LOG_STATUS_MESSAGE = - "@{status}, user: @{user}(@{remoteIP}), req: '@{request.method} @{request.url}'"; +export const LOG_STATUS_MESSAGE = "@{status}, user: @{user}(@{remoteIP}), req: '@{request.method} @{request.url}'"; export const LOG_VERDACCIO_ERROR = `${LOG_STATUS_MESSAGE}, error: @{!error}`; export const LOG_VERDACCIO_BYTES = `${LOG_STATUS_MESSAGE}, bytes: @{bytes.in}/@{bytes.out}`; @@ -316,7 +297,7 @@ export function log(config: Config) { { request: { method: req.method, - url: req.url + url: req.url, }, level: 35, // http user: (req.remote_user && req.remote_user.name) || null, @@ -325,8 +306,8 @@ export function log(config: Config) { error: res._verdaccio_error, bytes: { in: bytesin, - out: bytesout - } + out: bytesout, + }, }, message ); @@ -353,11 +334,7 @@ export function log(config: Config) { } // Middleware -export function errorReportingMiddleware( - req: $RequestExtend, - res: $ResponseExtend, - next: $NextFunctionVer -): void { +export function errorReportingMiddleware(req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { res.report_error = res.report_error || function (err: VerdaccioError): void { diff --git a/src/api/web/endpoint/package.ts b/src/api/web/endpoint/package.ts index 9fd08aafc..c2066443c 100644 --- a/src/api/web/endpoint/package.ts +++ b/src/api/web/endpoint/package.ts @@ -10,20 +10,14 @@ import { formatAuthor, convertDistRemoteToLocalTarballUrls, getLocalRegistryTarballUri, - isVersionValid + isVersionValid, + ErrorCode, } from '../../../lib/utils'; import { allow } from '../../middleware'; import { DIST_TAGS, HEADER_TYPE, HEADERS, HTTP_STATUS } from '../../../lib/constants'; import { generateGravatarUrl } from '../../../utils/user'; import { logger } from '../../../lib/logger'; -import { - IAuth, - $ResponseExtend, - $RequestExtend, - $NextFunctionVer, - IStorageHandler, - $SidebarPackage -} from '../../../../types'; +import { IAuth, $ResponseExtend, $RequestExtend, $NextFunctionVer, IStorageHandler, $SidebarPackage } from '../../../../types'; const getOrder = (order = 'asc') => { return order === 'asc'; @@ -31,12 +25,7 @@ const getOrder = (order = 'asc') => { export type PackcageExt = Package & { author: any; dist?: { tarball: string } }; -function addPackageWebApi( - route: Router, - storage: IStorageHandler, - auth: IAuth, - config: Config -): void { +function addPackageWebApi(route: Router, storage: IStorageHandler, auth: IAuth, config: Config): void { const can = allow(auth); const checkAllow = (name, remoteUser): Promise => @@ -54,135 +43,109 @@ function addPackageWebApi( }); // Get list of all visible package - route.get( - '/packages', - function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { - storage.getLocalDatabase(async function (err, packages): Promise { - if (err) { - throw err; - } + route.get('/packages', function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { + storage.getLocalDatabase(async function (err, packages): Promise { + if (err) { + throw err; + } - async function processPackages(packages: PackcageExt[] = []): Promise { - const permissions: PackcageExt[] = []; - const packgesCopy = packages.slice(); - for (const pkg of packgesCopy) { - const pkgCopy = { ...pkg }; - pkgCopy.author = formatAuthor(pkg.author); - try { - if (await checkAllow(pkg.name, req.remote_user)) { - if (config.web) { - pkgCopy.author.avatar = generateGravatarUrl( - pkgCopy.author.email, - config.web.gravatar - ); - } - if (!_.isNil(pkgCopy.dist) && !_.isNull(pkgCopy.dist.tarball)) { - pkgCopy.dist.tarball = getLocalRegistryTarballUri( - pkgCopy.dist.tarball, - pkg.name, - req, - config.url_prefix - ); - } - permissions.push(pkgCopy); + async function processPackages(packages: PackcageExt[] = []): Promise { + const permissions: PackcageExt[] = []; + const packgesCopy = packages.slice(); + for (const pkg of packgesCopy) { + const pkgCopy = { ...pkg }; + pkgCopy.author = formatAuthor(pkg.author); + try { + if (await checkAllow(pkg.name, req.remote_user)) { + if (config.web) { + pkgCopy.author.avatar = generateGravatarUrl(pkgCopy.author.email, config.web.gravatar); } - } catch (err) { - logger.logger.error( - { name: pkg.name, error: err }, - 'permission process for @{name} has failed: @{error}' - ); - throw err; + if (!_.isNil(pkgCopy.dist) && !_.isNull(pkgCopy.dist.tarball)) { + pkgCopy.dist.tarball = getLocalRegistryTarballUri(pkgCopy.dist.tarball, pkg.name, req, config.url_prefix); + } + permissions.push(pkgCopy); } + } catch (err) { + logger.error({ name: pkg.name, error: err }, 'permission process for @{name} has failed: @{error}'); + throw err; } - - return permissions; } - const { web } = config; - // @ts-ignore - const order: boolean = config.web ? getOrder(web.sort_packages) : true; + return permissions; + } + const { web } = config; + // @ts-ignore + const order: boolean = config.web ? getOrder(web.sort_packages) : true; + + try { next(sortByName(await processPackages(packages), order)); - }); - } - ); + } catch (error) { + next(ErrorCode.getInternalError()); + } + }); + }); // Get package readme - route.get( - '/package/readme/(@:scope/)?:package/:version?', - can('access'), - function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { - const packageName = req.params.scope - ? addScope(req.params.scope, req.params.package) - : req.params.package; + route.get('/package/readme/(@:scope/)?:package/:version?', can('access'), function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { + const packageName = req.params.scope ? addScope(req.params.scope, req.params.package) : req.params.package; - storage.getPackage({ - name: packageName, - uplinksLook: true, - req, - callback: function (err, info): void { - if (err) { - return next(err); - } - - res.set(HEADER_TYPE.CONTENT_TYPE, HEADERS.TEXT_PLAIN); - next(parseReadme(info.name, info.readme)); + storage.getPackage({ + name: packageName, + uplinksLook: true, + req, + callback: function (err, info): void { + if (err) { + return next(err); } - }); - } - ); - route.get( - '/sidebar/(@:scope/)?:package', - can('access'), - function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { - const packageName: string = req.params.scope - ? addScope(req.params.scope, req.params.package) - : req.params.package; + res.set(HEADER_TYPE.CONTENT_TYPE, HEADERS.TEXT_PLAIN); + next(parseReadme(info.name, info.readme)); + }, + }); + }); - storage.getPackage({ - name: packageName, - uplinksLook: true, - keepUpLinkData: true, - req, - callback: function (err: Error, info: $SidebarPackage): void { - if (_.isNil(err)) { - const { v } = req.query; - let sideBarInfo: any = _.clone(info); - sideBarInfo.versions = convertDistRemoteToLocalTarballUrls( - info, - req, - config.url_prefix - ).versions; - if (isVersionValid(info, v)) { - // @ts-ignore - sideBarInfo.latest = sideBarInfo.versions[v]; + route.get('/sidebar/(@:scope/)?:package', can('access'), function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer): void { + const packageName: string = req.params.scope ? addScope(req.params.scope, req.params.package) : req.params.package; + + storage.getPackage({ + name: packageName, + uplinksLook: true, + keepUpLinkData: true, + req, + callback: function (err: Error, info: $SidebarPackage): void { + if (_.isNil(err)) { + const { v } = req.query; + let sideBarInfo: any = _.clone(info); + sideBarInfo.versions = convertDistRemoteToLocalTarballUrls(info, req, config.url_prefix).versions; + if (isVersionValid(info, v)) { + // @ts-ignore + sideBarInfo.latest = sideBarInfo.versions[v]; + sideBarInfo.latest.author = formatAuthor(sideBarInfo.latest.author); + } else { + sideBarInfo.latest = sideBarInfo.versions[info[DIST_TAGS].latest]; + if (sideBarInfo?.latest) { sideBarInfo.latest.author = formatAuthor(sideBarInfo.latest.author); } else { - sideBarInfo.latest = sideBarInfo.versions[info[DIST_TAGS].latest]; - if (sideBarInfo?.latest) { - sideBarInfo.latest.author = formatAuthor(sideBarInfo.latest.author); - } else { - res.status(HTTP_STATUS.NOT_FOUND); - res.end(); - return; - } + res.status(HTTP_STATUS.NOT_FOUND); + res.end(); + return; } - sideBarInfo = deleteProperties(['readme', '_attachments', '_rev', 'name'], sideBarInfo); - if (config.web) { - sideBarInfo = addGravatarSupport(sideBarInfo, config.web.gravatar); - } else { - sideBarInfo = addGravatarSupport(sideBarInfo); - } - next(sideBarInfo); - } else { - res.status(HTTP_STATUS.NOT_FOUND); - res.end(); } + sideBarInfo = deleteProperties(['readme', '_attachments', '_rev', 'name'], sideBarInfo); + if (config.web) { + sideBarInfo = addGravatarSupport(sideBarInfo, config.web.gravatar); + } else { + sideBarInfo = addGravatarSupport(sideBarInfo); + } + next(sideBarInfo); + } else { + res.status(HTTP_STATUS.NOT_FOUND); + res.end(); } - }); - } - ); + }, + }); + }); } export default addPackageWebApi; diff --git a/src/api/web/html/favicon.ico b/src/api/web/html/favicon.ico new file mode 100644 index 000000000..1a4beb4b6 Binary files /dev/null and b/src/api/web/html/favicon.ico differ diff --git a/src/api/web/html/manifest.ts b/src/api/web/html/manifest.ts new file mode 100644 index 000000000..6faa8861f --- /dev/null +++ b/src/api/web/html/manifest.ts @@ -0,0 +1,19 @@ +import buildDebug from 'debug'; + +export type Manifest = { + // goes on first place at the header + ico: string; + css: string[]; + js: string[]; +}; + +const debug = buildDebug('verdaccio'); + +export function getManifestValue(manifestItems: string[], manifest, basePath: string = ''): string[] { + return manifestItems?.map((item) => { + debug('resolve item %o', item); + const resolvedItem = `${basePath}${manifest[item]}`; + debug('resolved item %o', resolvedItem); + return resolvedItem; + }); +} diff --git a/src/api/web/html/renderHTML.ts b/src/api/web/html/renderHTML.ts new file mode 100644 index 000000000..7ee2ba0b1 --- /dev/null +++ b/src/api/web/html/renderHTML.ts @@ -0,0 +1,95 @@ +import { URL } from 'url'; +import buildDebug from 'debug'; +import LRU from 'lru-cache'; +import { HEADERS } from '@verdaccio/commons-api'; + +import { getPublicUrl } from '../../../lib/utils'; +import { WEB_TITLE } from '../../../lib/constants'; +import renderTemplate from './template'; + +const pkgJSON = require('../../../../package.json'); +const DEFAULT_LANGUAGE = 'es-US'; +const cache = new LRU({ max: 500, maxAge: 1000 * 60 * 60 }); + +const debug = buildDebug('verdaccio'); + +const defaultManifestFiles = { + js: ['runtime.js', 'vendors.js', 'main.js'], + ico: 'favicon.ico', +}; + +export function validatePrimaryColor(primaryColor) { + const isHex = /^#+([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/i.test(primaryColor); + if (!isHex) { + debug('invalid primary color %o', primaryColor); + return; + } + + return primaryColor; +} + +export default function renderHTML(config, manifest, manifestFiles, req, res) { + const { url_prefix } = config; + const base = getPublicUrl(config?.url_prefix, req); + const basename = new URL(base).pathname; + const language = config?.i18n?.web ?? DEFAULT_LANGUAGE; + const darkMode = config?.web?.darkMode ?? false; + const title = config?.web?.title ?? WEB_TITLE; + const scope = config?.web?.scope ?? ''; + // FIXME: logo URI is incomplete + let logoURI = config?.web?.logo ?? ''; + const version = pkgJSON.version; + const primaryColor = validatePrimaryColor(config?.web?.primary_color) ?? '#4b5e40'; + const { scriptsBodyAfter, metaScripts, scriptsbodyBefore } = Object.assign( + {}, + { + scriptsBodyAfter: [], + bodyBefore: [], + metaScripts: [], + }, + config?.web + ); + const options = { + darkMode, + url_prefix, + basename, + base, + primaryColor, + version, + logoURI, + title, + scope, + language, + }; + + let webPage; + + try { + webPage = cache.get('template'); + + if (!webPage) { + debug('web options %o', options); + debug('web manifestFiles %o', manifestFiles); + webPage = renderTemplate( + { + manifest: manifestFiles ?? defaultManifestFiles, + options, + scriptsBodyAfter, + metaScripts, + scriptsbodyBefore, + }, + manifest + ); + debug('template :: %o', webPage); + cache.set('template', webPage); + debug('set template cache'); + } else { + debug('reuse template cache'); + } + } catch (error) { + throw new Error(`theme could not be load, stack ${error.stack}`); + } + res.setHeader('Content-Type', HEADERS.TEXT_HTML); + res.send(webPage); + debug('render web'); +} diff --git a/src/api/web/html/template.ts b/src/api/web/html/template.ts new file mode 100644 index 000000000..29c3f998f --- /dev/null +++ b/src/api/web/html/template.ts @@ -0,0 +1,60 @@ +import buildDebug from 'debug'; +import { getManifestValue, Manifest } from './manifest'; + +const debug = buildDebug('verdaccio'); + +export type TemplateUIOptions = { + title?: string; + uri?: string; + darkMode?: boolean; + protocol?: string; + host?: string; + url_prefix?: string; + base: string; + primaryColor?: string; + version?: string; + logoURI?: string; + scope?: string; + language?: string; +}; + +export type Template = { + manifest: Manifest; + options: TemplateUIOptions; + metaScripts?: string[]; + scriptsBodyAfter?: string[]; + scriptsbodyBefore?: string[]; +}; + +// the outcome of the Webpack Manifest Plugin +export interface WebpackManifest { + [key: string]: string; +} + +export default function renderTemplate(template: Template, manifest: WebpackManifest) { + debug('template %o', template); + debug('manifest %o', manifest); + + return ` + + + + + + ${template?.options?.title ?? ''} + + + + ${template?.metaScripts ? template.metaScripts.join('') : ''} + + + ${template?.scriptsbodyBefore ? template.scriptsbodyBefore.join('') : ''} +
+ ${getManifestValue(template.manifest.js, manifest, template?.options.base).map((item) => ``).join('')} + ${template?.scriptsBodyAfter ? template.scriptsBodyAfter.join('') : ''} + + + `; +} diff --git a/src/api/web/index.ts b/src/api/web/index.ts index d55cf5879..8536632bc 100644 --- a/src/api/web/index.ts +++ b/src/api/web/index.ts @@ -1,22 +1,15 @@ -/** - * @prettier - */ - -import fs from 'fs'; - -import path from 'path'; import _ from 'lodash'; import express from 'express'; +import buildDebug from 'debug'; -import { combineBaseUrl, getWebProtocol, isHTTPProtocol } from '../../lib/utils'; import Search from '../../lib/search'; -import { HEADERS, HTTP_STATUS, WEB_TITLE } from '../../lib/constants'; +import { HTTP_STATUS } from '../../lib/constants'; import loadPlugin from '../../lib/plugin-loader'; +import renderHTML from './html/renderHTML'; const { setSecurityWebHeaders } = require('../middleware'); -const pkgJSON = require('../../../package.json'); -const DEFAULT_LANGUAGE = 'es-US'; +const debug = buildDebug('verdaccio'); export function loadTheme(config) { if (_.isNil(config.theme) === false) { @@ -37,7 +30,8 @@ export function loadTheme(config) { export function validatePrimaryColor(primaryColor) { const isHex = /^#+([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/i.test(primaryColor); if (!isHex) { - return ''; + debug('invalid primary color %o', primaryColor); + return; } return primaryColor; @@ -55,81 +49,31 @@ const sendFileCallback = (next) => (err) => { }; export default function (config, auth, storage) { + let { staticPath, manifest, manifestFiles } = loadTheme(config) || require('@verdaccio/ui-theme')(); + debug('static path %o', staticPath); Search.configureStorage(storage); + /* eslint new-cap:off */ const router = express.Router(); - router.use(auth.webUIJWTmiddleware()); router.use(setSecurityWebHeaders); - const themePath = loadTheme(config) || require('@verdaccio/ui-theme')(); - const indexTemplate = path.join(themePath, 'index.html'); - const template = fs.readFileSync(indexTemplate).toString(); - // Logo - let logoURI = _.get(config, 'web.logo') ? config.web.logo : ''; - if (logoURI && !isHTTPProtocol(logoURI)) { - // URI related to a local file - - // Note: `path.join` will break on Windows, because it transforms `/` to `\` - // Use POSIX version `path.posix.join` instead. - logoURI = path.posix.join('/-/static/', path.basename(logoURI)); - router.get(logoURI, function (req, res, next) { - res.sendFile(path.resolve(config.web.logo), sendFileCallback(next)); - }); - } - - // Static + // static assets router.get('/-/static/*', function (req, res, next) { const filename = req.params[0]; - const file = `${themePath}/${filename}`; + const file = `${staticPath}/${filename}`; + debug('render static file %o', file); res.sendFile(file, sendFileCallback(next)); }); - function renderHTML(req, res) { - const protocol = getWebProtocol(req.get(HEADERS.FORWARDED_PROTO), req.protocol); - const host = req.get('host'); - const { url_prefix } = config; - const uri = `${protocol}://${host}`; - const base = combineBaseUrl(protocol, host, url_prefix); - const language = config?.i18n?.web ?? DEFAULT_LANGUAGE; - const darkMode = config?.web?.darkMode ?? false; - const primaryColor = validatePrimaryColor(config?.web?.primary_color); - const title = _.get(config, 'web.title') ? config.web.title : WEB_TITLE; - const scope = _.get(config, 'web.scope') ? config.web.scope : ''; - const options = { - uri, - darkMode, - protocol, - host, - url_prefix, - base, - primaryColor, - title, - scope, - language - }; - - const webPage = template - .replace(/ToReplaceByVerdaccioUI/g, JSON.stringify(options)) - .replace(/ToReplaceByVerdaccio/g, base) - .replace(/ToReplaceByPrefix/g, url_prefix) - .replace(/ToReplaceByVersion/g, pkgJSON.version) - .replace(/ToReplaceByTitle/g, title) - .replace(/ToReplaceByLogo/g, logoURI) - .replace(/ToReplaceByPrimaryColor/g, primaryColor) - .replace(/ToReplaceByScope/g, scope); - - res.setHeader('Content-Type', HEADERS.TEXT_HTML); - - res.send(webPage); - } - router.get('/-/web/:section/*', function (req, res) { - renderHTML(req, res); + renderHTML(config, manifest, manifestFiles, req, res); + debug('render html section'); }); router.get('/', function (req, res) { - renderHTML(req, res); + renderHTML(config, manifest, manifestFiles, req, res); + debug('render root'); }); return router; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 9c2f3d129..d89b8da44 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,17 +1,16 @@ -/** - * @prettier - */ import fs from 'fs'; import assert from 'assert'; -import URL from 'url'; -import { IncomingHttpHeaders } from 'http2'; +import DefaultURL, { URL } from 'url'; import _ from 'lodash'; +import buildDebug from 'debug'; import semver from 'semver'; import YAML from 'js-yaml'; +import validator from 'validator'; import sanitizyReadme from '@verdaccio/readme'; import { Package, Version, Author } from '@verdaccio/types'; import { Request } from 'express'; +// eslint-disable-next-line max-len import { getConflict, getBadData, getBadRequest, getInternalError, getUnauthorized, getForbidden, getServiceUnavailable, getNotFound, getCode } from '@verdaccio/commons-api'; import { generateGravatarUrl, GENERIC_AVATAR } from '../utils/user'; import { StringValue, AuthorAvatar } from '../../types'; @@ -21,11 +20,14 @@ import { normalizeContributors } from './storage-utils'; import { logger } from './logger'; +const debug = buildDebug('verdaccio'); + // eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-var-requires require('pkginfo')(module); const pkgVersion = module.exports.version; const pkgName = module.exports.name; +const validProtocols = ['https', 'http']; export function getUserAgent(): string { assert(_.isString(pkgName)); @@ -116,32 +118,9 @@ export function validateMetadata(object: Package, name: string): Package { return object; } -/** - * Create base url for registry. - * @return {String} base registry url - */ -export function combineBaseUrl(protocol: string, host: string | void, prefix?: string | void): string { - const result = `${protocol}://${host}`; - - const prefixOnlySlash = prefix === '/'; - if (prefix && !prefixOnlySlash) { - if (prefix.endsWith('/')) { - prefix = prefix.slice(0, -1); - } - - if (prefix.startsWith('/')) { - return `${result}${prefix}`; - } - - return prefix; - } - - return result; -} - export function extractTarballFromUrl(url: string): string { // @ts-ignore - return URL.parse(url).pathname.replace(/^.*\//, ''); + return DefaultURL.parse(url).pathname.replace(/^.*\//, ''); } /** @@ -176,20 +155,11 @@ export function getLocalRegistryTarballUri(uri: string, pkgName: string, req: Re return uri; } const tarballName = extractTarballFromUrl(uri); - const headers = req.headers as IncomingHttpHeaders; - const protocol = getWebProtocol(req.get(HEADERS.FORWARDED_PROTO), req.protocol); - const domainRegistry = combineBaseUrl(protocol, headers.host, urlPrefix); + const domainRegistry = getPublicUrl(urlPrefix || '', req); - return `${domainRegistry}/${encodeScopedUri(pkgName)}/-/${tarballName}`; + return `${domainRegistry}${encodeScopedUri(pkgName)}/-/${tarballName}`; } -/** - * Create a tag for a package - * @param {*} data - * @param {*} version - * @param {*} tag - * @return {Boolean} whether a package has been tagged - */ export function tagVersion(data: Package, version: string, tag: StringValue): boolean { if (tag && data[DIST_TAGS][tag] !== version && semver.parse(version, true)) { // valid version - store @@ -362,12 +332,19 @@ export function parseInterval(interval: any): number { * Detect running protocol (http or https) */ export function getWebProtocol(headerProtocol: string | void, protocol: string): string { + let returnProtocol; + const [, defaultProtocol] = validProtocols; + // HAProxy variant might return http,http with X-Forwarded-Proto if (typeof headerProtocol === 'string' && headerProtocol !== '') { + debug('header protocol: %o', protocol); const commaIndex = headerProtocol.indexOf(','); - return commaIndex > 0 ? headerProtocol.substr(0, commaIndex) : headerProtocol; + returnProtocol = commaIndex > 0 ? headerProtocol.substr(0, commaIndex) : headerProtocol; + } else { + debug('req protocol: %o', headerProtocol); + returnProtocol = protocol; } - return protocol; + return validProtocols.includes(returnProtocol) ? returnProtocol : defaultProtocol; } export function getLatestVersion(pkgInfo: Package): string { @@ -623,3 +600,76 @@ export function isRelatedToDeprecation(pkgInfo: Package): boolean { } return false; } + +export function validateURL(publicUrl: string | void) { + try { + const parsed = new URL(publicUrl as string); + if (!validProtocols.includes(parsed.protocol.replace(':', ''))) { + throw Error('invalid protocol'); + } + return true; + } catch (err) { + // TODO: add error logger here + return false; + } +} + +export function isHost(url: string = '', options = {}): boolean { + return validator.isURL(url, { + require_host: true, + allow_trailing_dot: false, + require_valid_protocol: false, + // @ts-ignore + require_port: false, + require_tld: false, + ...options, + }); +} + +export function getPublicUrl(url_prefix: string = '', req): string { + if (validateURL(process.env.VERDACCIO_PUBLIC_URL as string)) { + const envURL = new URL(wrapPrefix(url_prefix), process.env.VERDACCIO_PUBLIC_URL as string).href; + debug('public url by env %o', envURL); + return envURL; + } else if (req.get('host')) { + const host = req.get('host'); + if (!isHost(host)) { + throw new Error('invalid host'); + } + const protocol = getWebProtocol(req.get(HEADERS.FORWARDED_PROTO), req.protocol); + const combinedUrl = combineBaseUrl(protocol, host, url_prefix); + debug('public url by request %o', combinedUrl); + return combinedUrl; + } else { + return '/'; + } +} + +/** + * Create base url for registry. + * @return {String} base registry url + */ +export function combineBaseUrl(protocol: string, host: string, prefix: string = ''): string { + debug('combined protocol %o', protocol); + debug('combined host %o', host); + const newPrefix = wrapPrefix(prefix); + debug('combined prefix %o', newPrefix); + const groupedURI = new URL(wrapPrefix(prefix), `${protocol}://${host}`); + const result = groupedURI.href; + debug('combined url %o', result); + return result; +} + +export function wrapPrefix(prefix: string | void): string { + if (prefix === '' || typeof prefix === 'undefined' || prefix === null) { + return ''; + } else if (!prefix.startsWith('/') && prefix.endsWith('/')) { + return `/${prefix}`; + } else if (!prefix.startsWith('/') && !prefix.endsWith('/')) { + return `/${prefix}/`; + } else if (prefix.startsWith('/') && !prefix.endsWith('/')) { + return `${prefix}/`; + } else { + return prefix; + } +} diff --git a/test/e2e-cli/config/_bootstrap_verdaccio.yaml b/test/e2e-cli/config/_bootstrap_verdaccio.yaml index 50fe65d73..9a881d6a2 100644 --- a/test/e2e-cli/config/_bootstrap_verdaccio.yaml +++ b/test/e2e-cli/config/_bootstrap_verdaccio.yaml @@ -15,7 +15,7 @@ web: uplinks: npmjs: - url: https://registry.npmjs.org/ + url: https://registry.verdaccio.org/ logs: - { type: stdout, format: pretty, level: warn } diff --git a/test/unit/modules/utils/utils.spec.ts b/test/unit/modules/utils/utils.spec.ts index b4360c654..17cc44c0e 100644 --- a/test/unit/modules/utils/utils.spec.ts +++ b/test/unit/modules/utils/utils.spec.ts @@ -1,3 +1,5 @@ +import * as httpMocks from 'node-mocks-http'; +import { HEADERS } from '@verdaccio/commons-api'; import { generateGravatarUrl, GENERIC_AVATAR } from '../../../../src/utils/user'; import { spliceURL } from '../../../../src/utils/string'; import { @@ -14,7 +16,8 @@ import { getVersionFromTarball, sortByName, formatAuthor, - isHTTPProtocol + isHTTPProtocol, + getPublicUrl, } from '../../../../src/lib/utils'; import { DIST_TAGS, DEFAULT_USER } from '../../../../src/lib/constants'; import { logger, setup } from '../../../../src/lib/logger'; @@ -32,15 +35,15 @@ describe('Utilities', () => { versions: { '1.0.0': { dist: { - tarball: 'http://registry.org/npm_test/-/npm_test-1.0.0.tgz' - } + tarball: 'http://registry.org/npm_test/-/npm_test-1.0.0.tgz', + }, }, '1.0.1': { dist: { - tarball: 'http://registry.org/npm_test/-/npm_test-1.0.1.tgz' - } - } - } + tarball: 'http://registry.org/npm_test/-/npm_test-1.0.1.tgz', + }, + }, + }, }; const cloneMetadata = (pkg = metadata) => Object.assign({}, pkg); @@ -49,40 +52,40 @@ describe('Utilities', () => { describe('Sort packages', () => { const packages = [ { - name: 'ghc' + name: 'ghc', }, { - name: 'abc' + name: 'abc', }, { - name: 'zxy' - } + name: 'zxy', + }, ]; test('should order ascending', () => { expect(sortByName(packages)).toEqual([ { - name: 'abc' + name: 'abc', }, { - name: 'ghc' + name: 'ghc', }, { - name: 'zxy' - } + name: 'zxy', + }, ]); }); test('should order descending', () => { expect(sortByName(packages, false)).toEqual([ { - name: 'zxy' + name: 'zxy', }, { - name: 'ghc' + name: 'ghc', }, { - name: 'abc' - } + name: 'abc', + }, ]); }); }); @@ -104,9 +107,12 @@ describe('Utilities', () => { expect(getWebProtocol('https', '')).toBe('https'); }); + test('should have handle invalid protocol', () => { + expect(getWebProtocol('ftp', '')).toBe('http'); + }); + describe('getWebProtocol and HAProxy variant', () => { // https://github.com/verdaccio/verdaccio/issues/695 - test('should handle http', () => { expect(getWebProtocol('http,http', 'https')).toBe('http'); }); @@ -119,14 +125,15 @@ describe('Utilities', () => { describe('convertDistRemoteToLocalTarballUrls', () => { test('should build a URI for dist tarball based on new domain', () => { - const convertDist = convertDistRemoteToLocalTarballUrls(cloneMetadata(), { + const req = httpMocks.createRequest({ + method: 'GET', headers: { - host: fakeHost + host: fakeHost, }, - // @ts-ignore - get: () => 'http', - protocol: 'http' + protocol: 'http', + url: '/', }); + const convertDist = convertDistRemoteToLocalTarballUrls(cloneMetadata(), req); expect(convertDist.versions['1.0.0'].dist.tarball).toEqual(buildURI(fakeHost, '1.0.0')); expect(convertDist.versions['1.0.1'].dist.tarball).toEqual(buildURI(fakeHost, '1.0.1')); }); @@ -136,11 +143,9 @@ describe('Utilities', () => { headers: {}, // @ts-ignore get: () => 'http', - protocol: 'http' + protocol: 'http', }); - expect(convertDist.versions['1.0.0'].dist.tarball).toEqual( - convertDist.versions['1.0.0'].dist.tarball - ); + expect(convertDist.versions['1.0.0'].dist.tarball).toEqual(convertDist.versions['1.0.0'].dist.tarball); }); }); @@ -148,7 +153,7 @@ describe('Utilities', () => { test('should delete a invalid latest version', () => { const pkg = cloneMetadata(); pkg[DIST_TAGS] = { - latest: '20000' + latest: '20000', }; normalizeDistTags(pkg); @@ -168,7 +173,7 @@ describe('Utilities', () => { test('should define last published version as latest with a custom dist-tag', () => { const pkg = cloneMetadata(); pkg[DIST_TAGS] = { - beta: '1.0.1' + beta: '1.0.1', }; normalizeDistTags(pkg); @@ -179,7 +184,7 @@ describe('Utilities', () => { test('should convert any array of dist-tags to a plain string', () => { const pkg = cloneMetadata(); pkg[DIST_TAGS] = { - latest: ['1.0.1'] + latest: ['1.0.1'], }; normalizeDistTags(pkg); @@ -206,17 +211,21 @@ describe('Utilities', () => { describe('combineBaseUrl', () => { test('should create a URI', () => { - expect(combineBaseUrl('http', 'domain')).toEqual('http://domain'); + expect(combineBaseUrl('http', 'domain')).toEqual('http://domain/'); }); test('should create a base url for registry', () => { - expect(combineBaseUrl('http', 'domain', '')).toEqual('http://domain'); - expect(combineBaseUrl('http', 'domain', '/')).toEqual('http://domain'); - expect(combineBaseUrl('http', 'domain', '/prefix/')).toEqual('http://domain/prefix'); - expect(combineBaseUrl('http', 'domain', '/prefix/deep')).toEqual( - 'http://domain/prefix/deep' - ); - expect(combineBaseUrl('http', 'domain', 'only-prefix')).toEqual('only-prefix'); + expect(combineBaseUrl('http', 'domain.com', '')).toEqual('http://domain.com/'); + expect(combineBaseUrl('http', 'domain.com', '/')).toEqual('http://domain.com/'); + expect(combineBaseUrl('http', 'domain.com', '/prefix/')).toEqual('http://domain.com/prefix/'); + expect(combineBaseUrl('http', 'domain.com', '/prefix/deep')).toEqual('http://domain.com/prefix/deep/'); + expect(combineBaseUrl('http', 'domain.com', 'prefix/')).toEqual('http://domain.com/prefix/'); + expect(combineBaseUrl('http', 'domain.com', 'prefix')).toEqual('http://domain.com/prefix/'); + }); + + test('invalid url prefix', () => { + expect(combineBaseUrl('http', 'domain.com', 'only-prefix')).toEqual('http://domain.com/only-prefix/'); + expect(combineBaseUrl('https', 'domain.com', 'only-prefix')).toEqual('https://domain.com/only-prefix/'); }); }); @@ -389,19 +398,14 @@ describe('Utilities', () => { expect(parseReadme('testPackage', randomText)).toEqual('

%%%%%**##==

'); expect(parseReadme('testPackage', simpleText)).toEqual('

simple text

'); - expect(parseReadme('testPackage', randomTextMarkdown)).toEqual( - '

simple text

\n

markdown

' - ); + expect(parseReadme('testPackage', randomTextMarkdown)).toEqual('

simple text

\n

markdown

'); }); test('should show error for no readme data', () => { const noData = ''; const spy = jest.spyOn(logger, 'error'); expect(parseReadme('testPackage', noData)).toEqual('

ERROR: No README data found!

'); - expect(spy).toHaveBeenCalledWith( - { packageName: 'testPackage' }, - '@{packageName}: No readme found' - ); + expect(spy).toHaveBeenCalledWith({ packageName: 'testPackage' }, '@{packageName}: No readme found'); }); }); @@ -413,7 +417,7 @@ describe('Utilities', () => { test('author, contributors and maintainers fields are not present', () => { const packageInfo = { - latest: {} + latest: {}, }; // @ts-ignore @@ -429,16 +433,16 @@ describe('Utilities', () => { test('author field is a string type', () => { const packageInfo = { - latest: { author: 'user@verdccio.org' } + latest: { author: 'user@verdccio.org' }, }; const result = { latest: { author: { author: 'user@verdccio.org', avatar: GENERIC_AVATAR, - email: '' - } - } + email: '', + }, + }, }; // @ts-ignore @@ -447,16 +451,16 @@ describe('Utilities', () => { test('author field is an object type with author information', () => { const packageInfo = { - latest: { author: { name: 'verdaccio', email: 'user@verdccio.org' } } + latest: { author: { name: 'verdaccio', email: 'user@verdccio.org' } }, }; const result = { latest: { author: { avatar: 'https://www.gravatar.com/avatar/794d7f6ef93d0689437de3c3e48fadc7', email: 'user@verdccio.org', - name: 'verdaccio' - } - } + name: 'verdaccio', + }, + }, }; // @ts-ignore @@ -466,8 +470,8 @@ describe('Utilities', () => { test('contributor field is a blank array', () => { const packageInfo = { latest: { - contributors: [] - } + contributors: [], + }, }; // @ts-ignore @@ -480,9 +484,9 @@ describe('Utilities', () => { latest: { contributors: [ { name: 'user', email: 'user@verdccio.org' }, - { name: 'user1', email: 'user1@verdccio.org' } - ] - } + { name: 'user1', email: 'user1@verdccio.org' }, + ], + }, }; const result = { @@ -491,15 +495,15 @@ describe('Utilities', () => { { avatar: 'https://www.gravatar.com/avatar/794d7f6ef93d0689437de3c3e48fadc7', email: 'user@verdccio.org', - name: 'user' + name: 'user', }, { avatar: 'https://www.gravatar.com/avatar/51105a49ce4a9c2bfabf0f6a2cba3762', email: 'user1@verdccio.org', - name: 'user1' - } - ] - } + name: 'user1', + }, + ], + }, }; // @ts-ignore @@ -509,8 +513,8 @@ describe('Utilities', () => { test('contributors field is an object', () => { const packageInfo = { latest: { - contributors: { name: 'user', email: 'user@verdccio.org' } - } + contributors: { name: 'user', email: 'user@verdccio.org' }, + }, }; const result = { @@ -519,10 +523,10 @@ describe('Utilities', () => { { avatar: 'https://www.gravatar.com/avatar/794d7f6ef93d0689437de3c3e48fadc7', email: 'user@verdccio.org', - name: 'user' - } - ] - } + name: 'user', + }, + ], + }, }; // @ts-ignore @@ -533,8 +537,8 @@ describe('Utilities', () => { const contributor = 'Barney Rubble (http://barnyrubble.tumblr.com/)'; const packageInfo = { latest: { - contributors: contributor - } + contributors: contributor, + }, }; const result = { @@ -543,10 +547,10 @@ describe('Utilities', () => { { avatar: GENERIC_AVATAR, email: contributor, - name: contributor - } - ] - } + name: contributor, + }, + ], + }, }; // @ts-ignore @@ -557,8 +561,8 @@ describe('Utilities', () => { test('maintainers field is a blank array', () => { const packageInfo = { latest: { - maintainers: [] - } + maintainers: [], + }, }; // @ts-ignore @@ -570,9 +574,9 @@ describe('Utilities', () => { latest: { maintainers: [ { name: 'user', email: 'user@verdccio.org' }, - { name: 'user1', email: 'user1@verdccio.org' } - ] - } + { name: 'user1', email: 'user1@verdccio.org' }, + ], + }, }; const result = { @@ -581,15 +585,15 @@ describe('Utilities', () => { { avatar: 'https://www.gravatar.com/avatar/794d7f6ef93d0689437de3c3e48fadc7', email: 'user@verdccio.org', - name: 'user' + name: 'user', }, { avatar: 'https://www.gravatar.com/avatar/51105a49ce4a9c2bfabf0f6a2cba3762', email: 'user1@verdccio.org', - name: 'user1' - } - ] - } + name: 'user1', + }, + ], + }, }; // @ts-ignore @@ -606,7 +610,7 @@ describe('Utilities', () => { const user = { name: 'Verdaccion NPM', email: 'verdaccio@verdaccio.org', - url: 'https://verdaccio.org' + url: 'https://verdaccio.org', }; expect(formatAuthor(user).url).toEqual(user.url); expect(formatAuthor(user).email).toEqual(user.email); @@ -618,4 +622,254 @@ describe('Utilities', () => { expect(formatAuthor([]).name).toEqual(DEFAULT_USER); }); }); + + describe('host', () => { + // this scenario is usual when reverse proxy is setup + // without the host header + test('get empty string with missing host header', () => { + const req = httpMocks.createRequest({ + method: 'GET', + url: '/', + }); + expect(getPublicUrl(undefined, req)).toEqual('/'); + }); + + test('get a valid host', () => { + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: 'some.com', + }, + url: '/', + }); + expect(getPublicUrl(undefined, req)).toEqual('http://some.com/'); + }); + + test('check a valid host header injection', () => { + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: `some.com">`, + }, + url: '/', + }); + expect(function () { + // @ts-expect-error + getPublicUrl({}, req); + }).toThrow('invalid host'); + }); + + test('get a valid host with prefix', () => { + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: 'some.com', + }, + url: '/', + }); + + expect(getPublicUrl('/prefix/', req)).toEqual('http://some.com/prefix/'); + }); + + test('get a valid host with prefix no trailing', () => { + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: 'some.com', + }, + url: '/', + }); + + expect(getPublicUrl('/prefix-no-trailing', req)).toEqual('http://some.com/prefix-no-trailing/'); + }); + + test('get a valid host with null prefix', () => { + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: 'some.com', + }, + url: '/', + }); + + // @ts-ignore + expect(getPublicUrl(null, req)).toEqual('http://some.com/'); + }); + }); + + describe('X-Forwarded-Proto', () => { + test('with a valid X-Forwarded-Proto https', () => { + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: 'some.com', + [HEADERS.FORWARDED_PROTO]: 'https', + }, + url: '/', + }); + + expect(getPublicUrl(undefined, req)).toEqual('https://some.com/'); + }); + + test('with a invalid X-Forwarded-Proto https', () => { + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: 'some.com', + [HEADERS.FORWARDED_PROTO]: 'invalidProto', + }, + url: '/', + }); + + expect(getPublicUrl(undefined, req)).toEqual('http://some.com/'); + }); + + test('with a HAProxy X-Forwarded-Proto https', () => { + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: 'some.com', + [HEADERS.FORWARDED_PROTO]: 'https,https', + }, + url: '/', + }); + + expect(getPublicUrl(undefined, req)).toEqual('https://some.com/'); + }); + + test('with a HAProxy X-Forwarded-Proto different protocol', () => { + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: 'some.com', + [HEADERS.FORWARDED_PROTO]: 'http,https', + }, + url: '/', + }); + + expect(getPublicUrl(undefined, req)).toEqual('http://some.com/'); + }); + }); + + describe('env variable', () => { + test('with a valid X-Forwarded-Proto https and env variable', () => { + process.env.VERDACCIO_PUBLIC_URL = 'https://env.domain.com/'; + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: 'some.com', + [HEADERS.FORWARDED_PROTO]: 'https', + }, + url: '/', + }); + + expect(getPublicUrl(undefined, req)).toEqual('https://env.domain.com/'); + delete process.env.VERDACCIO_PUBLIC_URL; + }); + + test('with a valid X-Forwarded-Proto https and env variable with prefix', () => { + process.env.VERDACCIO_PUBLIC_URL = 'https://env.domain.com/urlPrefix/'; + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: 'some.com', + [HEADERS.FORWARDED_PROTO]: 'http', + }, + url: '/', + }); + + expect(getPublicUrl(undefined, req)).toEqual('https://env.domain.com/urlPrefix/'); + delete process.env.VERDACCIO_PUBLIC_URL; + }); + + test('with a valid X-Forwarded-Proto https and env variable with prefix as url prefix', () => { + process.env.VERDACCIO_PUBLIC_URL = 'https://env.domain.com/urlPrefix/'; + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: 'some.com', + [HEADERS.FORWARDED_PROTO]: 'https', + }, + url: '/', + }); + + expect(getPublicUrl('conf_url_prefix', req)).toEqual('https://env.domain.com/conf_url_prefix/'); + delete process.env.VERDACCIO_PUBLIC_URL; + }); + + test('with a valid X-Forwarded-Proto https and env variable with prefix as root url prefix', () => { + process.env.VERDACCIO_PUBLIC_URL = 'https://env.domain.com/urlPrefix/'; + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: 'some.com', + [HEADERS.FORWARDED_PROTO]: 'https', + }, + url: '/', + }); + + expect(getPublicUrl('/', req)).toEqual('https://env.domain.com/'); + delete process.env.VERDACCIO_PUBLIC_URL; + }); + + test('with a invalid X-Forwarded-Proto https and env variable', () => { + process.env.VERDACCIO_PUBLIC_URL = 'https://env.domain.com'; + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: 'some.com', + [HEADERS.FORWARDED_PROTO]: 'invalidProtocol', + }, + url: '/', + }); + + expect(getPublicUrl(undefined, req)).toEqual('https://env.domain.com/'); + delete process.env.VERDACCIO_PUBLIC_URL; + }); + + test('with a invalid X-Forwarded-Proto https and invalid url with env variable', () => { + process.env.VERDACCIO_PUBLIC_URL = 'ftp://env.domain.com'; + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: 'some.com', + [HEADERS.FORWARDED_PROTO]: 'invalidProtocol', + }, + url: '/', + }); + + expect(getPublicUrl(undefined, req)).toEqual('http://some.com/'); + delete process.env.VERDACCIO_PUBLIC_URL; + }); + + test('with a invalid X-Forwarded-Proto https and host injection with host', () => { + process.env.VERDACCIO_PUBLIC_URL = 'http://injection.test.com">'; + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: 'some.com', + [HEADERS.FORWARDED_PROTO]: 'invalidProtocol', + }, + url: '/', + }); + + expect(getPublicUrl(undefined, req)).toEqual('http://some.com/'); + delete process.env.VERDACCIO_PUBLIC_URL; + }); + + test('with a invalid X-Forwarded-Proto https and host injection with invalid host', () => { + process.env.VERDACCIO_PUBLIC_URL = 'http://injection.test.com">'; + const req = httpMocks.createRequest({ + method: 'GET', + headers: { + host: 'some', + [HEADERS.FORWARDED_PROTO]: 'invalidProtocol', + }, + url: '/', + }); + + expect(getPublicUrl(undefined, req)).toEqual('http://some/'); + delete process.env.VERDACCIO_PUBLIC_URL; + }); + }); }); diff --git a/test/unit/modules/web/__snapshots__/template.spec.ts.snap b/test/unit/modules/web/__snapshots__/template.spec.ts.snap new file mode 100644 index 000000000..a372acb5e --- /dev/null +++ b/test/unit/modules/web/__snapshots__/template.spec.ts.snap @@ -0,0 +1,151 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`template custom body after 1`] = ` +" + + + + + + + + + + + + + +
+ + + + + + + + + + " +`; + +exports[`template custom render 1`] = ` +" + + + + + + + + + + + + + +
+ + + + + " +`; + +exports[`template custom title 1`] = ` +" + + + + + + foo title + + + + + + + +
+ + + + + " +`; + +exports[`template custom title 2`] = ` +" + + + + + + foo title + + + + + + + +
+ + + + + " +`; + +exports[`template meta scripts 1`] = ` +" + + + + + + + + + + + + + +
+ + + + + " +`; diff --git a/test/unit/modules/web/partials/manifest/manifest.json b/test/unit/modules/web/partials/manifest/manifest.json new file mode 100644 index 000000000..4f936f30e --- /dev/null +++ b/test/unit/modules/web/partials/manifest/manifest.json @@ -0,0 +1,21 @@ +{ + "main.js": "-/static/main.9be80fd172e81558124c.js", + "runtime.js": "-/static/runtime.9be80fd172e81558124c.js", + "NotFound.js": "-/static/NotFound.9be80fd172e81558124c.js", + "Provider.js": "-/static/Provider.9be80fd172e81558124c.js", + "Version.js": "-/static/Version.9be80fd172e81558124c.js", + "Home.js": "-/static/Home.9be80fd172e81558124c.js", + "Versions.js": "-/static/Versions.9be80fd172e81558124c.js", + "UpLinks.js": "-/static/UpLinks.9be80fd172e81558124c.js", + "Dependencies.js": "-/static/Dependencies.9be80fd172e81558124c.js", + "Engines.js": "-/static/Engines.9be80fd172e81558124c.js", + "Dist.js": "-/static/Dist.9be80fd172e81558124c.js", + "Install.js": "-/static/Install.9be80fd172e81558124c.js", + "Repository.js": "-/static/Repository.9be80fd172e81558124c.js", + "vendors.js": "-/static/vendors.9be80fd172e81558124c.js", + "vendors-node_modules_pnpm_material-ui_core_4_11_2_react-dom_16_13_1_react_16_13_1_node_module-2c2376.9be80fd172e81558124c.js": "-/static/vendors-node_modules_pnpm_material-ui_core_4_11_2_react-dom_16_13_1_react_16_13_1_node_module-2c2376.9be80fd172e81558124c.js", + "vendors-node_modules_pnpm_material-ui_core_4_11_2_react-dom_16_13_1_react_16_13_1_node_module-a68215.9be80fd172e81558124c.js": "-/static/vendors-node_modules_pnpm_material-ui_core_4_11_2_react-dom_16_13_1_react_16_13_1_node_module-a68215.9be80fd172e81558124c.js", + "vendors-node_modules_pnpm_material-ui_core_4_11_2_react-dom_16_13_1_react_16_13_1_node_module-3c0585.9be80fd172e81558124c.js": "-/static/vendors-node_modules_pnpm_material-ui_core_4_11_2_react-dom_16_13_1_react_16_13_1_node_module-3c0585.9be80fd172e81558124c.js", + "favicon.ico": "-/static/favicon.ico", + "index.html": "-/static/index.html" +} diff --git a/test/unit/modules/web/template.spec.ts b/test/unit/modules/web/template.spec.ts new file mode 100644 index 000000000..3f3621685 --- /dev/null +++ b/test/unit/modules/web/template.spec.ts @@ -0,0 +1,52 @@ +import renderTemplate from "../../../../src/api/web/html/template"; + +const manifest = require('./partials/manifest/manifest.json'); + +const exampleManifest = { + css: ['main.css'], + js: ['runtime.js', 'main.js'], + ico: '/static/foo.ico', +}; + +describe('template', () => { + test('custom render', () => { + expect(renderTemplate({ options: {base: 'http://domain.com'}, manifest: exampleManifest }, manifest)).toMatchSnapshot(); + }); + + test('custom title', () => { + expect( + renderTemplate({ options: {base: 'http://domain.com', title: 'foo title' }, manifest: exampleManifest }, manifest) + ).toMatchSnapshot(); + }); + + test('custom title', () => { + expect( + renderTemplate({ options: {base: 'http://domain.com', title: 'foo title' }, manifest: exampleManifest }, manifest) + ).toMatchSnapshot(); + }); + + test('meta scripts', () => { + expect( + renderTemplate({ options: {base: 'http://domain.com'}, metaScripts: [``], manifest: exampleManifest }, manifest) + ).toMatchSnapshot(); + }); + + test('custom body after', () => { + expect( + renderTemplate({ options: {base: 'http://domain.com'}, scriptsBodyAfter: [`