├── .babelrc ├── .env.example ├── .eslintrc.yml ├── .github └── workflows │ └── integrate.yaml ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── Procfile ├── README.md ├── __db__ └── database.prod.sqlite3 ├── __rest__ ├── .env.example └── main.rest ├── __test__ ├── config │ ├── env.test.js │ └── signale.test.js └── index.test.js ├── app.json ├── jsconfig.json ├── package-lock.json ├── package.json └── src ├── config ├── app.js ├── env.js ├── signale.js └── sqlite.js ├── controllers ├── admin.js └── compress.js ├── index.js ├── middleware ├── auth.js ├── index.js └── params.js ├── services └── database.js ├── utils ├── admin.js └── index.js └── views ├── create-user.ejs └── users.ejs /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": [ 13 | [ 14 | "module-resolver", 15 | { 16 | "root": ["./"], 17 | "alias": { 18 | "@src": "./src", 19 | "@test": "./__test__" 20 | } 21 | } 22 | ], 23 | "@babel/plugin-syntax-optional-chaining" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_KEY=admin:admin 2 | APP_DEFAULT_QUALITY=40 3 | APP_MIN_COMPRESS_LENGTH=1024 4 | DB_FILENAME=database.prod.sqlite3 -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | node: true 4 | es2020: true 5 | mocha: true 6 | extends: "eslint:recommended" 7 | parserOptions: 8 | ecmaVersion: 11 9 | sourceType: module 10 | rules: {} 11 | -------------------------------------------------------------------------------- /.github/workflows/integrate.yaml: -------------------------------------------------------------------------------- 1 | name: Project CI 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | 7 | jobs: 8 | test_pull_request: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 12 15 | - run: npm ci 16 | - run: cp .env.example .env 17 | - run: npm test 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | .nyc_output/ 4 | coverage/ 5 | .env 6 | __rest__/.env 7 | __db__/database.dev.sqlite3 -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Logs 2 | 3 | ## Version 2.0.0 4 | - Update Boilerplate (ES6, Unit Testing, and Env). 5 | - Implement Redis 6 | - Simple Admin Page 7 | 8 | ## Version 2.1.0 9 | - Replace redis -> rethinkdb. 10 | - Using tokens instead of basic authentication. 11 | - New service path, from "/:userId" to "/s/:username/:token" 12 | - Update admin users view 13 | 14 | ## Version 2.2.0 15 | - Add regenerate token function in admin page. 16 | - Make token shorten from 32 character to 6 character. 17 | - Add rethinkdb password 18 | - Update API Route from `admin/user` to `admin/api/user`. 19 | - Add API Route POST: `admin/api/token`. 20 | - Fix typo. 21 | - Fix GZip compression problem behind nginx reserve proxy. 22 | 23 | ## Version 2.2.1 24 | - update(docker): Make rethinkdb accessible only local 25 | - update(admin:controller): User list orderBy created date 26 | - update(admin:web): use VueJS 27 | - fix(typo): jsdoc params 28 | 29 | ## Version v2.2.2 30 | - update(express): move helmet from top-level middleware into admin route only 31 | 32 | ## Version v2.3.0 33 | - replace rethinkdb -> sqlite3 34 | - package(rethinkdb): remove 35 | - package(knex/sqlite3): install 36 | - update(config/rethinkdb): remove 37 | - new(config/sqlite): create configuration for knex sqlite driver 38 | - new(services/database): create database method helpers 39 | - update(database): use sqlite driver 40 | - update(bypassed): log bypassed hash -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Anatoliy Yastreb 4 | Copyright (c) 2020 Ryan Aunur Rassyid 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm run serve -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bandwidth Hero (Refactored) 2 | 3 | [![Repository](https://img.shields.io/badge/github-bandwidth--hero--proxy-green?logo=github&style=flat)](https://github.com/nyancodeid/bandwidth-hero-proxy) 4 | ![License MIT](https://img.shields.io/github/license/nyancodeid/bandwidth-hero-proxy) 5 | ![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen) 6 | ![Version](https://img.shields.io/badge/version-v2.3.0-brightgreen) 7 | [![Issues](https://img.shields.io/github/issues/nyancodeid/bandwidth-hero-proxy)](https://github.com/nyancodeid/bandwidth-hero-proxy/issues) 8 | ![Project CI](https://github.com/nyancodeid/bandwidth-hero-proxy/workflows/Project%20CI/badge.svg) 9 | 10 | Bandwidth Hero is an open-source browser extension which reduces the amount of data consumed when 11 | you browse web pages by compressing all images on the page. It uses 12 | [data compression service](https://github.com/ayastreb/bandwidth-hero-proxy) to convert images to 13 | low-resolution [WebP](https://developers.google.com/speed/webp/) or JPEG images. 14 | 15 | ## How It Works? 16 | 17 | ![Workflow](https://raw.githubusercontent.com/ayastreb/bandwidth-hero/master/how-it-works.png) 18 | 19 | 1. When active, Bandwidth Hero intercepts all images loading requests 20 | 2. It sends each image URL to the data compression service 21 | 3. Compression service downloads the original image 22 | 4. Once image is downloaded it is then converted to low-resolution 23 | [WebP](https://developers.google.com/speed/webp/)/JPEG image. 24 | 5. Compression service returns processed image to the browser 25 | 26 | ## Privacy Consideration 27 | 28 | After installing the extension you need to setup data compression service. 29 | 30 | Please refer to [data compression service docs](https://github.com/nyancodeid/bandwidth-hero-proxy) 31 | for detailed instructions on how to run your own service. 32 | 33 | Once you have your own instance running, click "Configure data compression service" button under 34 | "Compression settings" in the extension popup. 35 | 36 | ## Deploy to Heroku 37 | You can deploy this project to Heroku 38 | 39 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/nyancodeid/bandwidth-hero-proxy) 40 | 41 | ## Installation 42 | First, make sure you have installed the Bandwidth hero extension in your browser of choice. If not, you can install it via the link below: 43 | 44 | [![Get Chrome Web Extension](https://cloudflare-ipfs.com/ipfs/bafkreih36ke7zkef4wfbkb6mrru2tx3i6npihzudqjntvvwnmf5quf6xtq)](https://chrome.google.com/webstore/detail/bandwidth-hero/mmhippoadkhcflebgghophicgldbahdb?hl=en-US) 45 | 46 | [![Get Firefox Addon](https://cloudflare-ipfs.com/ipfs/bafkreib7acf3fqog6ta2yrponpufbmmk3h5jlqfrmlaw3y325bkhd5tj7i)](https://addons.mozilla.org/en-US/firefox/addon/bandwidth-hero/) 47 | 48 | Next, for setting the Data Compression Service you can enter the url of this nodejs app. For example, if this apps is running on `localhost` with port `3000` then you enter the url 49 | 50 | `http://localhost:3000/s/:username/:token` 51 | 52 | Make sure you have created a user to get the access token. Or you can use the demo account below. 53 | 54 | ## Demo Account 55 | Default account for production database (database.prod.sqlite3): 56 | 57 | - username : demo 58 | - email : demo@gmail.com 59 | - password : demo 60 | - token : `67cb14` 61 | 62 | ## Authors 63 | 64 | - [ayastreb](https://github.com/ayastreb) (c) 2016 (Original) - [ayastreb/bandwidth-hero-proxy](https://github.com/ayastreb/bandwidth-hero-proxy) 65 | - [nyancodeid](https://github.com/nyancodeid) (c) 2020-2021 - [nyancodeid/bandwidth-hero-proxy](https://github.com/nyancodeid/bandwidth-hero-proxy) 66 | -------------------------------------------------------------------------------- /__db__/database.prod.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyancodeid/bandwidth-hero-proxy/4386c760afdd8dc2c96af9f7dba300acbc467ef7/__db__/database.prod.sqlite3 -------------------------------------------------------------------------------- /__rest__/.env.example: -------------------------------------------------------------------------------- 1 | BASEURL=http://localhost:3060 2 | 3 | APP_KEY=admin:admin -------------------------------------------------------------------------------- /__rest__/main.rest: -------------------------------------------------------------------------------- 1 | # REST Client 2 | 3 | ### Get all users 4 | GET {{$dotenv BASEURL}}/admin/api/users HTTP/1.1 5 | Accept: text/html 6 | Authorization: Basic {{$dotenv APP_KEY}} 7 | 8 | ### Create new user 9 | POST {{$dotenv BASEURL}}/admin/api/user HTTP/1.1 10 | Content-Type: application/json 11 | Authorization: Basic {{$dotenv APP_KEY}} 12 | 13 | { 14 | "username": "nyancodeid", 15 | "email": "nyancodeid@gmail.com", 16 | "password": "nyancodeid" 17 | } -------------------------------------------------------------------------------- /__test__/config/env.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | 3 | import Env from "@src/config/env.js"; 4 | 5 | describe("Env Config Test", () => { 6 | it("it will be function", () => { 7 | expect(Env.use).to.be.a("function"); 8 | }); 9 | it("it will be get APP_KEY key", () => { 10 | expect(Env.use("APP_KEY")).to.equal("admin:admin"); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /__test__/config/signale.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | 3 | import * as Log from "@src/config/signale.js"; 4 | import { Signale } from "signale"; 5 | 6 | describe("Signale Config Test", () => { 7 | it("it will be instanceOf Signale", () => { 8 | expect(Log.signale).to.be.an.instanceOf(Signale); 9 | }); 10 | it("it will be run logInfo using string", () => { 11 | const expected = `[APP] Simple message log`; 12 | const message = Log.logInfo("APP", "Simple message log"); 13 | 14 | expect(Log.logInfo).to.be.a("function"); 15 | expect(message).to.equal(expected); 16 | }); 17 | it("it will be run logInfo using array of string", () => { 18 | const expected = `[APP][CONFIG] Simple message log from config`; 19 | const message = Log.logInfo( 20 | ["APP", "CONFIG"], 21 | "Simple message log from config" 22 | ); 23 | 24 | expect(Log.logInfo).to.be.a("function"); 25 | expect(message).to.equal(expected); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /__test__/index.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | 3 | describe("Index Test", () => { 4 | it("it will run as expected", () => { 5 | expect(true).to.equal(true); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ny-bandwidth-hero-proxy", 3 | "description": "Data compression service that converts images to low-res WebP or JPEG on the fly. Used in Bandwidth-Hero browser extension. NyanCodeID Version", 4 | "repository": "https://github.com/nyancodeid/bandwidth-hero-proxy", 5 | "logo": "https://cloudflare-ipfs.com/ipfs/bafkreigpqs7x227obuvdcxlk65ghkqh3xw3rvaiemlmowlmkdzvzhatvkq", 6 | "keywords": [ 7 | "proxy", 8 | "compression", 9 | "data saving", 10 | "compress image", 11 | "webp", 12 | "nyandevid", 13 | "nyancodeid" 14 | ], 15 | "env": { 16 | "APP_KEY": { 17 | "description": "Admin Page Credentials.", 18 | "required": false 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "baseUrl": "./", 5 | "paths": { 6 | "@src/*": ["src/*"], 7 | "@test/*": ["__test__/*"] 8 | } 9 | }, 10 | "exclude": ["build"] 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bandwidth-hero-proxy", 3 | "version": "2.3.0", 4 | "description": "Bandwidth Hero Refactored by nyancodeid", 5 | "main": "index.js", 6 | "engines": { 7 | "npm": "7.x" 8 | }, 9 | "scripts": { 10 | "start": "nodemon --exec babel-node ./src/index.js", 11 | "start:server": "npm run build && node ./build/index.js", 12 | "migrate": "babel-node ./src/migrate.js", 13 | "clean": "rm -rf build && mkdir build", 14 | "build": "npm run clean && npm run build:source && cp -r ./src/views ./build/views", 15 | "build:source": "babel -d ./build ./src -s", 16 | "serve": "node ./build/index.js", 17 | "lint": "eslint --ext .js", 18 | "lint:fix": "eslint --fix --ext .js", 19 | "test": "mocha --recursive \"__test__/**/*.test.js\" --require @babel/register", 20 | "cover": "npm run cover:test && npm run cover:report", 21 | "cover:test": "nyc --silent npm run test", 22 | "cover:report": "nyc report --reporter=lcov --reporter=text", 23 | "cover:serve": "concurrently \"serve -l 5577 coverage/lcov-report\" \"open-cli http://localhost:5577/\"" 24 | }, 25 | "dependencies": { 26 | "basic-auth": "^2.0.1", 27 | "bcrypt": "^5.0.0", 28 | "body-parser": "^1.19.0", 29 | "chalk": "^4.1.0", 30 | "compression": "^1.7.4", 31 | "cookie-parser": "^1.4.5", 32 | "csurf": "^1.11.0", 33 | "dotenv": "^8.2.0", 34 | "ejs": "^3.1.5", 35 | "express": "^4.17.1", 36 | "helmet": "^4.1.0", 37 | "knex": "^0.21.21", 38 | "lodash": "^4.17.21", 39 | "md5": "^2.3.0", 40 | "pretty-bytes": "^5.3.0", 41 | "request": "^2.88.2", 42 | "sharp": "^0.28.3", 43 | "signale": "^1.4.0", 44 | "sqlite3": "^5.0.2", 45 | "uuid": "^8.3.2" 46 | }, 47 | "devDependencies": { 48 | "@babel/cli": "^7.10.4", 49 | "@babel/core": "^7.10.4", 50 | "@babel/node": "^7.10.4", 51 | "@babel/plugin-syntax-optional-chaining": "^7.8.3", 52 | "@babel/preset-env": "^7.10.4", 53 | "@babel/register": "^7.10.4", 54 | "babel-plugin-module-resolver": "^4.0.0", 55 | "chai": "^4.2.0", 56 | "concurrently": "^5.3.0", 57 | "eslint": "^7.6.0", 58 | "mocha": "^8.0.1", 59 | "nodemon": "^2.0.4", 60 | "nyc": "^15.1.0", 61 | "open-cli": "^6.0.1", 62 | "serve": "^11.3.2" 63 | }, 64 | "author": "Ryan Aunur Rassyid ", 65 | "license": "MIT" 66 | } -------------------------------------------------------------------------------- /src/config/app.js: -------------------------------------------------------------------------------- 1 | export const WHITELIST_EXTENSION = ["image/svg+xml", "image/gif"]; 2 | export const HELMET_CSP_DIRECTIVES = [ 3 | "'self'", 4 | "'unsafe-inline'", 5 | "'unsafe-eval'", 6 | "unpkg.com", 7 | "v5.getbootstrap.com", 8 | ]; 9 | export const HELMET_CONFIGURATION_OPTIONS = { 10 | contentSecurityPolicy: { 11 | directives: { 12 | defaultSrc: HELMET_CSP_DIRECTIVES, 13 | scriptSrc: HELMET_CSP_DIRECTIVES, 14 | }, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/config/env.js: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | 3 | const Env = { 4 | /** 5 | * Use env variable 6 | * @param {string} name 7 | * @param {string|boolean|number} fallback 8 | */ 9 | use(name, fallback) { 10 | const isBoolean = typeof fallback == "boolean"; 11 | const value = process?.env?.[name] || fallback; 12 | 13 | return isBoolean ? Boolean(value) : value; 14 | }, 15 | }; 16 | 17 | export default Env; 18 | -------------------------------------------------------------------------------- /src/config/signale.js: -------------------------------------------------------------------------------- 1 | import { Signale } from "signale"; 2 | 3 | const options = {}; 4 | 5 | export const signale = new Signale(options); 6 | 7 | /** 8 | * Log info with service name 9 | * @param {string|string[]} services 10 | * @param {string} message 11 | * @return {string} 12 | */ 13 | export const logInfo = (services, message) => { 14 | services = typeof services === "string" ? [services] : services; 15 | 16 | const logName = services.map((service) => `[${service}]`).join(""); 17 | const logMessage = `${logName} ${message}`; 18 | 19 | signale.info(logMessage); 20 | 21 | return logMessage; 22 | }; 23 | -------------------------------------------------------------------------------- /src/config/sqlite.js: -------------------------------------------------------------------------------- 1 | import Knex from "knex"; 2 | import Env from "@src/config/env.js"; 3 | 4 | const DATABASE_NAME = Env.use("DB_FILENAME", "database.prod.sqlite3"); 5 | 6 | export const db = Knex({ 7 | client: "sqlite3", 8 | connection: { 9 | filename: "./__db__/" + DATABASE_NAME, 10 | }, 11 | useNullAsDefault: true, 12 | }); 13 | -------------------------------------------------------------------------------- /src/controllers/admin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('express').Request} Request 3 | * @typedef {import('express').Response} Response 4 | */ 5 | 6 | import bcrypt from "bcrypt"; 7 | import prettyByte from "pretty-bytes"; 8 | 9 | import { signale } from "@src/config/signale.js"; 10 | import { generateToken } from "@src/utils/admin.js"; 11 | import { 12 | createUser as createUserData, 13 | isUsernameAvailable, 14 | regenerateUserToken as regenerateUserTokenData, 15 | getStatisticsWithUser, 16 | } from "@src/services/database.js"; 17 | 18 | const BCRYPT_SALT_ROUNDS = 10; 19 | 20 | /** 21 | * Get all registered users 22 | * @view 23 | * @param {Request} req 24 | * @param {Response} res 25 | */ 26 | export const getAllUser = async (req, res) => { 27 | try { 28 | const [error, results] = await getStatisticsWithUser(); 29 | 30 | const users = results.map((user) => { 31 | return { 32 | username: user.username, 33 | email: user.email, 34 | token: user.token, 35 | stat: { 36 | processed: Number(user.processed).toLocaleString(), 37 | bypassed: Number(user.bypassed).toLocaleString(), 38 | compressed: Number(user.compressed).toLocaleString(), 39 | byte_total: prettyByte(parseInt(user.byte_total)), 40 | byte_save_total: prettyByte(parseInt(user.byte_save_total)), 41 | percentage: 42 | ( 43 | ((parseInt(user.byte_save_total) - parseInt(user.byte_total)) / 44 | parseInt(user.byte_total)) * 45 | 100 + 46 | 100 47 | ).toFixed(0) | 0, 48 | }, 49 | created_at: user.created_at, 50 | updated_at: user.updated_at, 51 | last_login_at: user.last_login_at, 52 | }; 53 | }); 54 | 55 | res.render("users", { 56 | users: Buffer.from(JSON.stringify(users)).toString("base64"), 57 | csrfToken: req.csrfToken(), 58 | }); 59 | } catch (err) { 60 | res.status(500).send("Internal Server Error"); 61 | signale.error(err); 62 | } 63 | }; 64 | 65 | /** 66 | * Create user view form 67 | * @view 68 | * @param {Request} req 69 | * @param {Response} res 70 | */ 71 | export const createUserView = (req, res) => { 72 | res.render("create-user", { csrfToken: req.csrfToken() }); 73 | }; 74 | 75 | /** 76 | * Create user POST Handler 77 | * @rest 78 | * @controller 79 | * @param {Request} req 80 | * @param {Response} res 81 | */ 82 | export const createUser = async (req, res) => { 83 | const { username, password, email } = req.body; 84 | const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS); 85 | 86 | const [_, isAvailable] = await isUsernameAvailable(username); 87 | 88 | if (isAvailable) 89 | return res.json({ 90 | _s: false, 91 | message: `user "${username}" already available!`, 92 | }); 93 | 94 | const [error] = await createUserData({ 95 | username, 96 | email, 97 | password: passwordHash, 98 | }); 99 | 100 | if (error) 101 | return res.status(500).json({ 102 | _s: false, 103 | error, 104 | }); 105 | 106 | return res.status(201).json({ 107 | _s: true, 108 | message: "Created", 109 | }); 110 | }; 111 | 112 | /** 113 | * Regenerate user token POST Handler 114 | * @rest 115 | * @controller 116 | * @param {Request} req 117 | * @param {Response} res 118 | */ 119 | export const regenerateUserToken = async (req, res) => { 120 | const { username, email } = req.body; 121 | 122 | const token = generateToken({ username, length: 6 }); 123 | 124 | const [error] = await regenerateUserTokenData({ username, email, token }); 125 | 126 | if (error) 127 | return res.status(500).json({ 128 | _s: false, 129 | error, 130 | }); 131 | 132 | return res.status(200).json({ 133 | _s: true, 134 | message: "Operation successful", 135 | token, 136 | }); 137 | }; 138 | -------------------------------------------------------------------------------- /src/controllers/compress.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('express').Request} Request 3 | * @typedef {import('express').Response} Response 4 | */ 5 | 6 | import { storeBypassedSite, isSiteBypassed } from "@src/services/database"; 7 | import { 8 | redirect, 9 | copyHeaders, 10 | shouldCompress, 11 | bypass, 12 | compress, 13 | fetchImage, 14 | } from "@src/utils/index.js"; 15 | import md5 from "md5"; 16 | import signale from "signale"; 17 | 18 | /** 19 | * @function 20 | * @param {Request} req 21 | * @param {Response} res 22 | */ 23 | export const controller = async (req, res) => { 24 | const hash = md5(req.params.url); 25 | const [error, isBypassed] = await isSiteBypassed(hash); 26 | 27 | if (error || isBypassed) return redirect(req, res); 28 | 29 | const { action, data } = await fetchImage(req); 30 | 31 | if (action === "REDIRECT") return redirect(req, res); 32 | 33 | copyHeaders(data.origin, res); 34 | 35 | req.params.originType = data.origin.headers["content-type"] || ""; 36 | req.params.originSize = data.buffer.length; 37 | 38 | if (!shouldCompress(req)) return bypass(req, res, data.buffer); 39 | 40 | return compress(req, res, data.buffer); 41 | }; 42 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Env from "@src/config/env.js"; 2 | import { signale } from "@src/config/signale.js"; 3 | 4 | import path from "path"; 5 | import express from "express"; 6 | import csrf from "csurf"; 7 | import helmet from "helmet"; 8 | import cookieParser from "cookie-parser"; 9 | import bodyParser from "body-parser"; 10 | 11 | import { HELMET_CONFIGURATION_OPTIONS } from "@src/config/app.js"; 12 | 13 | import * as middleware from "@src/middleware/index.js"; 14 | import * as compress from "@src/controllers/compress.js"; 15 | import * as admin from "@src/controllers/admin.js"; 16 | 17 | const SERVER_PORT = Env.use("PORT", 3000); 18 | 19 | const app = express(); 20 | const csrfProtection = csrf({ cookie: true }); 21 | const helmetProtection = helmet(HELMET_CONFIGURATION_OPTIONS); 22 | 23 | app.disable("x-powered-by"); 24 | app.enable("trust proxy"); 25 | 26 | app.set("views", path.join(__dirname, "views")); 27 | app.set("view engine", "ejs"); 28 | 29 | app.use(bodyParser.json()); 30 | app.use(bodyParser.urlencoded({ extended: false })); 31 | app.use(cookieParser()); 32 | 33 | app.get( 34 | "/s/:username/:token", 35 | [middleware.authenticate, middleware.params], 36 | compress.controller 37 | ); 38 | 39 | const adminWebMiddleware = [ 40 | middleware.adminAuthenticate, 41 | csrfProtection, 42 | helmetProtection, 43 | ]; 44 | const adminApiMiddleware = [csrfProtection, helmetProtection]; 45 | 46 | app.get("/admin/users", adminWebMiddleware, admin.getAllUser); 47 | app.get("/admin/user", adminWebMiddleware, admin.createUserView); 48 | app.post("/admin/api/user", adminApiMiddleware, admin.createUser); 49 | app.post("/admin/api/token", adminApiMiddleware, admin.regenerateUserToken); 50 | 51 | app.get("/favicon.ico", (req, res) => res.status(204).end()); 52 | 53 | app.listen(SERVER_PORT, () => signale.success(`Listening on :${SERVER_PORT}`)); 54 | -------------------------------------------------------------------------------- /src/middleware/auth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('express').Request} Request 3 | * @typedef {import('express').Response} Response 4 | * @typedef {import('express').NextFunction} NextFunction 5 | */ 6 | 7 | import auth from "basic-auth"; 8 | 9 | import Env from "@src/config/env.js"; 10 | import { accessDenied } from "@src/utils/index.js"; 11 | import { findUser } from "@src/services/database"; 12 | 13 | /** 14 | * @middleware 15 | * @param {Request} req 16 | * @param {Response} res 17 | * @param {NextFunction} next 18 | */ 19 | export const authenticate = async (req, res, next) => { 20 | const { username, token } = req.params; 21 | 22 | if (!username) return accessDenied(res, false); 23 | 24 | const [error, user] = await findUser({ username, token }); 25 | 26 | if (error || !user) return accessDenied(res, false); 27 | 28 | req.userId = user.id; 29 | req.user = user; 30 | 31 | next(); 32 | }; 33 | 34 | /** 35 | * @middleware 36 | * @param {Request} req 37 | * @param {Response} res 38 | * @param {NextFunction} next 39 | */ 40 | export const adminAuthenticate = (req, res, next) => { 41 | const adminCredential = Env.use("APP_KEY", "admin:admin"); 42 | const [username, password] = adminCredential.split(":"); 43 | 44 | const credentials = auth(req); 45 | if ( 46 | !credentials || 47 | credentials.name !== username || 48 | credentials.pass !== password 49 | ) { 50 | return accessDenied(res); 51 | } 52 | 53 | req.user = { username, password }; 54 | 55 | next(); 56 | }; 57 | -------------------------------------------------------------------------------- /src/middleware/index.js: -------------------------------------------------------------------------------- 1 | export { authenticate, adminAuthenticate } from "@src/middleware/auth.js"; 2 | export { params } from "@src/middleware/params.js"; 3 | -------------------------------------------------------------------------------- /src/middleware/params.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('express').Request} Request 3 | * @typedef {import('express').Response} Response 4 | * @typedef {import('express').NextFunction} NextFunction 5 | */ 6 | 7 | import Env from "@src/config/env.js"; 8 | import { updateUserLastLogin } from "@src/services/database"; 9 | 10 | /** 11 | * @middleware 12 | * @param {Request} req 13 | * @param {Response} res 14 | * @param {NextFunction} next 15 | */ 16 | export const params = async (req, res, next) => { 17 | let url = req.query?.url; 18 | if (Array.isArray(url)) { 19 | url = url.join("&url="); 20 | } 21 | 22 | /** 23 | * When url goes empty or undefined it's marked as service-discover 24 | * return string "bandwidth-hero-proxy". 25 | */ 26 | if (!url) { 27 | await updateUserLastLogin(req.userId); 28 | 29 | return res.end("bandwidth-hero-proxy"); 30 | } 31 | 32 | url = url.replace(/http:\/\/1\.1\.\d\.\d\/bmi\/(https?:\/\/)?/i, "http://"); 33 | req.params.url = url; 34 | req.params.webp = !req.query?.jpeg; 35 | req.params.grayscale = req.query?.bw != 0; 36 | req.params.quality = 37 | Number(req.query?.l) || Number(Env.use("APP_DEFAULT_QUALITY", 40)); 38 | 39 | next(); 40 | }; 41 | -------------------------------------------------------------------------------- /src/services/database.js: -------------------------------------------------------------------------------- 1 | import { db } from "@src/config/sqlite"; 2 | import { generateToken, generateUserId } from "@src/utils/admin.js"; 3 | 4 | /** 5 | * @typedef {Promise<[?Error, (?Object.|?Object.[])]>} TWrapper 6 | */ 7 | 8 | /** 9 | * Find user with where parameters 10 | * @param {Object} where 11 | * @returns 12 | */ 13 | export const findUser = (where) => { 14 | return wrapper(() => db.table("users").where(where).first()); 15 | }; 16 | 17 | /** 18 | * Check is username available to use 19 | * @param {String} username 20 | * @returns 21 | */ 22 | export const isUsernameAvailable = (username) => { 23 | return wrapper(() => 24 | db.table("users").select("id").where({ username }).first() 25 | ); 26 | }; 27 | 28 | /** 29 | * Get user data 30 | * @param {String} id uuid 31 | * @returns 32 | */ 33 | export const getUser = (id) => { 34 | return wrapper(() => db.table("users").where({ id }).first()); 35 | }; 36 | 37 | /** 38 | * Create user data and initialize statistics 39 | * @param {Object} param username, email, password 40 | * @returns 41 | */ 42 | export const createUser = ({ username, email, password }) => { 43 | const userId = generateUserId(); 44 | const token = generateToken({ username, length: 6 }); 45 | const tasks = db 46 | .table("users") 47 | .insert({ 48 | id: userId, 49 | username, 50 | email, 51 | password, 52 | token, 53 | created_at: Date.now(), 54 | updated_at: Date.now(), 55 | last_login_at: Date.now(), 56 | }) 57 | .then(() => initializeUserStatistics(userId)); 58 | 59 | return wrapper(() => tasks); 60 | }; 61 | 62 | /** 63 | * Regenerate user token and store it in database 64 | * @param {Object} param username, email, token 65 | * @returns 66 | */ 67 | export const regenerateUserToken = ({ username, email, token }) => { 68 | return wrapper(() => db.table("users").where({ username, email })).update({ 69 | token, 70 | updated_at: Date.now(), 71 | }); 72 | }; 73 | 74 | /** 75 | * Update user last login timestamp in database 76 | * @param {String} userId 77 | * @returns 78 | */ 79 | export const updateUserLastLogin = (userId) => { 80 | return wrapper(() => 81 | db 82 | .table("users") 83 | .where({ user_id: userId }) 84 | .update({ last_login_at: Date.now() }) 85 | ); 86 | }; 87 | 88 | /** 89 | * Get all statistics for users 90 | * @returns 91 | */ 92 | export const getStatisticsWithUser = () => { 93 | return wrapper(() => 94 | db 95 | .table("statistics") 96 | .join("users", "users.id", "=", "statistics.user_id") 97 | .orderBy("created_at") 98 | ); 99 | }; 100 | 101 | /** 102 | * Find statistics data using where operator 103 | * @param {Object} where 104 | * @returns 105 | */ 106 | export const findStatistics = (where) => { 107 | return wrapper(() => db.table("statistics").where(where).limit(1).first()); 108 | }; 109 | 110 | /** 111 | * Update statistics data with id 112 | * @param {Number} id 113 | * @param {Object.} data 114 | * @returns 115 | */ 116 | export const updateStatisticById = (id, data) => { 117 | return wrapper(() => db.table("statistics").where({ id }).update(data)); 118 | }; 119 | /** 120 | * Initialize user statistics 121 | * @param {String} userId 122 | * @returns 123 | */ 124 | export const initializeUserStatistics = (userId) => { 125 | return wrapper(() => 126 | db.table("statistics").insert({ 127 | user_id: userId, 128 | processed: 0, 129 | bypassed: 0, 130 | compressed: 0, 131 | byte_total: 0, 132 | byte_save_total: 0, 133 | updated_at: Date.now(), 134 | }) 135 | ); 136 | }; 137 | 138 | export const storeBypassedSite = (hash) => { 139 | return wrapper(() => db.table("bypassed").insert({ hash, hit: 0 })); 140 | }; 141 | 142 | export const isSiteBypassed = (hash) => { 143 | const tasks = db 144 | .table("bypassed") 145 | .where({ hash }) 146 | .first() 147 | .then(async (site) => { 148 | if (site) { 149 | await db 150 | .table("bypassed") 151 | .where({ id: site.id }) 152 | .update({ 153 | hit: parseInt(site.hit) + 1, 154 | }); 155 | 156 | return true; 157 | } 158 | 159 | return false; 160 | }); 161 | 162 | return wrapper(() => tasks); 163 | }; 164 | 165 | /** 166 | * 167 | * @param {Promise} process 168 | * @returns {TWrapper} 169 | */ 170 | const wrapper = async (process) => { 171 | try { 172 | const result = await process(); 173 | 174 | return [null, result]; 175 | } catch (error) { 176 | return [error, null]; 177 | } 178 | }; 179 | -------------------------------------------------------------------------------- /src/utils/admin.js: -------------------------------------------------------------------------------- 1 | import md5 from "md5"; 2 | import { v4 as uuid } from "uuid"; 3 | 4 | /** 5 | * Generate user token 6 | * @param {Object} data 7 | * @param {string} data.username 8 | * @param {number} data.length 9 | * @return {string} 10 | */ 11 | export const generateToken = ({ username, length }) => { 12 | const raw = `${username}${Date.now()}`; 13 | 14 | return md5(raw).toString().slice(0, length); 15 | }; 16 | 17 | export const generateUserId = () => { 18 | return uuid(); 19 | }; 20 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('express').Request} Request 3 | * @typedef {import('express').Response} Response 4 | */ 5 | 6 | import sharp from "sharp"; 7 | import prettyByte from "pretty-bytes"; 8 | import chalk from "chalk"; 9 | import request from "request"; 10 | import pick from "lodash/pick"; 11 | 12 | import Env from "@src/config/env.js"; 13 | import { signale } from "@src/config/signale.js"; 14 | import { 15 | findStatistics, 16 | storeBypassedSite, 17 | updateStatisticById, 18 | } from "@src/services/database.js"; 19 | import { WHITELIST_EXTENSION } from "@src/config/app.js"; 20 | import md5 from "md5"; 21 | 22 | const MIN_COMPRESS_LENGTH = Env.use("APP_MIN_COMPRESS_LENGTH", 1024); 23 | const MIN_TRANSPARENT_COMPRESS_LENGTH = MIN_COMPRESS_LENGTH * 100; 24 | 25 | export const fetchImage = (req) => { 26 | return new Promise((resolve) => { 27 | request.get( 28 | req.params.url, 29 | { 30 | headers: { 31 | ...pick(req.headers, ["cookie", "dnt", "referer"]), 32 | "user-agent": "Bandwidth-Hero Compressor", 33 | "x-forwarded-for": req.headers["x-forwarded-for"] || req.ip, 34 | via: "1.1 bandwidth-hero", 35 | }, 36 | timeout: 10000, 37 | maxRedirects: 5, 38 | encoding: null, 39 | strictSSL: false, 40 | gzip: true, 41 | jar: true, 42 | }, 43 | (err, origin, buffer) => { 44 | if (err || origin.statusCode >= 400) { 45 | return resolve({ action: "REDIRECT", data: {} }); 46 | } 47 | 48 | return resolve({ action: "NEXT", data: { origin, buffer } }); 49 | } 50 | ); 51 | }); 52 | }; 53 | 54 | /** 55 | * @function 56 | * @param {Object} source 57 | * @param {Response} res 58 | */ 59 | export const copyHeaders = (source, res) => { 60 | for (const [key, value] of Object.entries(source.headers)) { 61 | try { 62 | res.setHeader(key, value); 63 | } catch (e) { 64 | signale.error(e.message); 65 | } 66 | } 67 | }; 68 | 69 | /** 70 | * 71 | * @param {Request} req 72 | * @return {Boolean} 73 | */ 74 | export const shouldCompress = (req) => { 75 | const { originType, originSize, webp } = req.params; 76 | const whiteListCheck = WHITELIST_EXTENSION.filter((ext) => { 77 | return originType.startsWith(ext) || originType.includes(ext); 78 | }); 79 | 80 | if (!originType.startsWith("image")) return false; 81 | if (whiteListCheck.length > 0) return false; 82 | 83 | if (originSize === 0) return false; 84 | if (webp && originSize < MIN_COMPRESS_LENGTH) return false; 85 | if ( 86 | !webp && 87 | (originType.endsWith("png") || originType.endsWith("gif")) && 88 | originSize < MIN_TRANSPARENT_COMPRESS_LENGTH 89 | ) { 90 | return false; 91 | } 92 | 93 | return true; 94 | }; 95 | 96 | /** 97 | * @function 98 | * @param {Request} req 99 | * @param {Response} res 100 | * @param {Buffer} buffer 101 | */ 102 | export const compress = (req, res, buffer) => { 103 | const format = req.params.webp ? "webp" : "jpeg"; 104 | const host = new URL(req.params.url); 105 | 106 | const options = { 107 | webp: { 108 | lossless: req.params.quality == 80, 109 | }, 110 | jpeg: { 111 | progressive: true, 112 | optimizeScans: true, 113 | }, 114 | }; 115 | 116 | const formatOption = Object.assign( 117 | { 118 | quality: req.params.quality, 119 | }, 120 | options[format] 121 | ); 122 | 123 | sharp(buffer) 124 | .grayscale(req.params.grayscale) 125 | .toFormat(format, formatOption) 126 | .toBuffer({ 127 | resolveWithObject: true, 128 | }) 129 | .then(({ data: output, info }) => { 130 | if (!info || res.headersSent) return redirect(req, res); 131 | 132 | const saved = req.params.originSize - info.size; 133 | const percentage = 134 | ((info.size - req.params.originSize) / req.params.originSize) * 100; 135 | 136 | if (saved < 1) return bypass(req, res, buffer); 137 | 138 | const percentageText = 139 | percentage > 0 140 | ? chalk.red(percentage.toFixed(1) + "%") 141 | : chalk.green(percentage.toFixed(1) + "%"); 142 | 143 | signale.info( 144 | `[${host.hostname}]${ 145 | req.params.grayscale ? "[BW]" : "" 146 | } Compression successfully CHANGE:[${chalk.yellow( 147 | prettyByte(req.params.originSize) 148 | )} -> ${chalk.yellow(prettyByte(info.size))}] SAVE:[${chalk.green( 149 | prettyByte(saved) 150 | )}] PERC:[${percentageText}]` 151 | ); 152 | 153 | const userId = req.userId; 154 | 155 | incrementState({ 156 | userId, 157 | byte: req.params.originSize, 158 | saveByte: saved, 159 | status: "compressed", 160 | }).catch((error) => { 161 | signale.error(`[INC#ERR][STATE][${userId}] Error while update state`); 162 | signale.error(error); 163 | }); 164 | 165 | res.setHeader("content-type", `image/${format}`); 166 | res.setHeader("content-length", info.size); 167 | res.setHeader("x-original-size", req.params.originSize); 168 | res.setHeader("x-bytes-saved", saved); 169 | res.status(200); 170 | res.write(output); 171 | res.end(); 172 | }) 173 | .catch((error) => { 174 | return redirect(req, res, buffer); 175 | }); 176 | }; 177 | 178 | /** 179 | * @function 180 | * @param {Request} req 181 | * @param {Response} res 182 | * @param {Buffer} buffer 183 | */ 184 | export const bypass = (req, res, buffer) => { 185 | const host = new URL(req.params.url); 186 | 187 | signale.info( 188 | `[${host.hostname}] Compression bypassed CHANGE:[${chalk.yellow( 189 | prettyByte(req.params.originSize) 190 | )}]` 191 | ); 192 | 193 | const userId = req.userId; 194 | 195 | incrementState({ 196 | userId, 197 | byte: buffer.length, 198 | saveByte: 0, 199 | status: "bypass", 200 | }).catch((error) => { 201 | signale.error(`[INC#ERR][STATE][${userId}] Error while update state`); 202 | signale.error(error); 203 | }); 204 | 205 | storeBypassedSite(md5(req.params.url)).catch(() => { 206 | signale.error(`[BYPS#ERR][STATE][${hash}] Error while update state`); 207 | }); 208 | 209 | res.setHeader("x-proxy-bypass", 1); 210 | res.setHeader("content-length", buffer.length); 211 | res.status(200); 212 | res.write(buffer); 213 | res.end(); 214 | }; 215 | 216 | /** 217 | * @function 218 | * @param {Object} data 219 | * @param {string} data.userId 220 | * @param {number} data.byte 221 | * @param {number} data.saveByte 222 | * @param {string} data.status 223 | * @param {RConnection} connection 224 | * @return {Promise} 225 | */ 226 | export const incrementState = async ({ userId, byte, saveByte, status }) => { 227 | try { 228 | const [error, stat] = await findStatistics({ 229 | user_id: userId, 230 | }); 231 | 232 | if (error) return; 233 | 234 | const update = { 235 | processed: stat.processed + 1, 236 | bypassed: status == "bypass" ? stat.bypassed + 1 : stat.bypassed, 237 | compressed: 238 | status == "compressed" ? stat.compressed + 1 : stat.compressed, 239 | byte_total: stat.byte_total + byte, 240 | byte_save_total: stat.byte_save_total + saveByte, 241 | updated_at: Date.now(), 242 | }; 243 | 244 | return updateStatisticById(stat.id, update); 245 | } catch (error) { 246 | signale.error(error); 247 | } 248 | }; 249 | 250 | /** 251 | * @function 252 | * @param {Request} req 253 | * @param {Response} res 254 | */ 255 | export const redirect = (req, res) => { 256 | const host = new URL(req.params.url); 257 | 258 | if (res.headersSent) return; 259 | 260 | storeBypassedSite(md5(req.params.url)).catch(() => { 261 | signale.error(`[BYPS#ERR][STATE][${hash}] Error while update state`); 262 | }); 263 | 264 | res.setHeader("content-length", 0); 265 | res.removeHeader("cache-control"); 266 | res.removeHeader("expires"); 267 | res.removeHeader("date"); 268 | res.removeHeader("etag"); 269 | res.setHeader("location", encodeURI(req.params.url)); 270 | res.status(302).end(); 271 | }; 272 | 273 | /** 274 | * @function 275 | * @param {Response} res 276 | */ 277 | export const accessDenied = (res, basic = true) => { 278 | if (basic) 279 | res.setHeader( 280 | "WWW-Authenticate", 281 | `Basic realm="Bandwidth-Hero Compression Service"` 282 | ); 283 | 284 | return res.status(401).end("Access denied"); 285 | }; 286 | -------------------------------------------------------------------------------- /src/views/create-user.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Create User 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |

