diff --git a/.env.example b/.env.example index 1f2fe5f..b836b09 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,20 @@ -HOST=127.0.0.1 # 0.0.0.0 if txtdot is not behind reverse proxy +# Server settings + +HOST=0.0.0.0 PORT=8080 +TIMEOUT=0 # 0 means no timeout +REVERSE_PROXY=false # only with reverse proxy; see docs -TIMEOUT=0 # Connection timout 0 (no timout) - -REVERSE_PROXY=true # only for reverse proxy; see docs +# Features +## Proxy PROXY_RES=true -SWAGGER=false # whether to add API docs route or not -# Search +IMG_COMPRESS=true # enable image compressing; proxy_res is required -SEARCH_ENABLED=false # If enabled then you need > -SEARX_URL="" # Base url of searxng instance without /search, etc +## Documentation +SWAGGER=false # whether to add API docs route + +## Search +SEARCH_ENABLED=false # searx_url is required when enabled +SEARX_URL="" # SearXNG base URL, e.g. https://searx.dc09.ru \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..1812c56 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,19 @@ +name: build-check +on: pull_request +jobs: + build-check: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: Install dependencies + run: npm install + + - name: Start build + run: npm run build diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml index 6ea15f5..1653278 100644 --- a/.github/workflows/format-check.yml +++ b/.github/workflows/format-check.yml @@ -1,7 +1,7 @@ name: format-check on: pull_request jobs: - check-formatting: + format-check: runs-on: ubuntu-latest steps: - name: Checkout code diff --git a/README.md b/README.md index b2fd2a5..d78f3f2 100644 --- a/README.md +++ b/README.md @@ -11,28 +11,32 @@ HTTP proxy that parses only text, links and pictures from pages reducing internet traffic, removing ads and heavy scripts. +Mozilla's Readability library is used under the hood. -Uses [Mozilla's readability.js](https://github.com/mozilla/readability), -[🔗 linkedom](https://github.com/WebReflection/linkedom), -[Fastify web framework](https://github.com/fastify/fastify). +## Features -## Installation - -```bash -npm install -``` +- Server-side page simplification +- Media proxy +- Image compression with Sharp +- Search with SearXNG +- Custom parsers for StackOverflow and SearXNG +- Handy API endpoints +- No client JavaScript +- Some kind of Material Design 3 ## Running -### Dev +### Development ```bash +npm install npm run dev ``` -### Prod +### Production ```bash +npm install npm run build npm run start ``` @@ -40,6 +44,63 @@ npm run start ### Docker ```bash -docker compose build docker compose up -d ``` + +## Screenshots + +
+Main page with URL input field +SearXNG search results page +
+ +## Performance tests + +txtdot is a great tool in case of slow internet connection or weak signal. +Here is the comparision of performance metrics from pagespeed.web.dev +between original page and proxied one. + +"Mobile" test includes "Slow 4G" artificial network throttling. + +
+Expand + +| | Original page | Proxied through txtdot | +| :------------------------------- | :-------------------: | :--------------------: | +| [Habr][habr-link] Desktop | ![56%][habr-do-img] | ![99%][habr-dt-img] | +| [Habr][habr-link] Mobile | ![21%][habr-mo-img] | ![100%][habr-mt-img] | +| [Medium][medium-link] Desktop | ![44%][medium-do-img] | ![100%][medium-dt-img] | +| [Medium][medium-link] Mobile | ![36%][medium-mo-img] | ![100%][medium-mt-img] | +| [Nginx Blog][nginx-link] Desktop | ![53%][nginx-do-img] | ![100%][nginx-dt-img] | +| [Nginx Blog][nginx-link] Mobile | ![26%][nginx-mo-img] | ![100%][nginx-mt-img] | + +[habr-link]: https://habr.com/ru/articles/780692/ +[habr-do-img]: https://raw.githubusercontent.com/TxtDot/.github/main/tests/habr/desktop_orig.png +[habr-dt-img]: https://raw.githubusercontent.com/TxtDot/.github/main/tests/habr/desktop_txtdot.png +[habr-mo-img]: https://raw.githubusercontent.com/TxtDot/.github/main/tests/habr/mobile_orig.png +[habr-mt-img]: https://raw.githubusercontent.com/TxtDot/.github/main/tests/habr/mobile_txtdot.png +[medium-link]: https://levelup.gitconnected.com/proxy-servers-how-proxies-work-0ec083fc1030 +[medium-do-img]: https://raw.githubusercontent.com/TxtDot/.github/main/tests/medium/desktop_orig.png +[medium-dt-img]: https://raw.githubusercontent.com/TxtDot/.github/main/tests/medium/desktop_txtdot.png +[medium-mo-img]: https://raw.githubusercontent.com/TxtDot/.github/main/tests/medium/mobile_orig.png +[medium-mt-img]: https://raw.githubusercontent.com/TxtDot/.github/main/tests/medium/mobile_txtdot.png +[nginx-link]: https://www.nginx.com/blog/rate-limiting-nginx/ +[nginx-do-img]: https://raw.githubusercontent.com/TxtDot/.github/main/tests/nginx-blog/desktop_orig.png +[nginx-dt-img]: https://raw.githubusercontent.com/TxtDot/.github/main/tests/nginx-blog/desktop_txtdot.png +[nginx-mo-img]: https://raw.githubusercontent.com/TxtDot/.github/main/tests/nginx-blog/mobile_orig.png +[nginx-mt-img]: https://raw.githubusercontent.com/TxtDot/.github/main/tests/nginx-blog/mobile_txtdot.png + +
+ +## Credits + +- [Readability.js](https://github.com/mozilla/readability) +- [🔗 LinkeDOM](https://github.com/WebReflection/linkedom) +- [Fastify web framework](https://github.com/fastify/fastify) +- [EJS](https://github.com/mde/ejs) +- [Axios](https://github.com/axios/axios) +- [DOMPurify](https://github.com/cure53/DOMPurify) +- [Sharp](https://github.com/lovell/sharp) +- [MicroMatch](https://github.com/micromatch/micromatch) +- [RouteParser](https://github.com/rcs/route-parser) +- [IconvLite](https://github.com/ashtuchkin/iconv-lite) diff --git a/package-lock.json b/package-lock.json index b32cc33..040e322 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "txtdot", - "version": "1.6.1", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "txtdot", - "version": "1.6.1", + "version": "1.7.0", "license": "MIT", "dependencies": { "@fastify/static": "^6.12.0", @@ -24,14 +24,15 @@ "json-schema-to-ts": "^3.0.0", "linkedom": "^0.16.8", "micromatch": "^4.0.5", - "route-parser": "^0.0.5" + "route-parser": "^0.0.5", + "sharp": "^0.33.2" }, "devDependencies": { "@types/dompurify": "^3.0.5", "@types/ejs": "^3.1.5", "@types/jsdom": "^21.1.6", "@types/micromatch": "^4.0.6", - "@types/node": "^20.11.20", + "@types/node": "^20.11.24", "@types/route-parser": "^0.1.7", "@typescript-eslint/eslint-plugin": "^7.1.0", "@typescript-eslint/parser": "^7.1.0", @@ -63,6 +64,15 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/runtime": { + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.45.0.tgz", + "integrity": "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -313,6 +323,437 @@ "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.2.tgz", + "integrity": "sha512-itHBs1rPmsmGF9p4qRe++CzCgd+kFYktnsoR1sbIAfsRMrJZau0Tt1AH9KVnufc2/tU02Gf6Ibujx+15qRE03w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.2.tgz", + "integrity": "sha512-/rK/69Rrp9x5kaWBjVN07KixZanRr+W1OiyKdXcbjQD6KbW+obaTeBBtLUAtbBsnlTTmWthw99xqoOS7SsySDg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.1" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-kQyrSNd6lmBV7O0BUiyu/OEw9yeNGFbQhbxswS1i6rMDwBBSX+e+rPzu3S+MwAiGU3HdLze3PanQ4Xkfemgzcw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=11", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.1.tgz", + "integrity": "sha512-eVU/JYLPVjhhrd8Tk6gosl5pVlvsqiFlt50wotCvdkFGf+mDNBJxMh+bvav+Wt3EBnNZWq8Sp2I7XfSjm8siog==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=10.13", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.1.tgz", + "integrity": "sha512-FtdMvR4R99FTsD53IA3LxYGghQ82t3yt0ZQ93WMZ2xV3dqrb0E8zq4VHaTOuLEAuA83oDawHV3fd+BsAPadHIQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.1.tgz", + "integrity": "sha512-bnGG+MJjdX70mAQcSLxgeJco11G+MxTz+ebxlz8Y3dxyeb3Nkl7LgLI0mXupoO+u1wRNx/iRj5yHtzA4sde1yA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.1.tgz", + "integrity": "sha512-3+rzfAR1YpMOeA2zZNp+aYEzGNWK4zF3+sdMxuCS3ey9HhDbJ66w6hDSHDMoap32DueFwhhs3vwooAB2MaK4XQ==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.1.tgz", + "integrity": "sha512-3NR1mxFsaSgMMzz1bAnnKbSAI+lHXVTqAHgc1bgzjHuXjo4hlscpUxc0vFSAPKI3yuzdzcZOkq7nDPrP2F8Jgw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.1.tgz", + "integrity": "sha512-5aBRcjHDG/T6jwC3Edl3lP8nl9U2Yo8+oTl5drd1dh9Z1EBfzUKAJFUDTDisDjUwc7N4AjnPGfCA3jl3hY8uDg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.1.tgz", + "integrity": "sha512-dcT7inI9DBFK6ovfeWRe3hG30h51cBAP5JXlZfx6pzc/Mnf9HFCQDLtYf4MCBjxaaTfjCCjkBxcy3XzOAo5txw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.2.tgz", + "integrity": "sha512-Fndk/4Zq3vAc4G/qyfXASbS3HBZbKrlnKZLEJzPLrXoJuipFNNwTes71+Ki1hwYW5lch26niRYoZFAtZVf3EGA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.2.tgz", + "integrity": "sha512-pz0NNo882vVfqJ0yNInuG9YH71smP4gRSdeL09ukC2YLE6ZyZePAlWKEHgAzJGTiOh8Qkaov6mMIMlEhmLdKew==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.2.tgz", + "integrity": "sha512-MBoInDXDppMfhSzbMmOQtGfloVAflS2rP1qPcUIiITMi36Mm5YR7r0ASND99razjQUpHTzjrU1flO76hKvP5RA==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.2.tgz", + "integrity": "sha512-xUT82H5IbXewKkeF5aiooajoO1tQV4PnKfS/OZtb5DDdxS/FCI/uXTVZ35GQ97RZXsycojz/AJ0asoz6p2/H/A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.2.tgz", + "integrity": "sha512-F+0z8JCu/UnMzg8IYW1TMeiViIWBVg7IWP6nE0p5S5EPQxlLd76c8jYemG21X99UzFwgkRo5yz2DS+zbrnxZeA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.2.tgz", + "integrity": "sha512-+ZLE3SQmSL+Fn1gmSaM8uFusW5Y3J9VOf+wMGNnTtJUMUxFhv+P4UPaYEYT8tqnyYVaOVGgMN/zsOxn9pSsO2A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.2.tgz", + "integrity": "sha512-fLbTaESVKuQcpm8ffgBD7jLb/CQLcATju/jxtTXR1XCLwbOQt+OL5zPHSDMmp2JZIeq82e18yE0Vv7zh6+6BfQ==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^0.45.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.2.tgz", + "integrity": "sha512-okBpql96hIGuZ4lN3+nsAjGeggxKm7hIRu9zyec0lnfB8E7Z6p95BuRZzDDXZOl2e8UmR4RhYt631i7mfmKU8g==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.2.tgz", + "integrity": "sha512-E4magOks77DK47FwHUIGH0RYWSgRBfGdK56kIHSVeB9uIS4pPFr4N2kIVsXdQQo4LzOsENKV5KAhRlRL7eMAdg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@lukeed/ms": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", @@ -411,9 +852,9 @@ } }, "node_modules/@types/node": { - "version": "20.11.22", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.22.tgz", - "integrity": "sha512-/G+IxWxma6V3E+pqK1tSl2Fo1kl41pK1yeCyDsgkF9WlVAme4j5ISYM2zR11bgLFJGLN5sVK40T4RJNuiZbEjA==", + "version": "20.11.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", + "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -1034,6 +1475,18 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1050,6 +1503,15 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1244,6 +1706,14 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "engines": { + "node": ">=8" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2141,6 +2611,11 @@ "node": ">= 0.10" } }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3083,6 +3558,45 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/sharp": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.2.tgz", + "integrity": "sha512-WlYOPyyPDiiM07j/UO+E720ju6gtNtHjEGg5vovUk1Lgxyjm2LFO+37Nt/UI3MMh2l6hxTWQWi7qk3cXJTutcQ==", + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "semver": "^7.5.4" + }, + "engines": { + "libvips": ">=8.15.1", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.2", + "@img/sharp-darwin-x64": "0.33.2", + "@img/sharp-libvips-darwin-arm64": "1.0.1", + "@img/sharp-libvips-darwin-x64": "1.0.1", + "@img/sharp-libvips-linux-arm": "1.0.1", + "@img/sharp-libvips-linux-arm64": "1.0.1", + "@img/sharp-libvips-linux-s390x": "1.0.1", + "@img/sharp-libvips-linux-x64": "1.0.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.1", + "@img/sharp-libvips-linuxmusl-x64": "1.0.1", + "@img/sharp-linux-arm": "0.33.2", + "@img/sharp-linux-arm64": "0.33.2", + "@img/sharp-linux-s390x": "0.33.2", + "@img/sharp-linux-x64": "0.33.2", + "@img/sharp-linuxmusl-arm64": "0.33.2", + "@img/sharp-linuxmusl-x64": "0.33.2", + "@img/sharp-wasm32": "0.33.2", + "@img/sharp-win32-ia32": "0.33.2", + "@img/sharp-win32-x64": "0.33.2" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3104,6 +3618,14 @@ "node": ">=8" } }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -3362,6 +3884,12 @@ "typescript": "*" } }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 9b84838..77f3726 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "txtdot", - "version": "1.6.1", + "version": "1.7.0", "private": true, "description": "txtdot is an HTTP proxy that parses only text, links and pictures from pages reducing internet bandwidth usage, removing ads and heavy scripts", "main": "dist/app.js", @@ -20,14 +20,15 @@ "json-schema-to-ts": "^3.0.0", "linkedom": "^0.16.8", "micromatch": "^4.0.5", - "route-parser": "^0.0.5" + "route-parser": "^0.0.5", + "sharp": "^0.33.2" }, "devDependencies": { "@types/dompurify": "^3.0.5", "@types/ejs": "^3.1.5", "@types/jsdom": "^21.1.6", "@types/micromatch": "^4.0.6", - "@types/node": "^20.11.20", + "@types/node": "^20.11.24", "@types/route-parser": "^0.1.7", "@typescript-eslint/eslint-plugin": "^7.1.0", "@typescript-eslint/parser": "^7.1.0", diff --git a/src/app.ts b/src/app.ts index b35d4e2..b483c9f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -18,6 +18,9 @@ import errorHandler from './errors/handler'; import getConfig from './config/main'; import redirectRoute from './routes/browser/redirect'; +import dynConfig from './config/dynamic.config'; +import configurationRoute from './routes/browser/configuration'; + class App { async init() { const config = getConfig(); @@ -42,6 +45,7 @@ class App { }); if (config.swagger) { + dynConfig.addRoute('/doc'); await fastify.register(fastifySwagger, { swagger: { info: { @@ -54,14 +58,16 @@ class App { await fastify.register(fastifySwaggerUi, { routePrefix: '/doc' }); } + fastify.addHook('onRoute', (route) => { + dynConfig.addRoute(route.url); + }); + fastify.register(indexRoute); fastify.register(getRoute); + fastify.register(configurationRoute); - if (config.search.enabled) { - fastify.register(redirectRoute); - } - - if (config.proxy_res) fastify.register(proxyRoute); + config.search.enabled && fastify.register(redirectRoute); + config.proxy.enabled && fastify.register(proxyRoute); fastify.register(parseRoute); fastify.register(rawHtml); diff --git a/src/config/config.service.ts b/src/config/config.service.ts index 144962e..c585a42 100644 --- a/src/config/config.service.ts +++ b/src/config/config.service.ts @@ -5,7 +5,7 @@ export class ConfigService { public readonly port: number; public readonly timeout: number; public readonly reverse_proxy: boolean; - public readonly proxy_res: boolean; + public readonly proxy: ProxyConfig; public readonly swagger: boolean; public readonly search: SearchConfig; @@ -19,7 +19,11 @@ export class ConfigService { this.reverse_proxy = this.parseBool(process.env.REVERSE_PROXY, false); - this.proxy_res = this.parseBool(process.env.PROXY_RES, true); + this.proxy = { + enabled: this.parseBool(process.env.PROXY_RES, true), + img_compress: this.parseBool(process.env.IMG_COMPRESS, true), + }; + this.swagger = this.parseBool(process.env.SWAGGER, false); this.search = { @@ -34,6 +38,11 @@ export class ConfigService { } } +interface ProxyConfig { + enabled: boolean; + img_compress: boolean; +} + interface SearchConfig { enabled: boolean; searx_url?: string; diff --git a/src/config/dynamic.config.ts b/src/config/dynamic.config.ts new file mode 100644 index 0000000..ccddc1d --- /dev/null +++ b/src/config/dynamic.config.ts @@ -0,0 +1,10 @@ +class DynConfigService { + public routes: Set = new Set(); + constructor() {} + addRoute(route: string) { + this.routes.add(route); + } +} + +const config = new DynConfigService(); +export default config; diff --git a/src/errors/handler.ts b/src/errors/handler.ts index f5cf4c3..a7d2c15 100644 --- a/src/errors/handler.ts +++ b/src/errors/handler.ts @@ -55,7 +55,8 @@ function htmlErrorHandler(error: Error, reply: FastifyReply, url: string) { url, code: error.code, description: error.description, - proxyBtn: error instanceof NotHtmlMimetypeError && getConfig().proxy_res, + proxyBtn: + error instanceof NotHtmlMimetypeError && getConfig().proxy.enabled, }); } diff --git a/src/errors/main.ts b/src/errors/main.ts index 62c54bf..420346a 100644 --- a/src/errors/main.ts +++ b/src/errors/main.ts @@ -31,13 +31,23 @@ export class LocalResourceError extends TxtDotError { } } +export class UnsupportedMimetypeError extends TxtDotError { + constructor(expected: string, got?: string) { + super( + 415, + 'UnsupportedMimetypeError', + `Unsupported mimetype, expected ${expected}, got ${got}` + ); + } +} + export class NotHtmlMimetypeError extends TxtDotError { constructor() { super( 421, 'NotHtmlMimetypeError', 'Received non-HTML content, ' + - (getConfig().proxy_res + (getConfig().proxy.enabled ? 'use proxy instead of parser.' : 'proxying is disabled by the instance admin.') ); diff --git a/src/handlers/distributor.ts b/src/handlers/distributor.ts index 5dc7009..040ecc4 100644 --- a/src/handlers/distributor.ts +++ b/src/handlers/distributor.ts @@ -7,9 +7,7 @@ import DOMPurify from 'dompurify'; import { Readable } from 'stream'; -import isLocalResource from '../utils/islocal'; - -import { LocalResourceError, NotHtmlMimetypeError } from '../errors/main'; +import { NotHtmlMimetypeError } from '../errors/main'; import { HandlerInput } from './handler-input'; import { decodeStream, parseEncodingName } from '../utils/http'; import replaceHref from '../utils/replace-href'; @@ -40,10 +38,6 @@ export class Distributor { ): Promise { const urlObj = new URL(remoteUrl); - if (await isLocalResource(urlObj)) { - throw new LocalResourceError(); - } - const response = await axios.get(remoteUrl); const data: Readable = response.data; const mime: string | undefined = @@ -76,6 +70,7 @@ export class Distributor { if (specified) { return this.fallback[this.engines_id[specified]]; } + for (const engine of this.fallback) { if (micromatch.isMatch(host, engine.domains)) { return engine; diff --git a/src/handlers/engine.ts b/src/handlers/engine.ts index 22aea15..197ac0f 100644 --- a/src/handlers/engine.ts +++ b/src/handlers/engine.ts @@ -11,12 +11,14 @@ interface IRoute { export class Engine { name: string; + description: string; domains: string[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any routes: IRoute[] = []; - constructor(name: string, domains: string[] = []) { + constructor(name: string, description: string, domains: string[] = []) { this.domains = domains; this.name = name; + this.description = description; } route( diff --git a/src/handlers/engines/readability.ts b/src/handlers/engines/readability.ts index c52b10b..3ebec2f 100644 --- a/src/handlers/engines/readability.ts +++ b/src/handlers/engines/readability.ts @@ -3,7 +3,11 @@ import { EngineParseError } from '../../errors/main'; import { Engine } from '../engine'; -const ReadabilityEngine = new Engine('Readability'); +const ReadabilityEngine = new Engine( + 'Readability', + 'Engine for parsing content with Readability', + ['*'] +); ReadabilityEngine.route('*path', async (input, ro) => { const reader = new Readability(input.parseDom().window.document); diff --git a/src/handlers/engines/searx.ts b/src/handlers/engines/searx.ts index cf2b7e1..43f7637 100644 --- a/src/handlers/engines/searx.ts +++ b/src/handlers/engines/searx.ts @@ -2,7 +2,9 @@ import { Route } from '../../types/handlers'; import { Engine } from '../engine'; import { HandlerInput } from '../handler-input'; -const SearXEngine = new Engine('SearX', ['searx.*']); +const SearXEngine = new Engine('SearX', "Engine for searching with 'SearXNG'", [ + 'searx.*', +]); async function search( input: HandlerInput, diff --git a/src/handlers/engines/stackoverflow/main.ts b/src/handlers/engines/stackoverflow/main.ts index 43608bf..a72f4da 100644 --- a/src/handlers/engines/stackoverflow/main.ts +++ b/src/handlers/engines/stackoverflow/main.ts @@ -1,16 +1,20 @@ import { Engine } from '../../engine'; import questions from './questions'; import users from './users'; -const soEngine = new Engine('StackOverflow', [ - 'stackoverflow.com', - '*.stackoverflow.com', - '*.stackexchange.com', - 'askubuntu.com', - 'stackapps.com', - 'mathoverflow.net', - 'superuser.com', - 'serverfault.com', -]); +const soEngine = new Engine( + 'StackOverflow', + "Engine for 'StackOverflow'. Available routes: '/questions/' and '/users/'", + [ + 'stackoverflow.com', + '*.stackoverflow.com', + '*.stackexchange.com', + 'askubuntu.com', + 'stackapps.com', + 'mathoverflow.net', + 'superuser.com', + 'serverfault.com', + ] +); soEngine.route('/questions/:id/*slug', questions); soEngine.route('/users/:id/*slug', users); diff --git a/src/handlers/main.ts b/src/handlers/main.ts index 377d613..800738a 100644 --- a/src/handlers/main.ts +++ b/src/handlers/main.ts @@ -5,9 +5,9 @@ import StackOverflow from './engines/stackoverflow/main'; const distributor = new Distributor(); -distributor.engine(Readability); -distributor.engine(SearX); distributor.engine(StackOverflow); +distributor.engine(SearX); +distributor.engine(Readability); export const engineList = distributor.list; export default distributor; diff --git a/src/routes/browser/configuration.ts b/src/routes/browser/configuration.ts new file mode 100644 index 0000000..d5ce938 --- /dev/null +++ b/src/routes/browser/configuration.ts @@ -0,0 +1,19 @@ +import { FastifyInstance } from 'fastify'; + +import packageJSON from '../../package'; +import distributor from '../../handlers/main'; +import { indexSchema } from '../../types/requests/browser'; + +import getConfig from '../../config/main'; +import dynConfig from '../../config/dynamic.config'; + +export default async function configurationRoute(fastify: FastifyInstance) { + fastify.get('/configuration', { schema: indexSchema }, async (_, reply) => { + return reply.view('/templates/configuration.ejs', { + packageJSON, + engines: distributor.fallback, + dynConfig, + config: getConfig(), + }); + }); +} diff --git a/src/routes/browser/proxy.ts b/src/routes/browser/proxy.ts index c49f8ed..100a89c 100644 --- a/src/routes/browser/proxy.ts +++ b/src/routes/browser/proxy.ts @@ -1,6 +1,9 @@ import { FastifyInstance } from 'fastify'; import { IProxySchema, ProxySchema } from '../../types/requests/browser'; import axios from '../../types/axios'; +import sharp from 'sharp'; +import getConfig from '../../config/main'; +import { UnsupportedMimetypeError } from '../../errors/main'; import isLocalResource from '../../utils/islocal'; import { LocalResourceError } from '../../errors/main'; @@ -24,4 +27,49 @@ export default async function proxyRoute(fastify: FastifyInstance) { return reply.send(response.data); } ); + + if (getConfig().proxy.img_compress) + fastify.get( + '/proxy/img', + { schema: ProxySchema }, + async (request, reply) => { + const response = await axios.get(request.query.url, { + responseType: 'arraybuffer', + }); + + const mime: string | undefined = + response.headers['content-type']?.toString(); + + if (!(mime && mime.startsWith('image/'))) { + throw new UnsupportedMimetypeError('image/*', mime); + } + + const clen: number | undefined = parseInt( + response.headers['content-length']?.toString() || '0' + ); + + if (mime.startsWith('image/svg')) { + reply.header('Content-Type', mime); + reply.header('Content-Length', clen); + return reply.send(response.data); + } + + const buffer = await sharp(response.data) + // .grayscale(true) + .toFormat('webp', { + quality: 25, + progressive: true, + optimizeScans: true, + }) + .toBuffer(); + + reply.header('Content-Type', 'image/webp'); + reply.header('Content-Length', buffer.length); + + reply.header('x-original-size', clen); + reply.header('x-bytes-saved', clen - buffer.length); + + return reply.send(buffer); + } + ); } diff --git a/src/types/axios.ts b/src/types/axios.ts index 6e4628b..bf30a3e 100644 --- a/src/types/axios.ts +++ b/src/types/axios.ts @@ -1,9 +1,30 @@ -import axios from 'axios'; +import origAxios from 'axios'; +import { isLocalResource, isLocalResourceURL } from '../utils/islocal'; +import { LocalResourceError } from '../errors/main'; -export default axios.create({ +const axios = origAxios.create({ headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/116.0', }, responseType: 'stream', }); + +axios.interceptors.response.use( + (response) => { + if (isLocalResource(response.request.socket.remoteAddress)) { + throw new LocalResourceError(); + } + + return response; + }, + async (error) => { + if (await isLocalResourceURL(new URL(error.config?.url))) { + throw new LocalResourceError(); + } + + throw error; + } +); + +export default axios; diff --git a/src/utils/generate.ts b/src/utils/generate.ts index f5a3295..3fa58a4 100644 --- a/src/utils/generate.ts +++ b/src/utils/generate.ts @@ -27,12 +27,13 @@ export function generateParserUrl( export function generateProxyUrl( requestUrl: URL, remoteUrl: URL, - href: string + href: string, + subProxy?: string ): string { const realHref = getRealURL(href, remoteUrl); const urlParam = `?url=${encodeURIComponent(realHref.href)}`; - return `${requestUrl.origin}/proxy${urlParam}`; + return `${requestUrl.origin}/proxy${subProxy ? `/${subProxy}` : ''}${urlParam}`; } function getRealURL(href: string, remoteUrl: URL) { diff --git a/src/utils/islocal.ts b/src/utils/islocal.ts index 752cb7f..1d98165 100644 --- a/src/utils/islocal.ts +++ b/src/utils/islocal.ts @@ -1,5 +1,5 @@ -import dns from 'dns'; import ipRangeCheck from 'ip-range-check'; +import dns from 'dns'; const subnets = [ '0.0.0.0/8', @@ -35,7 +35,11 @@ const subnets = [ 'ff00::/8', ]; -export default async function isLocalResource(url: URL): Promise { +export function isLocalResource(addr: string): boolean { + return ipRangeCheck(addr, subnets); +} + +export async function isLocalResourceURL(url: URL): Promise { // Resolve domain name const addr = (await dns.promises.lookup(url.hostname)).address; diff --git a/src/utils/replace-href.ts b/src/utils/replace-href.ts index 522159f..f32ce17 100644 --- a/src/utils/replace-href.ts +++ b/src/utils/replace-href.ts @@ -15,16 +15,27 @@ export default function replaceHref( const proxyUrl = (href: string) => generateProxyUrl(requestUrl, remoteUrl, href); + const imgProxyUrl = (href: string) => + generateProxyUrl(requestUrl, remoteUrl, href, 'img'); + modifyLinks(doc.querySelectorAll('a[href]'), 'href', parserUrl); modifyLinks(doc.querySelectorAll('frame,iframe'), 'src', parserUrl); - if (getConfig().proxy_res) { + const config = getConfig(); + + if (config.proxy.enabled) { modifyLinks( - doc.querySelectorAll('img,image,video,audio,embed,track,source'), + doc.querySelectorAll('video,audio,embed,track,source'), 'src', proxyUrl ); + modifyLinks( + doc.querySelectorAll('img,image'), + 'src', + config.proxy.img_compress ? imgProxyUrl : proxyUrl + ); + modifyLinks(doc.getElementsByTagName('object'), 'data', proxyUrl); const sources = doc.querySelectorAll('source,img'); for (const source of sources) { diff --git a/static/configuration.css b/static/configuration.css new file mode 100644 index 0000000..6982be1 --- /dev/null +++ b/static/configuration.css @@ -0,0 +1,26 @@ +main { + display: flex; + flex-direction: column; + align-items: start; + gap: 0.375rem; +} + +h1 { + width: fit-content; + margin: auto; +} + +h1 > .dot { + color: var(--accent); +} + +.menu { + justify-content: center; +} + +.configuration { + display: flex; + flex-direction: column; + align-items: start; + gap: 0.375rem; +} diff --git a/templates/components/form-main.ejs b/templates/components/form-main.ejs index 2d225ec..04d9569 100644 --- a/templates/components/form-main.ejs +++ b/templates/components/form-main.ejs @@ -1,26 +1,30 @@ <% search = config.search.enabled %> -<% if (search) { %> +<% - +if (search) { + %> + - + - + + <% +} -<% } %> +%>