Create User

19 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | 28 |
29 | We'll never share your email with anyone else. 30 |
31 |
32 |
33 | 34 | 35 |
36 | 37 |
38 |
39 | 40 | 41 | 42 | 43 | 44 | 45 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /src/views/users.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Users 10 | 11 | 12 | 13 | 14 | 15 | 20 | 21 | 22 | 23 |
24 |

Table of Users

25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 61 | 66 | 67 | 68 | 69 | 72 | 75 | 78 | 79 | 80 |
#UsernameImage ProcessedTotal ByteTotal Byte SavedPercentageCreated AtRegenerate Token AtLast Login At
{{ index+1 }} 44 | {{ user.username }} 45 |
46 | {{ user.email }} 47 |
48 |
49 | {{ user.token }} 50 | 52 | 54 | 55 | 57 | 58 | 59 |
60 |
62 | {{ user.stat.processed }} 63 | Compressed: {{ user.stat.compressed }} 64 | Bypassed: {{ user.stat.bypassed }} 65 | {{ user.stat.byte_total }}{{ user.stat.byte_save_total }}{{ user.stat.percentage }}%{{ (user.created_at) | dateFormat }} 70 | {{ (user.created_at) | timeFormat }} 71 | {{ (user.updated_at) | dateFormat}} 73 | {{ (user.updated_at) | timeFormat }} 74 | {{ (user.last_login_at) | dateFormat }} 76 | {{ (user.last_login_at) | timeFormat }} 77 |
81 |
82 |
83 | 84 | 85 | 86 | 87 | 88 | 157 | 158 | 159 | --------------------------------------------------------------------------------