Pouria Ezzati преди 1 година
родител
ревизия
8fe106c2d6
променени са 81 файла, в които са добавени 3462 реда и са изтрити 2288 реда
  1. 0 5
      .eslintignore
  2. 0 26
      .eslintrc
  3. 0 4
      .husky/pre-commit
  4. 0 8
      .prettierrc
  5. 1 1
      knexfile.js
  6. 0 6
      nodemon.json
  7. 366 374
      package-lock.json
  8. 5 22
      package.json
  9. 10 5
      server/config/winston.js
  10. 4 6
      server/env.js
  11. 184 120
      server/handlers/auth.js
  12. 1 1
      server/handlers/domains.ts
  13. 87 0
      server/handlers/helpers.js
  14. 0 73
      server/handlers/helpers.ts
  15. 403 0
      server/handlers/links.js
  16. 0 396
      server/handlers/links.ts
  17. 1 1
      server/handlers/users.js
  18. 471 0
      server/handlers/validators.js
  19. 0 480
      server/handlers/validators.ts
  20. 3 3
      server/knex.js
  21. 1 0
      server/mail/index.js
  22. 0 1
      server/mail/index.ts
  23. 20 16
      server/mail/mail.js
  24. 3 4
      server/mail/text.js
  25. 8 4
      server/migrations/20200211220920_constraints.js
  26. 8 4
      server/migrations/20200510140704_domains.js
  27. 7 4
      server/migrations/20200718124944_description.js
  28. 7 4
      server/migrations/20200730203154_expire_in.js
  29. 8 4
      server/migrations/20200810195255_change_email.js
  30. 5 3
      server/models/domain.js
  31. 5 3
      server/models/host.js
  32. 8 0
      server/models/index.js
  33. 0 6
      server/models/index.ts
  34. 5 3
      server/models/ip.js
  35. 5 3
      server/models/link.js
  36. 5 3
      server/models/user.js
  37. 5 3
      server/models/visit.js
  38. 7 7
      server/passport.js
  39. 76 0
      server/queries/domain.js
  40. 0 84
      server/queries/domain.ts
  41. 15 0
      server/queries/index.js
  42. 0 17
      server/queries/index.ts
  43. 53 0
      server/queries/ip.js
  44. 0 47
      server/queries/ip.ts
  45. 71 81
      server/queries/link.js
  46. 40 37
      server/queries/user.js
  47. 1 1
      server/queries/visit.ts
  48. 1 1
      server/queues/visit.ts
  49. 20 16
      server/redis.js
  50. 1 0
      server/renders/index.js
  51. 18 0
      server/renders/renders.js
  52. 52 0
      server/routes/auth.js
  53. 0 52
      server/routes/auth.ts
  54. 2 2
      server/routes/domains.ts
  55. 1 0
      server/routes/index.js
  56. 0 1
      server/routes/index.ts
  57. 83 0
      server/routes/links.js
  58. 0 83
      server/routes/links.ts
  59. 17 0
      server/routes/routes.js
  60. 0 17
      server/routes/routes.ts
  61. 1 1
      server/routes/users.ts
  62. 81 0
      server/server.js
  63. 0 74
      server/server.ts
  64. 1 0
      server/utils/index.js
  65. 0 170
      server/utils/index.ts
  66. 233 0
      server/utils/utils.js
  67. 81 0
      server/views/homepage.hbs
  68. 62 0
      server/views/layout.hbs
  69. 3 0
      server/views/login.hbs
  70. 9 0
      server/views/partials/footer.hbs
  71. 40 0
      server/views/partials/header.hbs
  72. 50 0
      server/views/partials/login_signup.hbs
  73. 5 0
      server/views/partials/login_welcome.hbs
  74. 7 0
      server/views/partials/shorturl.hbs
  75. 5 0
      server/views/partials/signup_verify_email.hbs
  76. 768 0
      static/css/styles.css
  77. BIN
      static/fonts/nunito-variable.woff2
  78. BIN
      static/images/callout.png
  79. 0 0
      static/libs/htmx.min.js
  80. 21 0
      static/scripts/main.js
  81. 1 1
      tsconfig.json

+ 0 - 5
.eslintignore

@@ -1,5 +0,0 @@
-.next/
-flow-typed/
-node_modules/
-client/**/__test__/
-production-server

+ 0 - 26
.eslintrc

@@ -1,26 +0,0 @@
-{
-  "extends": [
-    "next/core-web-vitals",
-    "plugin:@typescript-eslint/recommended",
-    "prettier"
-  ],
-  "parserOptions": {
-    "ecmaVersion": "latest",
-    "sourceType": "module",
-    "project": ["./tsconfig.json", "./client/tsconfig.json"]
-  },
-  "plugins": ["@typescript-eslint", "prettier"],
-  "rules": {
-    "@typescript-eslint/no-explicit-any": ["off"]
-  },
-  "env": {
-    "es6": true,
-    "browser": true,
-    "node": true
-  },
-  "settings": {
-    "react": {
-      "version": "detect"
-    }
-  }
-}

+ 0 - 4
.husky/pre-commit

@@ -1,4 +0,0 @@
-#!/usr/bin/env sh
-. "$(dirname -- "$0")/_/husky.sh"
-
-npm run lint:nofix

+ 0 - 8
.prettierrc

@@ -1,8 +0,0 @@
-{
-  "useTabs": false,
-  "tabWidth": 2,
-  "trailingComma": "none",
-  "singleQuote": false,
-  "printWidth": 80,
-  "endOfLine": "lf"
-}

+ 1 - 1
knexfile.ts → knexfile.js

@@ -1,4 +1,4 @@
-import env from "./server/env";
+const env = require("./server/env");
 
 
 module.exports = {
 module.exports = {
   production: {
   production: {

+ 0 - 6
nodemon.json

@@ -1,6 +0,0 @@
-{
-  "watch": ["server/**/*.ts"],
-  "execMap": {
-    "ts": "rimraf production-server && tsc --project tsconfig.json && copyfiles -f \"server/mail/*.html\" production-server/mail && node production-server/server.js"
-  }
-}

Файловите разлики са ограничени, защото са твърде много
+ 366 - 374
package-lock.json


+ 5 - 22
package.json

@@ -7,15 +7,13 @@
     "test": "jest --passWithNoTests",
     "test": "jest --passWithNoTests",
     "docker:build": "docker build -t kutt .",
     "docker:build": "docker build -t kutt .",
     "docker:run": "docker run -p 3000:3000 --env-file .env -d kutt:latest",
     "docker:run": "docker run -p 3000:3000 --env-file .env -d kutt:latest",
-    "dev": "npm run migrate && cross-env NODE_ENV=development nodemon server/server.ts",
+    "dev": "node --watch server/server.js",
+    "dev:backup": "npm run migrate && cross-env NODE_ENV=development nodemon server/server.ts",
     "build": "rimraf production-server && tsc --project tsconfig.json && copyfiles -f \"server/mail/*.html\" production-server/mail && next build client/ ",
     "build": "rimraf production-server && tsc --project tsconfig.json && copyfiles -f \"server/mail/*.html\" production-server/mail && next build client/ ",
     "start": "npm run migrate && cross-env NODE_ENV=production node production-server/server.js",
     "start": "npm run migrate && cross-env NODE_ENV=production node production-server/server.js",
     "migrate": "knex migrate:latest --env production",
     "migrate": "knex migrate:latest --env production",
     "migrate:make": "knex migrate:make --env production",
     "migrate:make": "knex migrate:make --env production",
-    "lint": "eslint server/ --ext .js,.ts --fix",
-    "lint:nofix": "eslint server/ --ext .js,.ts",
-    "docs:build": "cd docs/api && tsc generate.ts --resolveJsonModule && node generate && cd ../..",
-    "prepare": "husky install"
+    "docs:build": "cd docs/api && tsc generate.ts --resolveJsonModule && node generate && cd ../.."
   },
   },
   "repository": {
   "repository": {
     "type": "git",
     "type": "git",
@@ -48,6 +46,7 @@
     "express-async-handler": "1.1.4",
     "express-async-handler": "1.1.4",
     "express-validator": "^6.14.2",
     "express-validator": "^6.14.2",
     "geoip-lite": "^1.4.6",
     "geoip-lite": "^1.4.6",
+    "hbs": "^4.2.0",
     "helmet": "^6.0.0",
     "helmet": "^6.0.0",
     "ioredis": "^5.2.4",
     "ioredis": "^5.2.4",
     "isbot": "^3.6.3",
     "isbot": "^3.6.3",
@@ -58,7 +57,6 @@
     "morgan": "^1.10.0",
     "morgan": "^1.10.0",
     "ms": "^2.1.3",
     "ms": "^2.1.3",
     "nanoid": "^2.1.11",
     "nanoid": "^2.1.11",
-    "next": "^12.3.3",
     "node-cron": "^3.0.2",
     "node-cron": "^3.0.2",
     "nodemailer": "^6.8.0",
     "nodemailer": "^6.8.0",
     "p-queue": "^7.3.0",
     "p-queue": "^7.3.0",
@@ -70,13 +68,6 @@
     "pg-query-stream": "^4.2.4",
     "pg-query-stream": "^4.2.4",
     "qrcode.react": "^3.1.0",
     "qrcode.react": "^3.1.0",
     "query-string": "^7.1.1",
     "query-string": "^7.1.1",
-    "re2": "^1.17.8",
-    "react": "^17.0.2",
-    "react-copy-to-clipboard": "^5.1.0",
-    "react-dom": "^17.0.2",
-    "react-inlinesvg": "^3.0.1",
-    "react-tooltip": "^4.5.0",
-    "react-use-form-state": "^0.13.2",
     "rebass": "^4.0.7",
     "rebass": "^4.0.7",
     "recharts": "^2.1.16",
     "recharts": "^2.1.16",
     "redis": "^4.5.0",
     "redis": "^4.5.0",
@@ -95,6 +86,7 @@
     "@types/cookie-parser": "^1.4.3",
     "@types/cookie-parser": "^1.4.3",
     "@types/cors": "^2.8.12",
     "@types/cors": "^2.8.12",
     "@types/express": "^4.17.14",
     "@types/express": "^4.17.14",
+    "@types/hbs": "^4.0.4",
     "@types/jest": "^26.0.20",
     "@types/jest": "^26.0.20",
     "@types/jsonwebtoken": "^7.2.8",
     "@types/jsonwebtoken": "^7.2.8",
     "@types/morgan": "^1.7.37",
     "@types/morgan": "^1.7.37",
@@ -104,19 +96,10 @@
     "@types/node-cron": "^2.0.2",
     "@types/node-cron": "^2.0.2",
     "@types/nodemailer": "^6.4.6",
     "@types/nodemailer": "^6.4.6",
     "@types/pg": "^8.6.5",
     "@types/pg": "^8.6.5",
-    "@types/qrcode.react": "^1.0.2",
-    "@types/react": "^17.0.52",
-    "@types/react-dom": "^17.0.18",
     "@types/rebass": "^4.0.10",
     "@types/rebass": "^4.0.10",
     "@types/signale": "^1.4.4",
     "@types/signale": "^1.4.4",
     "@types/styled-components": "^5.1.7",
     "@types/styled-components": "^5.1.7",
-    "@typescript-eslint/eslint-plugin": "^5.42.1",
-    "@typescript-eslint/parser": "^5.42.1",
     "copyfiles": "^2.4.1",
     "copyfiles": "^2.4.1",
-    "eslint": "^8.27.0",
-    "eslint-config-next": "^13.0.3",
-    "eslint-config-prettier": "^8.5.0",
-    "eslint-plugin-prettier": "^4.2.1",
     "husky": "^8.0.2",
     "husky": "^8.0.2",
     "jest": "^29.3.1",
     "jest": "^29.3.1",
     "nodemon": "^2.0.20",
     "nodemon": "^2.0.20",

+ 10 - 5
server/config/winston.ts → server/config/winston.js

@@ -1,6 +1,6 @@
-import appRoot from "app-root-path";
-import winston from "winston";
-import DailyRotateFile from "winston-daily-rotate-file";
+const appRoot = require("app-root-path");
+const winston = require("winston");
+const DailyRotateFile = require("winston-daily-rotate-file");
 
 
 const { combine, colorize, printf, timestamp } = winston.format;
 const { combine, colorize, printf, timestamp } = winston.format;
 
 
@@ -44,7 +44,7 @@ const options = {
 };
 };
 
 
 // instantiate a new Winston Logger with the settings defined above
 // instantiate a new Winston Logger with the settings defined above
-export const logger = winston.createLogger({
+const logger = winston.createLogger({
   format: combine(timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), logFormat),
   format: combine(timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), logFormat),
   transports: [
   transports: [
     new DailyRotateFile(options.file),
     new DailyRotateFile(options.file),
@@ -55,7 +55,7 @@ export const logger = winston.createLogger({
 });
 });
 
 
 // create a stream object with a 'write' function that will be used by `morgan`
 // create a stream object with a 'write' function that will be used by `morgan`
-export const stream = {
+const stream = {
   write: message => {
   write: message => {
     logger.info(message);
     logger.info(message);
   }
   }
@@ -67,3 +67,8 @@ winston.addColors({
   info: "green",
   info: "green",
   warn: "yellow"
   warn: "yellow"
 });
 });
+
+module.exports = {
+  logger,
+  stream
+}

+ 4 - 6
server/env.ts → server/env.js

@@ -1,7 +1,5 @@
-import * as dotenv from "dotenv";
-import { cleanEnv, num, str, bool } from "envalid";
-
-dotenv.config();
+require("dotenv").config();
+const { cleanEnv, num, str, bool } = require("envalid");
 
 
 const env = cleanEnv(process.env, {
 const env = cleanEnv(process.env, {
   PORT: num({ default: 3000 }),
   PORT: num({ default: 3000 }),
@@ -30,7 +28,7 @@ const env = cleanEnv(process.env, {
   ADMIN_EMAILS: str({ default: "" }),
   ADMIN_EMAILS: str({ default: "" }),
   RECAPTCHA_SITE_KEY: str({ default: "" }),
   RECAPTCHA_SITE_KEY: str({ default: "" }),
   RECAPTCHA_SECRET_KEY: str({ default: "" }),
   RECAPTCHA_SECRET_KEY: str({ default: "" }),
-  GOOGLE_SAFE_BROWSING_KEY: str({ default: "" }),
+  GOOGLE_SAFE_BROWSING_KEY: str({ default: "" }), // TODO: something about it
   MAIL_HOST: str(),
   MAIL_HOST: str(),
   MAIL_PORT: num(),
   MAIL_PORT: num(),
   MAIL_SECURE: bool({ default: false }),
   MAIL_SECURE: bool({ default: false }),
@@ -41,4 +39,4 @@ const env = cleanEnv(process.env, {
   CONTACT_EMAIL: str({ default: "" })
   CONTACT_EMAIL: str({ default: "" })
 });
 });
 
 
-export default env;
+module.exports = env;

+ 184 - 120
server/handlers/auth.ts → server/handlers/auth.js

@@ -1,43 +1,61 @@
-import { differenceInMinutes, addMinutes, subMinutes } from "date-fns";
-import { Handler } from "express";
-import passport from "passport";
-import bcrypt from "bcryptjs";
-import nanoid from "nanoid";
-import { v4 as uuid } from "uuid";
-import axios from "axios";
-
-import { CustomError } from "../utils";
-import * as utils from "../utils";
-import * as redis from "../redis";
-import * as mail from "../mail";
-import query from "../queries";
-import env from "../env";
-
-const authenticate = (
-  type: "jwt" | "local" | "localapikey",
-  error: string,
-  isStrict = true
-) =>
-  async function auth(req, res, next) {
+const { differenceInMinutes, addMinutes, subMinutes } = require("date-fns");
+const passport = require("passport");
+const { v4: uuid } = require("uuid");
+const bcrypt = require("bcryptjs");
+const nanoid = require("nanoid");
+const axios = require("axios");
+
+const { CustomError } = require("../utils");
+const query = require("../queries");
+const utils = require("../utils");
+const redis = require("../redis");
+const mail = require("../mail");
+const env = require("../env");
+
+function authenticate(type, error, isStrict) {
+  return function auth(req, res, next) {
     if (req.user) return next();
     if (req.user) return next();
 
 
     passport.authenticate(type, (err, user) => {
     passport.authenticate(type, (err, user) => {
       if (err) return next(err);
       if (err) return next(err);
+      const accepts = req.accepts(["json", "html"]);
 
 
       if (!user && isStrict) {
       if (!user && isStrict) {
-        throw new CustomError(error, 401);
+        if (accepts === "html") {
+          return utils.sleep(2000).then(() => {
+            return res.render("partials/login_signup", {
+              layout: null,
+              error
+            });
+          });
+        } else {
+          throw new CustomError(error, 401);
+        }
       }
       }
 
 
       if (user && isStrict && !user.verified) {
       if (user && isStrict && !user.verified) {
-        throw new CustomError(
-          "Your email address is not verified. " +
-            "Click on signup to get the verification link again.",
-          400
-        );
+        const errorMessage = "Your email address is not verified. " +
+          "Sign up to get the verification link again."
+        if (accepts === "html") {
+          return res.render("partials/login_signup", {
+            layout: null,
+            error: errorMessage
+          });
+        } else {
+          throw new CustomError(errorMessage, 400);
+        }
       }
       }
 
 
       if (user && user.banned) {
       if (user && user.banned) {
-        throw new CustomError("You're banned from using this website.", 403);
+        const errorMessage = "You're banned from using this website.";
+        if (accepts === "html") {
+          return res.render("partials/login_signup", {
+            layout: null,
+            error: errorMessage
+          });
+        } else {
+          throw new CustomError(errorMessage, 403);
+        }
       }
       }
 
 
       if (user) {
       if (user) {
@@ -49,27 +67,27 @@ const authenticate = (
       }
       }
       return next();
       return next();
     })(req, res, next);
     })(req, res, next);
-  };
+  }
+}
 
 
-export const local = authenticate("local", "Login credentials are wrong.");
-export const jwt = authenticate("jwt", "Unauthorized.");
-export const jwtLoose = authenticate("jwt", "Unauthorized.", false);
-export const apikey = authenticate(
-  "localapikey",
-  "API key is not correct.",
-  false
-);
+const local = authenticate("local", "Login credentials are wrong.", true);
+const jwt = authenticate("jwt", "Unauthorized.", true);
+const jwtLoose = authenticate("jwt", "Unauthorized.", false);
+const apikey = authenticate("localapikey", "API key is not correct.", false);
 
 
-export const cooldown: Handler = async (req, res, next) => {
+/**
+ * @type {import("express").Handler}
+ */
+async function cooldown(req, res, next) {
   if (env.DISALLOW_ANONYMOUS_LINKS) return next();
   if (env.DISALLOW_ANONYMOUS_LINKS) return next();
   const cooldownConfig = env.NON_USER_COOLDOWN;
   const cooldownConfig = env.NON_USER_COOLDOWN;
   if (req.user || !cooldownConfig) return next();
   if (req.user || !cooldownConfig) return next();
-
+  
   const ip = await query.ip.find({
   const ip = await query.ip.find({
     ip: req.realIP.toLowerCase(),
     ip: req.realIP.toLowerCase(),
     created_at: [">", subMinutes(new Date(), cooldownConfig).toISOString()]
     created_at: [">", subMinutes(new Date(), cooldownConfig).toISOString()]
   });
   });
-
+  
   if (ip) {
   if (ip) {
     const timeToWait =
     const timeToWait =
       cooldownConfig - differenceInMinutes(new Date(), new Date(ip.created_at));
       cooldownConfig - differenceInMinutes(new Date(), new Date(ip.created_at));
@@ -79,60 +97,66 @@ export const cooldown: Handler = async (req, res, next) => {
     );
     );
   }
   }
   next();
   next();
-};
-
-export const recaptcha: Handler = async (req, res, next) => {
-  if (env.isDev || req.user) return next();
-  if (env.DISALLOW_ANONYMOUS_LINKS) return next();
-  if (!env.RECAPTCHA_SECRET_KEY) return next();
-
-  const isReCaptchaValid = await axios({
-    method: "post",
-    url: "https://www.google.com/recaptcha/api/siteverify",
-    headers: {
-      "Content-type": "application/x-www-form-urlencoded"
-    },
-    params: {
-      secret: env.RECAPTCHA_SECRET_KEY,
-      response: req.body.reCaptchaToken,
-      remoteip: req.realIP
-    }
-  });
-
-  if (!isReCaptchaValid.data.success) {
-    throw new CustomError("reCAPTCHA is not valid. Try again.", 401);
-  }
+}
 
 
-  return next();
-};
-
-export const admin: Handler = async (req, res, next) => {
+/**
+ * @type {import("express").Handler}
+ */
+function admin(req, res, next) {
+  // FIXME: attaching to req is risky, find another way
   if (req.user.admin) return next();
   if (req.user.admin) return next();
   throw new CustomError("Unauthorized", 401);
   throw new CustomError("Unauthorized", 401);
-};
+}
 
 
-export const signup: Handler = async (req, res) => {
+/**
+ * @type {import("express").Handler}
+ */
+async function signup(req, res) {
   const salt = await bcrypt.genSalt(12);
   const salt = await bcrypt.genSalt(12);
   const password = await bcrypt.hash(req.body.password, salt);
   const password = await bcrypt.hash(req.body.password, salt);
 
 
+  const accepts = req.accepts(["json", "html"]);
+  
   const user = await query.user.add(
   const user = await query.user.add(
     { email: req.body.email, password },
     { email: req.body.email, password },
     req.user
     req.user
   );
   );
-
+  
   await mail.verification(user);
   await mail.verification(user);
 
 
-  return res.status(201).send({ message: "Verification email has been sent." });
-};
-
-export const token: Handler = async (req, res) => {
+  if (accepts === "html") {
+    return res.render("partials/signup_verify_email", { layout: null });
+  }
+  
+  return res.status(201).send({ message: "A verification email has been sent." });
+}
+
+/**
+ * @type {import("express").Handler}
+ */
+function login(req, res) {
   const token = utils.signToken(req.user);
   const token = utils.signToken(req.user);
+
+  const accepts = req.accepts(["json", "html"]);
+
+  if (accepts === "html") {
+    res.cookie("token", token, {
+      maxAge: 1000 * 60 * 15, // expire after seven days
+      httpOnly: true,
+      secure: env.isProd
+    });
+    return res.render("partials/login_welcome", { layout: false });
+  }
+  
   return res.status(200).send({ token });
   return res.status(200).send({ token });
-};
+}
 
 
-export const verify: Handler = async (req, res, next) => {
+/**
+ * @type {import("express").Handler}
+ */
+async function verify(req, res, next) {
   if (!req.params.verificationToken) return next();
   if (!req.params.verificationToken) return next();
-
+  
   const [user] = await query.user.update(
   const [user] = await query.user.update(
     {
     {
       verification_token: req.params.verificationToken,
       verification_token: req.params.verificationToken,
@@ -144,45 +168,54 @@ export const verify: Handler = async (req, res, next) => {
       verification_expires: null
       verification_expires: null
     }
     }
   );
   );
-
+  
   if (user) {
   if (user) {
     const token = utils.signToken(user);
     const token = utils.signToken(user);
     req.token = token;
     req.token = token;
   }
   }
-
+  
   return next();
   return next();
-};
+}
 
 
-export const changePassword: Handler = async (req, res) => {
+/**
+ * @type {import("express").Handler}
+ */
+async function changePassword(req, res) {
   const salt = await bcrypt.genSalt(12);
   const salt = await bcrypt.genSalt(12);
   const password = await bcrypt.hash(req.body.password, salt);
   const password = await bcrypt.hash(req.body.password, salt);
-
+  
   const [user] = await query.user.update({ id: req.user.id }, { password });
   const [user] = await query.user.update({ id: req.user.id }, { password });
-
+  
   if (!user) {
   if (!user) {
     throw new CustomError("Couldn't change the password. Try again later.");
     throw new CustomError("Couldn't change the password. Try again later.");
   }
   }
-
+  
   return res
   return res
     .status(200)
     .status(200)
     .send({ message: "Your password has been changed successfully." });
     .send({ message: "Your password has been changed successfully." });
-};
+}
 
 
-export const generateApiKey: Handler = async (req, res) => {
+/**
+ * @type {import("express").Handler}
+ */
+async function generateApiKey(req, res) {
   const apikey = nanoid(40);
   const apikey = nanoid(40);
-
+  
   redis.remove.user(req.user);
   redis.remove.user(req.user);
-
+  
   const [user] = await query.user.update({ id: req.user.id }, { apikey });
   const [user] = await query.user.update({ id: req.user.id }, { apikey });
-
+  
   if (!user) {
   if (!user) {
     throw new CustomError("Couldn't generate API key. Please try again later.");
     throw new CustomError("Couldn't generate API key. Please try again later.");
   }
   }
-
+  
   return res.status(201).send({ apikey });
   return res.status(201).send({ apikey });
-};
+}
 
 
-export const resetPasswordRequest: Handler = async (req, res) => {
+/**
+ * @type {import("express").Handler}
+ */
+async function resetPasswordRequest(req, res) {
   const [user] = await query.user.update(
   const [user] = await query.user.update(
     { email: req.body.email },
     { email: req.body.email },
     {
     {
@@ -190,19 +223,22 @@ export const resetPasswordRequest: Handler = async (req, res) => {
       reset_password_expires: addMinutes(new Date(), 30).toISOString()
       reset_password_expires: addMinutes(new Date(), 30).toISOString()
     }
     }
   );
   );
-
+  
   if (user) {
   if (user) {
     await mail.resetPasswordToken(user);
     await mail.resetPasswordToken(user);
   }
   }
-
+  
   return res.status(200).send({
   return res.status(200).send({
     message: "If email address exists, a reset password email has been sent."
     message: "If email address exists, a reset password email has been sent."
   });
   });
-};
+}
 
 
-export const resetPassword: Handler = async (req, res, next) => {
+/**
+ * @type {import("express").Handler}
+ */
+async function resetPassword(req, res, next) {
   const { resetPasswordToken } = req.params;
   const { resetPasswordToken } = req.params;
-
+  
   if (resetPasswordToken) {
   if (resetPasswordToken) {
     const [user] = await query.user.update(
     const [user] = await query.user.update(
       {
       {
@@ -211,35 +247,41 @@ export const resetPassword: Handler = async (req, res, next) => {
       },
       },
       { reset_password_expires: null, reset_password_token: null }
       { reset_password_expires: null, reset_password_token: null }
     );
     );
-
+  
     if (user) {
     if (user) {
-      const token = utils.signToken(user as UserJoined);
+      const token = utils.signToken(user);
       req.token = token;
       req.token = token;
     }
     }
   }
   }
   return next();
   return next();
-};
+}
 
 
-export const signupAccess: Handler = (req, res, next) => {
+/**
+ * @type {import("express").Handler}
+ */
+function signupAccess(req, res, next) {
   if (!env.DISALLOW_REGISTRATION) return next();
   if (!env.DISALLOW_REGISTRATION) return next();
   return res.status(403).send({ message: "Registration is not allowed." });
   return res.status(403).send({ message: "Registration is not allowed." });
-};
+}
 
 
-export const changeEmailRequest: Handler = async (req, res) => {
+/**
+ * @type {import("express").Handler}
+ */
+async function changeEmailRequest(req, res) {
   const { email, password } = req.body;
   const { email, password } = req.body;
-
+  
   const isMatch = await bcrypt.compare(password, req.user.password);
   const isMatch = await bcrypt.compare(password, req.user.password);
-
+  
   if (!isMatch) {
   if (!isMatch) {
     throw new CustomError("Password is wrong.", 400);
     throw new CustomError("Password is wrong.", 400);
   }
   }
-
+  
   const currentUser = await query.user.find({ email });
   const currentUser = await query.user.find({ email });
-
+  
   if (currentUser) {
   if (currentUser) {
     throw new CustomError("Can't use this email address.", 400);
     throw new CustomError("Can't use this email address.", 400);
   }
   }
-
+  
   const [updatedUser] = await query.user.update(
   const [updatedUser] = await query.user.update(
     { id: req.user.id },
     { id: req.user.id },
     {
     {
@@ -248,30 +290,33 @@ export const changeEmailRequest: Handler = async (req, res) => {
       change_email_expires: addMinutes(new Date(), 30).toISOString()
       change_email_expires: addMinutes(new Date(), 30).toISOString()
     }
     }
   );
   );
-
+  
   redis.remove.user(updatedUser);
   redis.remove.user(updatedUser);
-
+  
   if (updatedUser) {
   if (updatedUser) {
     await mail.changeEmail({ ...updatedUser, email });
     await mail.changeEmail({ ...updatedUser, email });
   }
   }
-
+  
   return res.status(200).send({
   return res.status(200).send({
     message:
     message:
       "If email address exists, an email " +
       "If email address exists, an email " +
       "with a verification link has been sent."
       "with a verification link has been sent."
   });
   });
-};
+}
 
 
-export const changeEmail: Handler = async (req, res, next) => {
+/**
+ * @type {import("express").Handler}
+ */
+async function changeEmail(req, res, next) {
   const { changeEmailToken } = req.params;
   const { changeEmailToken } = req.params;
-
+  
   if (changeEmailToken) {
   if (changeEmailToken) {
     const foundUser = await query.user.find({
     const foundUser = await query.user.find({
       change_email_token: changeEmailToken
       change_email_token: changeEmailToken
     });
     });
-
+  
     if (!foundUser) return next();
     if (!foundUser) return next();
-
+  
     const [user] = await query.user.update(
     const [user] = await query.user.update(
       {
       {
         change_email_token: changeEmailToken,
         change_email_token: changeEmailToken,
@@ -284,13 +329,32 @@ export const changeEmail: Handler = async (req, res, next) => {
         email: foundUser.change_email_address
         email: foundUser.change_email_address
       }
       }
     );
     );
-
+  
     redis.remove.user(foundUser);
     redis.remove.user(foundUser);
-
+  
     if (user) {
     if (user) {
-      const token = utils.signToken(user as UserJoined);
+      const token = utils.signToken(user);
       req.token = token;
       req.token = token;
     }
     }
   }
   }
   return next();
   return next();
-};
+}
+
+module.exports = {
+  admin,
+  apikey,
+  changeEmail,
+  changeEmailRequest,
+  changePassword,
+  cooldown,
+  generateApiKey,
+  jwt,
+  jwtLoose,
+  local,
+  login,
+  resetPassword,
+  resetPasswordRequest,
+  signup,
+  signupAccess,
+  verify,
+}

+ 1 - 1
server/handlers/domains.ts

@@ -1,7 +1,7 @@
 import { Handler } from "express";
 import { Handler } from "express";
 import query from "../queries";
 import query from "../queries";
 import * as redis from "../redis";
 import * as redis from "../redis";
-import { CustomError, sanitize } from "../utils";
+import { CustomError, sanitize } from "../utils/utils";
 
 
 export const add: Handler = async (req, res) => {
 export const add: Handler = async (req, res) => {
   const { address, homepage } = req.body;
   const { address, homepage } = req.body;

+ 87 - 0
server/handlers/helpers.js

@@ -0,0 +1,87 @@
+const { validationResult } = require("express-validator");
+const signale = require("signale");
+
+const { logger } = require("../config/winston");
+const { CustomError } = require("../utils");
+const env = require("../env");
+
+// export const ip: Handler = (req, res, next) => {
+//   req.realIP =
+//     (req.headers["x-real-ip"] as string) || req.connection.remoteAddress || "";
+//   return next();
+// };
+
+/**
+ * @type {import("express").ErrorRequestHandler}
+ */
+function error(error, _req, res, _next) {
+  if (env.isDev) {
+    signale.fatal(error);
+  }
+
+  if (error instanceof CustomError) {
+    return res.status(error.statusCode || 500).json({ error: error.message });
+  }
+
+  return res.status(500).json({ error: "An error occurred." });
+};
+
+function verify(template) {
+  return function (req, res, next) {
+    const errors = validationResult(req);
+    if (!errors.isEmpty()) {
+      const accepts = req.accepts(["json", "html"]);
+      const message = errors.array()[0].msg;
+
+      if (template && accepts === "html") {
+        return res.render(template, {
+          layout: null,
+          error: message
+        });
+      }
+      throw new CustomError(message, 400);
+    }
+    return next();
+  }
+}
+
+// export const query: Handler = (req, res, next) => {
+//   const { admin } = req.user || {};
+
+//   if (
+//     typeof req.query.limit !== "undefined" &&
+//     typeof req.query.limit !== "string"
+//   ) {
+//     return res.status(400).json({ error: "limit query is not valid." });
+//   }
+
+//   if (
+//     typeof req.query.skip !== "undefined" &&
+//     typeof req.query.skip !== "string"
+//   ) {
+//     return res.status(400).json({ error: "skip query is not valid." });
+//   }
+
+//   if (
+//     typeof req.query.search !== "undefined" &&
+//     typeof req.query.search !== "string"
+//   ) {
+//     return res.status(400).json({ error: "search query is not valid." });
+//   }
+
+//   const limit = parseInt(req.query.limit) || 10;
+//   const skip = parseInt(req.query.skip) || 0;
+
+//   req.context = {
+//     limit: limit > 50 ? 50 : limit,
+//     skip,
+//     all: admin ? req.query.all === "true" : false
+//   };
+
+//   next();
+// };
+
+module.exports = {
+  error,
+  verify,
+}

+ 0 - 73
server/handlers/helpers.ts

@@ -1,73 +0,0 @@
-import { Handler, ErrorRequestHandler } from "express";
-import { validationResult } from "express-validator";
-import signale from "signale";
-
-import { CustomError } from "../utils";
-import env from "../env";
-import { logger } from "../config/winston";
-
-export const ip: Handler = (req, res, next) => {
-  req.realIP =
-    (req.headers["x-real-ip"] as string) || req.connection.remoteAddress || "";
-  return next();
-};
-
-// eslint-disable-next-line
-export const error: ErrorRequestHandler = (error, _req, res, _next) => {
-  logger.error(error);
-
-  if (env.isDev) {
-    signale.fatal(error);
-  }
-
-  if (error instanceof CustomError) {
-    return res.status(error.statusCode || 500).json({ error: error.message });
-  }
-
-  return res.status(500).json({ error: "An error occurred." });
-};
-
-export const verify = (req, res, next) => {
-  const errors = validationResult(req);
-  if (!errors.isEmpty()) {
-    const message = errors.array()[0].msg;
-    throw new CustomError(message, 400);
-  }
-  return next();
-};
-
-export const query: Handler = (req, res, next) => {
-  const { admin } = req.user || {};
-
-  if (
-    typeof req.query.limit !== "undefined" &&
-    typeof req.query.limit !== "string"
-  ) {
-    return res.status(400).json({ error: "limit query is not valid." });
-  }
-
-  if (
-    typeof req.query.skip !== "undefined" &&
-    typeof req.query.skip !== "string"
-  ) {
-    return res.status(400).json({ error: "skip query is not valid." });
-  }
-
-  if (
-    typeof req.query.search !== "undefined" &&
-    typeof req.query.search !== "string"
-  ) {
-    return res.status(400).json({ error: "search query is not valid." });
-  }
-
-  const limit = parseInt(req.query.limit) || 10;
-  const skip = parseInt(req.query.skip) || 0;
-
-  req.context = {
-    limit: limit > 50 ? 50 : limit,
-    skip,
-    all: admin ? req.query.all === "true" : false
-  };
-
-  next();
-};

+ 403 - 0
server/handlers/links.js

@@ -0,0 +1,403 @@
+const promisify = require("util").promisify;
+const bcrypt = require("bcryptjs");
+const isbot = require("isbot");
+const URL = require("url");
+const dns = require("dns");
+
+const validators = require("./validators");
+// const transporter = require("../mail");
+const query = require("../queries");
+// const queue = require("../queues");
+const utils = require("../utils");
+const env = require("../env");
+
+const CustomError = utils.CustomError;
+const dnsLookup = promisify(dns.lookup);
+
+// export const get: Handler = async (req, res) => {
+//   const { limit, skip, all } = req.context;
+//   const search = req.query.search as string;
+//   const userId = req.user.id;
+
+//   const match = {
+//     ...(!all && { user_id: userId })
+//   };
+
+//   const [links, total] = await Promise.all([
+//     query.link.get(match, { limit, search, skip }),
+//     query.link.total(match, { search })
+//   ]);
+
+//   const data = links.map(utils.sanitize.link);
+
+//   return res.send({
+//     total,
+//     limit,
+//     skip,
+//     data
+//   });
+// };
+
+/**
+ * @type {import("express").Handler}
+ */
+async function create(req, res) {
+  const { reuse, password, customurl, description, target, domain, expire_in } = req.body;
+  const domain_id = domain ? domain.id : null;
+  
+  const targetDomain = utils.removeWww(URL.parse(target).hostname);
+  
+  const queries = await Promise.all([
+    validators.cooldown(req.user),
+    validators.malware(req.user, target),
+    validators.linksCount(req.user),
+    reuse &&
+      query.link.find({
+        target,
+        user_id: req.user.id,
+        domain_id
+      }),
+    customurl &&
+      query.link.find({
+        address: customurl,
+        domain_id
+      }),
+    !customurl && utils.generateId(domain_id),
+    validators.bannedDomain(targetDomain),
+    validators.bannedHost(targetDomain)
+  ]);
+  
+  // if "reuse" is true, try to return
+  // the existent URL without creating one
+  if (queries[3]) {
+    return res.json(utils.sanitize.link(queries[3]));
+  }
+  
+  // Check if custom link already exists
+  if (queries[4]) {
+    throw new CustomError("Custom URL is already in use.");
+  }
+
+  const accepts = req.accepts(["json", "html"]);
+
+  // Create new link
+  const address = customurl || queries[5];
+  const link = await query.link.create({
+    password,
+    address,
+    domain_id,
+    description,
+    target,
+    expire_in,
+    user_id: req.user && req.user.id
+  });
+  
+  if (!req.user && env.NON_USER_COOLDOWN) {
+    query.ip.add(req.realIP);
+  }
+
+  if (accepts === "html") {
+    const shortURL = utils.getShortURL(link.address, link.domain);
+    return res.render("partials/shorturl", {
+      layout: null,
+      link: shortURL.link, 
+      url: shortURL.url,
+    });
+  }
+  
+  return res
+    .status(201)
+    .send(utils.sanitize.link({ ...link, domain: domain?.address }));
+}
+
+// export const edit: Handler = async (req, res) => {
+//   const { address, target, description, expire_in, password } = req.body;
+//   if (!address && !target) {
+//     throw new CustomError("Should at least update one field.");
+//   }
+
+//   const link = await query.link.find({
+//     uuid: req.params.id,
+//     ...(!req.user.admin && { user_id: req.user.id })
+//   });
+
+//   if (!link) {
+//     throw new CustomError("Link was not found.");
+//   }
+
+//   const targetDomain = utils.removeWww(URL.parse(target).hostname);
+//   const domain_id = link.domain_id || null;
+
+//   const queries = await Promise.all([
+//     validators.cooldown(req.user),
+//     validators.malware(req.user, target),
+//     address !== link.address &&
+//       query.link.find({
+//         address,
+//         domain_id
+//       }),
+//     validators.bannedDomain(targetDomain),
+//     validators.bannedHost(targetDomain)
+//   ]);
+
+//   // Check if custom link already exists
+//   if (queries[2]) {
+//     throw new CustomError("Custom URL is already in use.");
+//   }
+
+//   // Update link
+//   const [updatedLink] = await query.link.update(
+//     {
+//       id: link.id
+//     },
+//     {
+//       ...(address && { address }),
+//       ...(description && { description }),
+//       ...(target && { target }),
+//       ...(expire_in && { expire_in }),
+//       ...(password && { password })
+//     }
+//   );
+
+//   return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink }));
+// };
+
+// export const remove: Handler = async (req, res) => {
+//   const link = await query.link.remove({
+//     uuid: req.params.id,
+//     ...(!req.user.admin && { user_id: req.user.id })
+//   });
+
+//   if (!link) {
+//     throw new CustomError("Could not delete the link");
+//   }
+
+//   return res
+//     .status(200)
+//     .send({ message: "Link has been deleted successfully." });
+// };
+
+// export const report: Handler = async (req, res) => {
+//   const { link } = req.body;
+
+//   const mail = await transporter.sendMail({
+//     from: env.MAIL_FROM || env.MAIL_USER,
+//     to: env.REPORT_EMAIL,
+//     subject: "[REPORT]",
+//     text: link,
+//     html: link
+//   });
+
+//   if (!mail.accepted.length) {
+//     throw new CustomError("Couldn't submit the report. Try again later.");
+//   }
+//   return res
+//     .status(200)
+//     .send({ message: "Thanks for the report, we'll take actions shortly." });
+// };
+
+// export const ban: Handler = async (req, res) => {
+//   const { id } = req.params;
+
+//   const update = {
+//     banned_by_id: req.user.id,
+//     banned: true
+//   };
+
+//   // 1. Check if link exists
+//   const link = await query.link.find({ uuid: id });
+
+//   if (!link) {
+//     throw new CustomError("No link has been found.", 400);
+//   }
+
+//   if (link.banned) {
+//     return res.status(200).send({ message: "Link has been banned already." });
+//   }
+
+//   const tasks = [];
+
+//   // 2. Ban link
+//   tasks.push(query.link.update({ uuid: id }, update));
+
+//   const domain = utils.removeWww(URL.parse(link.target).hostname);
+
+//   // 3. Ban target's domain
+//   if (req.body.domain) {
+//     tasks.push(query.domain.add({ ...update, address: domain }));
+//   }
+
+//   // 4. Ban target's host
+//   if (req.body.host) {
+//     const dnsRes = await dnsLookup(domain).catch(() => {
+//       throw new CustomError("Couldn't fetch DNS info.");
+//     });
+//     const host = dnsRes?.address;
+//     tasks.push(query.host.add({ ...update, address: host }));
+//   }
+
+//   // 5. Ban link owner
+//   if (req.body.user && link.user_id) {
+//     tasks.push(query.user.update({ id: link.user_id }, update));
+//   }
+
+//   // 6. Ban all of owner's links
+//   if (req.body.userLinks && link.user_id) {
+//     tasks.push(query.link.update({ user_id: link.user_id }, update));
+//   }
+
+//   // 7. Wait for all tasks to finish
+//   await Promise.all(tasks).catch(() => {
+//     throw new CustomError("Couldn't ban entries.");
+//   });
+
+//   // 8. Send response
+//   return res.status(200).send({ message: "Banned link successfully." });
+// };
+
+// export const redirect = (app) => async (
+//   req,
+//   res,
+//   next
+// ) => {
+//   const isBot = isbot(req.headers["user-agent"]);
+//   const isPreservedUrl = validators.preservedUrls.some(
+//     item => item === req.path.replace("/", "")
+//   );
+
+//   if (isPreservedUrl) return next();
+
+//   // 1. If custom domain, get domain info
+//   const host = utils.removeWww(req.headers.host);
+//   const domain =
+//     host !== env.DEFAULT_DOMAIN
+//       ? await query.domain.find({ address: host })
+//       : null;
+
+//   // 2. Get link
+//   const address = req.params.id.replace("+", "");
+//   const link = await query.link.find({
+//     address,
+//     domain_id: domain ? domain.id : null
+//   });
+
+//   // 3. When no link, if has domain redirect to domain's homepage
+//   // otherwise redirect to 404
+//   if (!link) {
+//     return res.redirect(302, domain ? domain.homepage : "/404");
+//   }
+
+//   // 4. If link is banned, redirect to banned page.
+//   if (link.banned) {
+//     return res.redirect("/banned");
+//   }
+
+//   // 5. If wants to see link info, then redirect
+//   const doesRequestInfo = /.*\+$/gi.test(req.params.id);
+//   if (doesRequestInfo && !link.password) {
+//     return app.render(req, res, "/url-info", { target: link.target });
+//   }
+
+//   // 6. If link is protected, redirect to password page
+//   if (link.password) {
+//     return res.redirect(`/protected/${link.uuid}`);
+//   }
+
+//   // 7. Create link visit
+//   if (link.user_id && !isBot) {
+//     queue.visit.add({
+//       headers: req.headers,
+//       realIP: req.realIP,
+//       referrer: req.get("Referrer"),
+//       link
+//     });
+//   }
+
+//   // 8. Redirect to target
+//   return res.redirect(link.target);
+// };
+
+// export const redirectProtected: Handler = async (req, res) => {
+//   // 1. Get link
+//   const uuid = req.params.id;
+//   const link = await query.link.find({ uuid });
+
+//   // 2. Throw error if no link
+//   if (!link || !link.password) {
+//     throw new CustomError("Couldn't find the link.", 400);
+//   }
+
+//   // 3. Check if password matches
+//   const matches = await bcrypt.compare(req.body.password, link.password);
+
+//   if (!matches) {
+//     throw new CustomError("Password is not correct.", 401);
+//   }
+
+//   // 4. Create visit
+//   if (link.user_id) {
+//     queue.visit.add({
+//       headers: req.headers,
+//       realIP: req.realIP,
+//       referrer: req.get("Referrer"),
+//       link
+//     });
+//   }
+
+//   // 5. Send target
+//   return res.status(200).send({ target: link.target });
+// };
+
+// export const redirectCustomDomain: Handler = async (req, res, next) => {
+//   const { path } = req;
+//   const host = utils.removeWww(req.headers.host);
+
+//   if (host === env.DEFAULT_DOMAIN) {
+//     return next();
+//   }
+
+//   if (
+//     path === "/" ||
+//     validators.preservedUrls
+//       .filter(l => l !== "url-password")
+//       .some(item => item === path.replace("/", ""))
+//   ) {
+//     const domain = await query.domain.find({ address: host });
+//     const redirectURL = domain
+//       ? domain.homepage
+//       : `https://${env.DEFAULT_DOMAIN + path}`;
+
+//     return res.redirect(302, redirectURL);
+//   }
+
+//   return next();
+// };
+
+// export const stats: Handler = async (req, res) => {
+//   const { user } = req;
+//   const uuid = req.params.id;
+
+//   const link = await query.link.find({
+//     ...(!user.admin && { user_id: user.id }),
+//     uuid
+//   });
+
+//   if (!link) {
+//     throw new CustomError("Link could not be found.");
+//   }
+
+//   const stats = await query.visit.find({ link_id: link.id }, link.visit_count);
+
+//   if (!stats) {
+//     throw new CustomError("Could not get the short link stats.");
+//   }
+
+//   return res.status(200).send({
+//     ...stats,
+//     ...utils.sanitize.link(link)
+//   });
+// };
+
+module.exports = {
+  create,
+}

+ 0 - 396
server/handlers/links.ts

@@ -1,396 +0,0 @@
-import { Handler } from "express";
-import { promisify } from "util";
-import bcrypt from "bcryptjs";
-import isbot from "isbot";
-import next from "next";
-import URL from "url";
-import dns from "dns";
-
-import * as validators from "./validators";
-import { CreateLinkReq } from "./types";
-import { CustomError } from "../utils";
-import transporter from "../mail/mail";
-import * as utils from "../utils";
-import query from "../queries";
-import queue from "../queues";
-import env from "../env";
-
-const dnsLookup = promisify(dns.lookup);
-
-export const get: Handler = async (req, res) => {
-  const { limit, skip, all } = req.context;
-  const search = req.query.search as string;
-  const userId = req.user.id;
-
-  const match = {
-    ...(!all && { user_id: userId })
-  };
-
-  const [links, total] = await Promise.all([
-    query.link.get(match, { limit, search, skip }),
-    query.link.total(match, { search })
-  ]);
-
-  const data = links.map(utils.sanitize.link);
-
-  return res.send({
-    total,
-    limit,
-    skip,
-    data
-  });
-};
-
-export const create: Handler = async (req: CreateLinkReq, res) => {
-  const {
-    reuse,
-    password,
-    customurl,
-    description,
-    target,
-    domain,
-    expire_in
-  } = req.body;
-  const domain_id = domain ? domain.id : null;
-
-  const targetDomain = utils.removeWww(URL.parse(target).hostname);
-
-  const queries = await Promise.all([
-    validators.cooldown(req.user),
-    validators.malware(req.user, target),
-    validators.linksCount(req.user),
-    reuse &&
-      query.link.find({
-        target,
-        user_id: req.user.id,
-        domain_id
-      }),
-    customurl &&
-      query.link.find({
-        address: customurl,
-        domain_id
-      }),
-    !customurl && utils.generateId(domain_id),
-    validators.bannedDomain(targetDomain),
-    validators.bannedHost(targetDomain)
-  ]);
-
-  // if "reuse" is true, try to return
-  // the existent URL without creating one
-  if (queries[3]) {
-    return res.json(utils.sanitize.link(queries[3]));
-  }
-
-  // Check if custom link already exists
-  if (queries[4]) {
-    throw new CustomError("Custom URL is already in use.");
-  }
-
-  // Create new link
-  const address = customurl || queries[5];
-  const link = await query.link.create({
-    password,
-    address,
-    domain_id,
-    description,
-    target,
-    expire_in,
-    user_id: req.user && req.user.id
-  });
-
-  if (!req.user && env.NON_USER_COOLDOWN) {
-    query.ip.add(req.realIP);
-  }
-
-  return res
-    .status(201)
-    .send(utils.sanitize.link({ ...link, domain: domain?.address }));
-};
-
-export const edit: Handler = async (req, res) => {
-  const { address, target, description, expire_in, password } = req.body;
-  if (!address && !target) {
-    throw new CustomError("Should at least update one field.");
-  }
-
-  const link = await query.link.find({
-    uuid: req.params.id,
-    ...(!req.user.admin && { user_id: req.user.id })
-  });
-
-  if (!link) {
-    throw new CustomError("Link was not found.");
-  }
-
-  const targetDomain = utils.removeWww(URL.parse(target).hostname);
-  const domain_id = link.domain_id || null;
-
-  const queries = await Promise.all([
-    validators.cooldown(req.user),
-    validators.malware(req.user, target),
-    address !== link.address &&
-      query.link.find({
-        address,
-        domain_id
-      }),
-    validators.bannedDomain(targetDomain),
-    validators.bannedHost(targetDomain)
-  ]);
-
-  // Check if custom link already exists
-  if (queries[2]) {
-    throw new CustomError("Custom URL is already in use.");
-  }
-
-  // Update link
-  const [updatedLink] = await query.link.update(
-    {
-      id: link.id
-    },
-    {
-      ...(address && { address }),
-      ...(description && { description }),
-      ...(target && { target }),
-      ...(expire_in && { expire_in }),
-      ...(password && { password })
-    }
-  );
-
-  return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink }));
-};
-
-export const remove: Handler = async (req, res) => {
-  const link = await query.link.remove({
-    uuid: req.params.id,
-    ...(!req.user.admin && { user_id: req.user.id })
-  });
-
-  if (!link) {
-    throw new CustomError("Could not delete the link");
-  }
-
-  return res
-    .status(200)
-    .send({ message: "Link has been deleted successfully." });
-};
-
-export const report: Handler = async (req, res) => {
-  const { link } = req.body;
-
-  const mail = await transporter.sendMail({
-    from: env.MAIL_FROM || env.MAIL_USER,
-    to: env.REPORT_EMAIL,
-    subject: "[REPORT]",
-    text: link,
-    html: link
-  });
-
-  if (!mail.accepted.length) {
-    throw new CustomError("Couldn't submit the report. Try again later.");
-  }
-  return res
-    .status(200)
-    .send({ message: "Thanks for the report, we'll take actions shortly." });
-};
-
-export const ban: Handler = async (req, res) => {
-  const { id } = req.params;
-
-  const update = {
-    banned_by_id: req.user.id,
-    banned: true
-  };
-
-  // 1. Check if link exists
-  const link = await query.link.find({ uuid: id });
-
-  if (!link) {
-    throw new CustomError("No link has been found.", 400);
-  }
-
-  if (link.banned) {
-    return res.status(200).send({ message: "Link has been banned already." });
-  }
-
-  const tasks = [];
-
-  // 2. Ban link
-  tasks.push(query.link.update({ uuid: id }, update));
-
-  const domain = utils.removeWww(URL.parse(link.target).hostname);
-
-  // 3. Ban target's domain
-  if (req.body.domain) {
-    tasks.push(query.domain.add({ ...update, address: domain }));
-  }
-
-  // 4. Ban target's host
-  if (req.body.host) {
-    const dnsRes = await dnsLookup(domain).catch(() => {
-      throw new CustomError("Couldn't fetch DNS info.");
-    });
-    const host = dnsRes?.address;
-    tasks.push(query.host.add({ ...update, address: host }));
-  }
-
-  // 5. Ban link owner
-  if (req.body.user && link.user_id) {
-    tasks.push(query.user.update({ id: link.user_id }, update));
-  }
-
-  // 6. Ban all of owner's links
-  if (req.body.userLinks && link.user_id) {
-    tasks.push(query.link.update({ user_id: link.user_id }, update));
-  }
-
-  // 7. Wait for all tasks to finish
-  await Promise.all(tasks).catch(() => {
-    throw new CustomError("Couldn't ban entries.");
-  });
-
-  // 8. Send response
-  return res.status(200).send({ message: "Banned link successfully." });
-};
-
-export const redirect = (app: ReturnType<typeof next>): Handler => async (
-  req,
-  res,
-  next
-) => {
-  const isBot = isbot(req.headers["user-agent"]);
-  const isPreservedUrl = validators.preservedUrls.some(
-    item => item === req.path.replace("/", "")
-  );
-
-  if (isPreservedUrl) return next();
-
-  // 1. If custom domain, get domain info
-  const host = utils.removeWww(req.headers.host);
-  const domain =
-    host !== env.DEFAULT_DOMAIN
-      ? await query.domain.find({ address: host })
-      : null;
-
-  // 2. Get link
-  const address = req.params.id.replace("+", "");
-  const link = await query.link.find({
-    address,
-    domain_id: domain ? domain.id : null
-  });
-
-  // 3. When no link, if has domain redirect to domain's homepage
-  // otherwise redirect to 404
-  if (!link) {
-    return res.redirect(302, domain ? domain.homepage : "/404");
-  }
-
-  // 4. If link is banned, redirect to banned page.
-  if (link.banned) {
-    return res.redirect("/banned");
-  }
-
-  // 5. If wants to see link info, then redirect
-  const doesRequestInfo = /.*\+$/gi.test(req.params.id);
-  if (doesRequestInfo && !link.password) {
-    return app.render(req, res, "/url-info", { target: link.target });
-  }
-
-  // 6. If link is protected, redirect to password page
-  if (link.password) {
-    return res.redirect(`/protected/${link.uuid}`);
-  }
-
-  // 7. Create link visit
-  if (link.user_id && !isBot) {
-    queue.visit.add({
-      headers: req.headers,
-      realIP: req.realIP,
-      referrer: req.get("Referrer"),
-      link
-    });
-  }
-
-  // 8. Redirect to target
-  return res.redirect(link.target);
-};
-
-export const redirectProtected: Handler = async (req, res) => {
-  // 1. Get link
-  const uuid = req.params.id;
-  const link = await query.link.find({ uuid });
-
-  // 2. Throw error if no link
-  if (!link || !link.password) {
-    throw new CustomError("Couldn't find the link.", 400);
-  }
-
-  // 3. Check if password matches
-  const matches = await bcrypt.compare(req.body.password, link.password);
-
-  if (!matches) {
-    throw new CustomError("Password is not correct.", 401);
-  }
-
-  // 4. Create visit
-  if (link.user_id) {
-    queue.visit.add({
-      headers: req.headers,
-      realIP: req.realIP,
-      referrer: req.get("Referrer"),
-      link
-    });
-  }
-
-  // 5. Send target
-  return res.status(200).send({ target: link.target });
-};
-
-export const redirectCustomDomain: Handler = async (req, res, next) => {
-  const { path } = req;
-  const host = utils.removeWww(req.headers.host);
-
-  if (host === env.DEFAULT_DOMAIN) {
-    return next();
-  }
-
-  if (
-    path === "/" ||
-    validators.preservedUrls
-      .filter(l => l !== "url-password")
-      .some(item => item === path.replace("/", ""))
-  ) {
-    const domain = await query.domain.find({ address: host });
-    const redirectURL = domain
-      ? domain.homepage
-      : `https://${env.DEFAULT_DOMAIN + path}`;
-
-    return res.redirect(302, redirectURL);
-  }
-
-  return next();
-};
-
-export const stats: Handler = async (req, res) => {
-  const { user } = req;
-  const uuid = req.params.id;
-
-  const link = await query.link.find({
-    ...(!user.admin && { user_id: user.id }),
-    uuid
-  });
-
-  if (!link) {
-    throw new CustomError("Link could not be found.");
-  }
-
-  const stats = await query.visit.find({ link_id: link.id }, link.visit_count);
-
-  if (!stats) {
-    throw new CustomError("Could not get the short link stats.");
-  }
-
-  return res.status(200).send({
-    ...stats,
-    ...utils.sanitize.link(link)
-  });
-};

+ 1 - 1
server/handlers/users.ts → server/handlers/users.js

@@ -1,5 +1,5 @@
 import query from "../queries";
 import query from "../queries";
-import * as utils from "../utils";
+import * as utils from "../utils/utils";
 
 
 export const get = async (req, res) => {
 export const get = async (req, res) => {
   const domains = await query.domain.get({ user_id: req.user.id });
   const domains = await query.domain.get({ user_id: req.user.id });

+ 471 - 0
server/handlers/validators.js

@@ -0,0 +1,471 @@
+const { body, param } = require("express-validator");
+const { isAfter, subDays, subHours, addMilliseconds } = require("date-fns");
+const urlRegex = require("url-regex-safe");
+const { promisify } = require("util");
+const bcrypt = require("bcryptjs");
+const axios = require("axios");
+const dns = require("dns");
+const URL = require("url");
+const ms = require("ms");
+
+const { CustomError, addProtocol, preservedURLs, removeWww } = require("../utils");
+const query = require("../queries");
+const knex = require("../knex");
+const env = require("../env");
+
+const dnsLookup = promisify(dns.lookup);
+
+const checkUser = (value, { req }) => !!req.user;
+
+const createLink = [
+  body("target")
+    .exists({ checkNull: true, checkFalsy: true })
+    .withMessage("Target is missing.")
+    .isString()
+    .trim()
+    .isLength({ min: 1, max: 2040 })
+    .withMessage("Maximum URL length is 2040.")
+    .customSanitizer(addProtocol)
+    .custom(
+      value =>
+        urlRegex({ exact: true, strict: false }).test(value) ||
+        /^(?!https?)(\w+):\/\//.test(value)
+    )
+    .withMessage("URL is not valid.")
+    .custom(value => removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
+    .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
+  body("password")
+    .optional({ nullable: true, checkFalsy: true })
+    .custom(checkUser)
+    .withMessage("Only users can use this field.")
+    .isString()
+    .isLength({ min: 3, max: 64 })
+    .withMessage("Password length must be between 3 and 64."),
+  body("customurl")
+    .optional({ nullable: true, checkFalsy: true })
+    .custom(checkUser)
+    .withMessage("Only users can use this field.")
+    .isString()
+    .trim()
+    .isLength({ min: 1, max: 64 })
+    .withMessage("Custom URL length must be between 1 and 64.")
+    .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
+    .withMessage("Custom URL is not valid")
+    .custom(value => !preservedURLs.some(url => url.toLowerCase() === value))
+    .withMessage("You can't use this custom URL."),
+  body("reuse")
+    .optional({ nullable: true })
+    .custom(checkUser)
+    .withMessage("Only users can use this field.")
+    .isBoolean()
+    .withMessage("Reuse must be boolean."),
+  body("description")
+    .optional({ nullable: true, checkFalsy: true })
+    .isString()
+    .trim()
+    .isLength({ min: 0, max: 2040 })
+    .withMessage("Description length must be between 0 and 2040."),
+  body("expire_in")
+    .optional({ nullable: true, checkFalsy: true })
+    .isString()
+    .trim()
+    .custom(value => {
+      try {
+        return !!ms(value);
+      } catch {
+        return false;
+      }
+    })
+    .withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
+    .customSanitizer(ms)
+    .custom(value => value >= ms("1m"))
+    .withMessage("Minimum expire time should be '1 minute'.")
+    .customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
+  body("domain")
+    .optional({ nullable: true, checkFalsy: true })
+    .custom(checkUser)
+    .withMessage("Only users can use this field.")
+    .isString()
+    .withMessage("Domain should be string.")
+    .customSanitizer(value => value.toLowerCase())
+    .customSanitizer(value => removeWww(URL.parse(value).hostname || value))
+    .custom(async (address, { req }) => {
+      if (address === env.DEFAULT_DOMAIN) {
+        req.body.domain = null;
+        return;
+      }
+
+      const domain = await query.domain.find({
+        address,
+        user_id: req.user.id
+      });
+      req.body.domain = domain || null;
+
+      if (!domain) return Promise.reject();
+    })
+    .withMessage("You can't use this domain.")
+];
+
+// export const editLink = [
+//   body("target")
+//     .optional({ checkFalsy: true, nullable: true })
+//     .isString()
+//     .trim()
+//     .isLength({ min: 1, max: 2040 })
+//     .withMessage("Maximum URL length is 2040.")
+//     .customSanitizer(addProtocol)
+//     .custom(
+//       value =>
+//         urlRegex({ exact: true, strict: false }).test(value) ||
+//         /^(?!https?)(\w+):\/\//.test(value)
+//     )
+//     .withMessage("URL is not valid.")
+//     .custom(value => removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
+//     .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
+//   body("password")
+//     .optional({ nullable: true, checkFalsy: true })
+//     .isString()
+//     .isLength({ min: 3, max: 64 })
+//     .withMessage("Password length must be between 3 and 64."),
+//   body("address")
+//     .optional({ checkFalsy: true, nullable: true })
+//     .isString()
+//     .trim()
+//     .isLength({ min: 1, max: 64 })
+//     .withMessage("Custom URL length must be between 1 and 64.")
+//     .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
+//     .withMessage("Custom URL is not valid")
+//     .custom(value => !preservedURLs.some(url => url.toLowerCase() === value))
+//     .withMessage("You can't use this custom URL."),
+//   body("expire_in")
+//     .optional({ nullable: true, checkFalsy: true })
+//     .isString()
+//     .trim()
+//     .custom(value => {
+//       try {
+//         return !!ms(value);
+//       } catch {
+//         return false;
+//       }
+//     })
+//     .withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
+//     .customSanitizer(ms)
+//     .custom(value => value >= ms("1m"))
+//     .withMessage("Minimum expire time should be '1 minute'.")
+//     .customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
+//   body("description")
+//     .optional({ nullable: true, checkFalsy: true })
+//     .isString()
+//     .trim()
+//     .isLength({ min: 0, max: 2040 })
+//     .withMessage("Description length must be between 0 and 2040."),
+//   param("id", "ID is invalid.")
+//     .exists({ checkFalsy: true, checkNull: true })
+//     .isLength({ min: 36, max: 36 })
+// ];
+
+// export const redirectProtected = [
+//   body("password", "Password is invalid.")
+//     .exists({ checkFalsy: true, checkNull: true })
+//     .isString()
+//     .isLength({ min: 3, max: 64 })
+//     .withMessage("Password length must be between 3 and 64."),
+//   param("id", "ID is invalid.")
+//     .exists({ checkFalsy: true, checkNull: true })
+//     .isLength({ min: 36, max: 36 })
+// ];
+
+// export const addDomain = [
+//   body("address", "Domain is not valid")
+//     .exists({ checkFalsy: true, checkNull: true })
+//     .isLength({ min: 3, max: 64 })
+//     .withMessage("Domain length must be between 3 and 64.")
+//     .trim()
+//     .customSanitizer(value => {
+//       const parsed = URL.parse(value);
+//       return removeWww(parsed.hostname || parsed.href);
+//     })
+//     .custom(value => urlRegex({ exact: true, strict: false }).test(value))
+//     .custom(value => value !== env.DEFAULT_DOMAIN)
+//     .withMessage("You can't use the default domain.")
+//     .custom(async value => {
+//       const domain = await query.domain.find({ address: value });
+//       if (domain?.user_id || domain?.banned) return Promise.reject();
+//     })
+//     .withMessage("You can't add this domain."),
+//   body("homepage")
+//     .optional({ checkFalsy: true, nullable: true })
+//     .customSanitizer(addProtocol)
+//     .custom(value => urlRegex({ exact: true, strict: false }).test(value))
+//     .withMessage("Homepage is not valid.")
+// ];
+
+// export const removeDomain = [
+//   param("id", "ID is invalid.")
+//     .exists({
+//       checkFalsy: true,
+//       checkNull: true
+//     })
+//     .isLength({ min: 36, max: 36 })
+// ];
+
+// export const deleteLink = [
+//   param("id", "ID is invalid.")
+//     .exists({
+//       checkFalsy: true,
+//       checkNull: true
+//     })
+//     .isLength({ min: 36, max: 36 })
+// ];
+
+// export const reportLink = [
+//   body("link", "No link has been provided.")
+//     .exists({
+//       checkFalsy: true,
+//       checkNull: true
+//     })
+//     .customSanitizer(addProtocol)
+//     .custom(
+//       value => removeWww(URL.parse(value).hostname) === env.DEFAULT_DOMAIN
+//     )
+//     .withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
+// ];
+
+// export const banLink = [
+//   param("id", "ID is invalid.")
+//     .exists({
+//       checkFalsy: true,
+//       checkNull: true
+//     })
+//     .isLength({ min: 36, max: 36 }),
+//   body("host", '"host" should be a boolean.')
+//     .optional({
+//       nullable: true
+//     })
+//     .isBoolean(),
+//   body("user", '"user" should be a boolean.')
+//     .optional({
+//       nullable: true
+//     })
+//     .isBoolean(),
+//   body("userlinks", '"userlinks" should be a boolean.')
+//     .optional({
+//       nullable: true
+//     })
+//     .isBoolean(),
+//   body("domain", '"domain" should be a boolean.')
+//     .optional({
+//       nullable: true
+//     })
+//     .isBoolean()
+// ];
+
+// export const getStats = [
+//   param("id", "ID is invalid.")
+//     .exists({
+//       checkFalsy: true,
+//       checkNull: true
+//     })
+//     .isLength({ min: 36, max: 36 })
+// ];
+
+const signup = [
+  body("password", "Password is not valid.")
+    .exists({ checkFalsy: true, checkNull: true })
+    .isLength({ min: 8, max: 64 })
+    .withMessage("Password length must be between 8 and 64."),
+  body("email", "Email is not valid.")
+    .exists({ checkFalsy: true, checkNull: true })
+    .trim()
+    .isEmail()
+    .isLength({ min: 0, max: 255 })
+    .withMessage("Email length must be max 255.")
+    .custom(async (value, { req }) => {
+      const user = await query.user.find({ email: value });
+
+      if (user) {
+        req.user = user;
+      }
+
+      if (user?.verified) return Promise.reject();
+    })
+    .withMessage("You can't use this email address.")
+];
+
+const login = [
+  body("password", "Password is not valid.")
+    .exists({ checkFalsy: true, checkNull: true })
+    .isLength({ min: 8, max: 64 })
+    .withMessage("Password length must be between 8 and 64."),
+  body("email", "Email is not valid.")
+    .exists({ checkFalsy: true, checkNull: true })
+    .trim()
+    .isEmail()
+    .isLength({ min: 0, max: 255 })
+    .withMessage("Email length must be max 255.")
+];
+
+// export const changePassword = [
+//   body("password", "Password is not valid.")
+//     .exists({ checkFalsy: true, checkNull: true })
+//     .isLength({ min: 8, max: 64 })
+//     .withMessage("Password length must be between 8 and 64.")
+// ];
+
+// export const resetPasswordRequest = [
+//   body("email", "Email is not valid.")
+//     .exists({ checkFalsy: true, checkNull: true })
+//     .trim()
+//     .isEmail()
+//     .isLength({ min: 0, max: 255 })
+//     .withMessage("Email length must be max 255."),
+//   body("password", "Password is not valid.")
+//     .exists({ checkFalsy: true, checkNull: true })
+//     .isLength({ min: 8, max: 64 })
+//     .withMessage("Password length must be between 8 and 64.")
+// ];
+
+// export const resetEmailRequest = [
+//   body("email", "Email is not valid.")
+//     .exists({ checkFalsy: true, checkNull: true })
+//     .trim()
+//     .isEmail()
+//     .isLength({ min: 0, max: 255 })
+//     .withMessage("Email length must be max 255.")
+// ];
+
+// export const deleteUser = [
+//   body("password", "Password is not valid.")
+//     .exists({ checkFalsy: true, checkNull: true })
+//     .isLength({ min: 8, max: 64 })
+//     .custom(async (password, { req }) => {
+//       const isMatch = await bcrypt.compare(password, req.user.password);
+//       if (!isMatch) return Promise.reject();
+//     })
+// ];
+
+// TODO: if user has posted malware should do something better
+function cooldown(user) {
+  if (!env.GOOGLE_SAFE_BROWSING_KEY || !user || !user.cooldowns) return;
+
+  // If has active cooldown then throw error
+  const hasCooldownNow = user.cooldowns.some(cooldown =>
+    isAfter(subHours(new Date(), 12), new Date(cooldown))
+  );
+
+  if (hasCooldownNow) {
+    throw new CustomError("Cooldown because of a malware URL. Wait 12h");
+  }
+}
+
+// TODO: if user or non-user has posted malware should do something better
+async function malware(user, target) {
+  if (!env.GOOGLE_SAFE_BROWSING_KEY) return;
+
+  const isMalware = await axios.post(
+    `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${env.GOOGLE_SAFE_BROWSING_KEY}`,
+    {
+      client: {
+        clientId: env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
+        clientVersion: "1.0.0"
+      },
+      threatInfo: {
+        threatTypes: [
+          "THREAT_TYPE_UNSPECIFIED",
+          "MALWARE",
+          "SOCIAL_ENGINEERING",
+          "UNWANTED_SOFTWARE",
+          "POTENTIALLY_HARMFUL_APPLICATION"
+        ],
+        platformTypes: ["ANY_PLATFORM", "PLATFORM_TYPE_UNSPECIFIED"],
+        threatEntryTypes: [
+          "EXECUTABLE",
+          "URL",
+          "THREAT_ENTRY_TYPE_UNSPECIFIED"
+        ],
+        threatEntries: [{ url: target }]
+      }
+    }
+  );
+  if (!isMalware.data || !isMalware.data.matches) return;
+
+  if (user) {
+    const [updatedUser] = await query.user.update(
+      { id: user.id },
+      {
+        cooldowns: knex.raw("array_append(cooldowns, ?)", [
+          new Date().toISOString()
+        ])
+      }
+    );
+
+    // Ban if too many cooldowns
+    if (updatedUser.cooldowns.length > 2) {
+      await query.user.update({ id: user.id }, { banned: true });
+      throw new CustomError("Too much malware requests. You are now banned.");
+    }
+  }
+
+  throw new CustomError(
+    user ? "Malware detected! Cooldown for 12h." : "Malware detected!"
+  );
+};
+
+async function linksCount(user) {
+  if (!user) return;
+
+  const count = await query.link.total({
+    user_id: user.id,
+    created_at: [">", subDays(new Date(), 1).toISOString()]
+  });
+
+  if (count > env.USER_LIMIT_PER_DAY) {
+    throw new CustomError(
+      `You have reached your daily limit (${env.USER_LIMIT_PER_DAY}). Please wait 24h.`
+    );
+  }
+};
+
+async function bannedDomain(domain) {
+  const isBanned = await query.domain.find({
+    address: domain,
+    banned: true
+  });
+
+  if (isBanned) {
+    throw new CustomError("URL is containing malware/scam.", 400);
+  }
+};
+
+async function bannedHost(domain) {
+  let isBanned;
+
+  try {
+    const dnsRes = await dnsLookup(domain);
+
+    if (!dnsRes || !dnsRes.address) return;
+
+    isBanned = await query.host.find({
+      address: dnsRes.address,
+      banned: true
+    });
+  } catch (error) {
+    isBanned = null;
+  }
+
+  if (isBanned) {
+    throw new CustomError("URL is containing malware/scam.", 400);
+  }
+};
+
+module.exports = {
+  bannedDomain,
+  bannedHost,
+  checkUser,
+  cooldown,
+  createLink,
+  linksCount,
+  login, 
+  malware,
+  signup,
+}

+ 0 - 480
server/handlers/validators.ts

@@ -1,480 +0,0 @@
-import { body, param } from "express-validator";
-import { isAfter, subDays, subHours, addMilliseconds } from "date-fns";
-import urlRegex from "url-regex-safe";
-import { promisify } from "util";
-import bcrypt from "bcryptjs";
-import axios from "axios";
-import dns from "dns";
-import URL from "url";
-import ms from "ms";
-
-import { CustomError, addProtocol, removeWww } from "../utils";
-import query from "../queries";
-import knex from "../knex";
-import env from "../env";
-
-const dnsLookup = promisify(dns.lookup);
-
-export const preservedUrls = [
-  "login",
-  "logout",
-  "signup",
-  "reset-password",
-  "resetpassword",
-  "url-password",
-  "url-info",
-  "settings",
-  "stats",
-  "verify",
-  "api",
-  "404",
-  "static",
-  "images",
-  "banned",
-  "terms",
-  "privacy",
-  "protected",
-  "report",
-  "pricing"
-];
-
-export const checkUser = (value, { req }) => !!req.user;
-
-export const createLink = [
-  body("target")
-    .exists({ checkNull: true, checkFalsy: true })
-    .withMessage("Target is missing.")
-    .isString()
-    .trim()
-    .isLength({ min: 1, max: 2040 })
-    .withMessage("Maximum URL length is 2040.")
-    .customSanitizer(addProtocol)
-    .custom(
-      value =>
-        urlRegex({ exact: true, strict: false }).test(value) ||
-        /^(?!https?)(\w+):\/\//.test(value)
-    )
-    .withMessage("URL is not valid.")
-    .custom(value => removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
-    .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
-  body("password")
-    .optional({ nullable: true, checkFalsy: true })
-    .custom(checkUser)
-    .withMessage("Only users can use this field.")
-    .isString()
-    .isLength({ min: 3, max: 64 })
-    .withMessage("Password length must be between 3 and 64."),
-  body("customurl")
-    .optional({ nullable: true, checkFalsy: true })
-    .custom(checkUser)
-    .withMessage("Only users can use this field.")
-    .isString()
-    .trim()
-    .isLength({ min: 1, max: 64 })
-    .withMessage("Custom URL length must be between 1 and 64.")
-    .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
-    .withMessage("Custom URL is not valid")
-    .custom(value => !preservedUrls.some(url => url.toLowerCase() === value))
-    .withMessage("You can't use this custom URL."),
-  body("reuse")
-    .optional({ nullable: true })
-    .custom(checkUser)
-    .withMessage("Only users can use this field.")
-    .isBoolean()
-    .withMessage("Reuse must be boolean."),
-  body("description")
-    .optional({ nullable: true, checkFalsy: true })
-    .isString()
-    .trim()
-    .isLength({ min: 0, max: 2040 })
-    .withMessage("Description length must be between 0 and 2040."),
-  body("expire_in")
-    .optional({ nullable: true, checkFalsy: true })
-    .isString()
-    .trim()
-    .custom(value => {
-      try {
-        return !!ms(value);
-      } catch {
-        return false;
-      }
-    })
-    .withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
-    .customSanitizer(ms)
-    .custom(value => value >= ms("1m"))
-    .withMessage("Minimum expire time should be '1 minute'.")
-    .customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
-  body("domain")
-    .optional({ nullable: true, checkFalsy: true })
-    .custom(checkUser)
-    .withMessage("Only users can use this field.")
-    .isString()
-    .withMessage("Domain should be string.")
-    .customSanitizer(value => value.toLowerCase())
-    .customSanitizer(value => removeWww(URL.parse(value).hostname || value))
-    .custom(async (address, { req }) => {
-      if (address === env.DEFAULT_DOMAIN) {
-        req.body.domain = null;
-        return;
-      }
-
-      const domain = await query.domain.find({
-        address,
-        user_id: req.user.id
-      });
-      req.body.domain = domain || null;
-
-      if (!domain) return Promise.reject();
-    })
-    .withMessage("You can't use this domain.")
-];
-
-export const editLink = [
-  body("target")
-    .optional({ checkFalsy: true, nullable: true })
-    .isString()
-    .trim()
-    .isLength({ min: 1, max: 2040 })
-    .withMessage("Maximum URL length is 2040.")
-    .customSanitizer(addProtocol)
-    .custom(
-      value =>
-        urlRegex({ exact: true, strict: false }).test(value) ||
-        /^(?!https?)(\w+):\/\//.test(value)
-    )
-    .withMessage("URL is not valid.")
-    .custom(value => removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
-    .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
-  body("password")
-    .optional({ nullable: true, checkFalsy: true })
-    .isString()
-    .isLength({ min: 3, max: 64 })
-    .withMessage("Password length must be between 3 and 64."),
-  body("address")
-    .optional({ checkFalsy: true, nullable: true })
-    .isString()
-    .trim()
-    .isLength({ min: 1, max: 64 })
-    .withMessage("Custom URL length must be between 1 and 64.")
-    .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
-    .withMessage("Custom URL is not valid")
-    .custom(value => !preservedUrls.some(url => url.toLowerCase() === value))
-    .withMessage("You can't use this custom URL."),
-  body("expire_in")
-    .optional({ nullable: true, checkFalsy: true })
-    .isString()
-    .trim()
-    .custom(value => {
-      try {
-        return !!ms(value);
-      } catch {
-        return false;
-      }
-    })
-    .withMessage("Expire format is invalid. Valid examples: 1m, 8h, 42 days.")
-    .customSanitizer(ms)
-    .custom(value => value >= ms("1m"))
-    .withMessage("Minimum expire time should be '1 minute'.")
-    .customSanitizer(value => addMilliseconds(new Date(), value).toISOString()),
-  body("description")
-    .optional({ nullable: true, checkFalsy: true })
-    .isString()
-    .trim()
-    .isLength({ min: 0, max: 2040 })
-    .withMessage("Description length must be between 0 and 2040."),
-  param("id", "ID is invalid.")
-    .exists({ checkFalsy: true, checkNull: true })
-    .isLength({ min: 36, max: 36 })
-];
-
-export const redirectProtected = [
-  body("password", "Password is invalid.")
-    .exists({ checkFalsy: true, checkNull: true })
-    .isString()
-    .isLength({ min: 3, max: 64 })
-    .withMessage("Password length must be between 3 and 64."),
-  param("id", "ID is invalid.")
-    .exists({ checkFalsy: true, checkNull: true })
-    .isLength({ min: 36, max: 36 })
-];
-
-export const addDomain = [
-  body("address", "Domain is not valid")
-    .exists({ checkFalsy: true, checkNull: true })
-    .isLength({ min: 3, max: 64 })
-    .withMessage("Domain length must be between 3 and 64.")
-    .trim()
-    .customSanitizer(value => {
-      const parsed = URL.parse(value);
-      return removeWww(parsed.hostname || parsed.href);
-    })
-    .custom(value => urlRegex({ exact: true, strict: false }).test(value))
-    .custom(value => value !== env.DEFAULT_DOMAIN)
-    .withMessage("You can't use the default domain.")
-    .custom(async value => {
-      const domain = await query.domain.find({ address: value });
-      if (domain?.user_id || domain?.banned) return Promise.reject();
-    })
-    .withMessage("You can't add this domain."),
-  body("homepage")
-    .optional({ checkFalsy: true, nullable: true })
-    .customSanitizer(addProtocol)
-    .custom(value => urlRegex({ exact: true, strict: false }).test(value))
-    .withMessage("Homepage is not valid.")
-];
-
-export const removeDomain = [
-  param("id", "ID is invalid.")
-    .exists({
-      checkFalsy: true,
-      checkNull: true
-    })
-    .isLength({ min: 36, max: 36 })
-];
-
-export const deleteLink = [
-  param("id", "ID is invalid.")
-    .exists({
-      checkFalsy: true,
-      checkNull: true
-    })
-    .isLength({ min: 36, max: 36 })
-];
-
-export const reportLink = [
-  body("link", "No link has been provided.")
-    .exists({
-      checkFalsy: true,
-      checkNull: true
-    })
-    .customSanitizer(addProtocol)
-    .custom(
-      value => removeWww(URL.parse(value).hostname) === env.DEFAULT_DOMAIN
-    )
-    .withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
-];
-
-export const banLink = [
-  param("id", "ID is invalid.")
-    .exists({
-      checkFalsy: true,
-      checkNull: true
-    })
-    .isLength({ min: 36, max: 36 }),
-  body("host", '"host" should be a boolean.')
-    .optional({
-      nullable: true
-    })
-    .isBoolean(),
-  body("user", '"user" should be a boolean.')
-    .optional({
-      nullable: true
-    })
-    .isBoolean(),
-  body("userlinks", '"userlinks" should be a boolean.')
-    .optional({
-      nullable: true
-    })
-    .isBoolean(),
-  body("domain", '"domain" should be a boolean.')
-    .optional({
-      nullable: true
-    })
-    .isBoolean()
-];
-
-export const getStats = [
-  param("id", "ID is invalid.")
-    .exists({
-      checkFalsy: true,
-      checkNull: true
-    })
-    .isLength({ min: 36, max: 36 })
-];
-
-export const signup = [
-  body("password", "Password is not valid.")
-    .exists({ checkFalsy: true, checkNull: true })
-    .isLength({ min: 8, max: 64 })
-    .withMessage("Password length must be between 8 and 64."),
-  body("email", "Email is not valid.")
-    .exists({ checkFalsy: true, checkNull: true })
-    .trim()
-    .isEmail()
-    .isLength({ min: 0, max: 255 })
-    .withMessage("Email length must be max 255.")
-    .custom(async (value, { req }) => {
-      const user = await query.user.find({ email: value });
-
-      if (user) {
-        req.user = user;
-      }
-
-      if (user?.verified) return Promise.reject();
-    })
-    .withMessage("You can't use this email address.")
-];
-
-export const login = [
-  body("password", "Password is not valid.")
-    .exists({ checkFalsy: true, checkNull: true })
-    .isLength({ min: 8, max: 64 })
-    .withMessage("Password length must be between 8 and 64."),
-  body("email", "Email is not valid.")
-    .exists({ checkFalsy: true, checkNull: true })
-    .trim()
-    .isEmail()
-    .isLength({ min: 0, max: 255 })
-    .withMessage("Email length must be max 255.")
-];
-
-export const changePassword = [
-  body("password", "Password is not valid.")
-    .exists({ checkFalsy: true, checkNull: true })
-    .isLength({ min: 8, max: 64 })
-    .withMessage("Password length must be between 8 and 64.")
-];
-
-export const resetPasswordRequest = [
-  body("email", "Email is not valid.")
-    .exists({ checkFalsy: true, checkNull: true })
-    .trim()
-    .isEmail()
-    .isLength({ min: 0, max: 255 })
-    .withMessage("Email length must be max 255."),
-  body("password", "Password is not valid.")
-    .exists({ checkFalsy: true, checkNull: true })
-    .isLength({ min: 8, max: 64 })
-    .withMessage("Password length must be between 8 and 64.")
-];
-
-export const resetEmailRequest = [
-  body("email", "Email is not valid.")
-    .exists({ checkFalsy: true, checkNull: true })
-    .trim()
-    .isEmail()
-    .isLength({ min: 0, max: 255 })
-    .withMessage("Email length must be max 255.")
-];
-
-export const deleteUser = [
-  body("password", "Password is not valid.")
-    .exists({ checkFalsy: true, checkNull: true })
-    .isLength({ min: 8, max: 64 })
-    .custom(async (password, { req }) => {
-      const isMatch = await bcrypt.compare(password, req.user.password);
-      if (!isMatch) return Promise.reject();
-    })
-];
-
-export const cooldown = (user: User) => {
-  if (!env.GOOGLE_SAFE_BROWSING_KEY || !user || !user.cooldowns) return;
-
-  // If has active cooldown then throw error
-  const hasCooldownNow = user.cooldowns.some(cooldown =>
-    isAfter(subHours(new Date(), 12), new Date(cooldown))
-  );
-
-  if (hasCooldownNow) {
-    throw new CustomError("Cooldown because of a malware URL. Wait 12h");
-  }
-};
-
-export const malware = async (user: User, target: string) => {
-  if (!env.GOOGLE_SAFE_BROWSING_KEY) return;
-
-  const isMalware = await axios.post(
-    `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${env.GOOGLE_SAFE_BROWSING_KEY}`,
-    {
-      client: {
-        clientId: env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
-        clientVersion: "1.0.0"
-      },
-      threatInfo: {
-        threatTypes: [
-          "THREAT_TYPE_UNSPECIFIED",
-          "MALWARE",
-          "SOCIAL_ENGINEERING",
-          "UNWANTED_SOFTWARE",
-          "POTENTIALLY_HARMFUL_APPLICATION"
-        ],
-        platformTypes: ["ANY_PLATFORM", "PLATFORM_TYPE_UNSPECIFIED"],
-        threatEntryTypes: [
-          "EXECUTABLE",
-          "URL",
-          "THREAT_ENTRY_TYPE_UNSPECIFIED"
-        ],
-        threatEntries: [{ url: target }]
-      }
-    }
-  );
-  if (!isMalware.data || !isMalware.data.matches) return;
-
-  if (user) {
-    const [updatedUser] = await query.user.update(
-      { id: user.id },
-      {
-        cooldowns: knex.raw("array_append(cooldowns, ?)", [
-          new Date().toISOString()
-        ]) as any
-      }
-    );
-
-    // Ban if too many cooldowns
-    if (updatedUser.cooldowns.length > 2) {
-      await query.user.update({ id: user.id }, { banned: true });
-      throw new CustomError("Too much malware requests. You are now banned.");
-    }
-  }
-
-  throw new CustomError(
-    user ? "Malware detected! Cooldown for 12h." : "Malware detected!"
-  );
-};
-
-export const linksCount = async (user?: User) => {
-  if (!user) return;
-
-  const count = await query.link.total({
-    user_id: user.id,
-    created_at: [">", subDays(new Date(), 1).toISOString()]
-  });
-
-  if (count > env.USER_LIMIT_PER_DAY) {
-    throw new CustomError(
-      `You have reached your daily limit (${env.USER_LIMIT_PER_DAY}). Please wait 24h.`
-    );
-  }
-};
-
-export const bannedDomain = async (domain: string) => {
-  const isBanned = await query.domain.find({
-    address: domain,
-    banned: true
-  });
-
-  if (isBanned) {
-    throw new CustomError("URL is containing malware/scam.", 400);
-  }
-};
-
-export const bannedHost = async (domain: string) => {
-  let isBanned;
-
-  try {
-    const dnsRes = await dnsLookup(domain);
-
-    if (!dnsRes || !dnsRes.address) return;
-
-    isBanned = await query.host.find({
-      address: dnsRes.address,
-      banned: true
-    });
-  } catch (error) {
-    isBanned = null;
-  }
-
-  if (isBanned) {
-    throw new CustomError("URL is containing malware/scam.", 400);
-  }
-};

+ 3 - 3
server/knex.ts → server/knex.js

@@ -1,6 +1,6 @@
-import knex from "knex";
+const knex = require("knex");
 
 
-import env from "./env";
+const env = require("./env");
 
 
 const db = knex({
 const db = knex({
   client: "postgres",
   client: "postgres",
@@ -18,4 +18,4 @@ const db = knex({
   }
   }
 });
 });
 
 
-export default db;
+module.exports = db;

+ 1 - 0
server/mail/index.js

@@ -0,0 +1 @@
+module.exports = require("./mail");

+ 0 - 1
server/mail/index.ts

@@ -1 +0,0 @@
-export * from "./mail";

+ 20 - 16
server/mail/mail.ts → server/mail/mail.js

@@ -1,10 +1,10 @@
-import nodemailer from "nodemailer";
-import path from "path";
-import fs from "fs";
+const nodemailer = require("nodemailer");
+const path = require("path");
+const fs = require("fs");
 
 
-import { resetMailText, verifyMailText, changeEmailText } from "./text";
-import { CustomError } from "../utils";
-import env from "../env";
+const { resetMailText, verifyMailText, changeEmailText } = require("./text");
+const { CustomError } = require("../utils");
+const env = require("../env");
 
 
 const mailConfig = {
 const mailConfig = {
   host: env.MAIL_HOST,
   host: env.MAIL_HOST,
@@ -20,8 +20,6 @@ const mailConfig = {
 
 
 const transporter = nodemailer.createTransport(mailConfig);
 const transporter = nodemailer.createTransport(mailConfig);
 
 
-export default transporter;
-
 // Read email templates
 // Read email templates
 const resetEmailTemplatePath = path.join(__dirname, "template-reset.html");
 const resetEmailTemplatePath = path.join(__dirname, "template-reset.html");
 const verifyEmailTemplatePath = path.join(__dirname, "template-verify.html");
 const verifyEmailTemplatePath = path.join(__dirname, "template-verify.html");
@@ -42,7 +40,7 @@ const changeEmailTemplate = fs
   .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
   .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
   .replace(/{{site_name}}/gm, env.SITE_NAME);
   .replace(/{{site_name}}/gm, env.SITE_NAME);
 
 
-export const verification = async (user: User) => {
+async function verification(user) {
   const mail = await transporter.sendMail({
   const mail = await transporter.sendMail({
     from: env.MAIL_FROM || env.MAIL_USER,
     from: env.MAIL_FROM || env.MAIL_USER,
     to: user.email,
     to: user.email,
@@ -60,9 +58,9 @@ export const verification = async (user: User) => {
   if (!mail.accepted.length) {
   if (!mail.accepted.length) {
     throw new CustomError("Couldn't send verification email. Try again later.");
     throw new CustomError("Couldn't send verification email. Try again later.");
   }
   }
-};
+}
 
 
-export const changeEmail = async (user: User) => {
+async function changeEmail(user) {
   const mail = await transporter.sendMail({
   const mail = await transporter.sendMail({
     from: env.MAIL_FROM || env.MAIL_USER,
     from: env.MAIL_FROM || env.MAIL_USER,
     to: user.change_email_address,
     to: user.change_email_address,
@@ -76,13 +74,13 @@ export const changeEmail = async (user: User) => {
       .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
       .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
       .replace(/{{site_name}}/gm, env.SITE_NAME)
       .replace(/{{site_name}}/gm, env.SITE_NAME)
   });
   });
-
+  
   if (!mail.accepted.length) {
   if (!mail.accepted.length) {
     throw new CustomError("Couldn't send verification email. Try again later.");
     throw new CustomError("Couldn't send verification email. Try again later.");
   }
   }
-};
+}
 
 
-export const resetPasswordToken = async (user: User) => {
+async function resetPasswordToken(user) {
   const mail = await transporter.sendMail({
   const mail = await transporter.sendMail({
     from: env.MAIL_FROM || env.MAIL_USER,
     from: env.MAIL_FROM || env.MAIL_USER,
     to: user.email,
     to: user.email,
@@ -94,10 +92,16 @@ export const resetPasswordToken = async (user: User) => {
       .replace(/{{resetpassword}}/gm, user.reset_password_token)
       .replace(/{{resetpassword}}/gm, user.reset_password_token)
       .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
       .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
   });
   });
-
+  
   if (!mail.accepted.length) {
   if (!mail.accepted.length) {
     throw new CustomError(
     throw new CustomError(
       "Couldn't send reset password email. Try again later."
       "Couldn't send reset password email. Try again later."
     );
     );
   }
   }
-};
+}
+
+module.exports = {
+  changeEmail,
+  verification,
+  resetPasswordToken,
+}

+ 3 - 4
server/mail/text.ts → server/mail/text.js

@@ -1,17 +1,16 @@
-/* eslint-disable max-len */
-export const verifyMailText = `You're attempting to change your email address on {{site_name}}.
+module.exports.verifyMailText = `You're attempting to change your email address on {{site_name}}.
 
 
 Please verify your email address using the link below.
 Please verify your email address using the link below.
 
 
 https://{{domain}}/verify/{{verification}}`;
 https://{{domain}}/verify/{{verification}}`;
 
 
-export const changeEmailText = `Thanks for creating an account on {{site_name}}.
+module.exports.changeEmailText = `Thanks for creating an account on {{site_name}}.
 
 
 Please verify your email address using the link below.
 Please verify your email address using the link below.
 
 
 https://{{domain}}/verify-email/{{verification}}`;
 https://{{domain}}/verify-email/{{verification}}`;
 
 
-export const resetMailText = `A password reset has been requested for your account.
+module.exports.resetMailText = `A password reset has been requested for your account.
 
 
 Please click on the button below to reset your password. There's no need to take any action if you didn't request this.
 Please click on the button below to reset your password. There's no need to take any action if you didn't request this.
 
 

+ 8 - 4
server/migrations/20200211220920_constraints.ts → server/migrations/20200211220920_constraints.js

@@ -1,7 +1,6 @@
-import { Knex } from "knex";
-import * as models from "../models";
+const models = require("../models");
 
 
-export async function up(knex: Knex): Promise<any> {
+async function up(knex) {
   await models.createUserTable(knex);
   await models.createUserTable(knex);
   await models.createIPTable(knex);
   await models.createIPTable(knex);
   await models.createDomainTable(knex);
   await models.createDomainTable(knex);
@@ -37,6 +36,11 @@ export async function up(knex: Knex): Promise<any> {
   ]);
   ]);
 }
 }
 
 
-export async function down(): Promise<any> {
+async function down() {
   // do nothing
   // do nothing
 }
 }
+
+module.exports = {
+  up,
+  down
+}

+ 8 - 4
server/migrations/20200510140704_domains.ts → server/migrations/20200510140704_domains.js

@@ -1,7 +1,6 @@
-import { Knex } from "knex";
-import * as models from "../models";
+const models = require("../models");
 
 
-export async function up(knex: Knex): Promise<any> {
+async function up(knex) {
   await models.createUserTable(knex);
   await models.createUserTable(knex);
   await models.createIPTable(knex);
   await models.createIPTable(knex);
   await models.createDomainTable(knex);
   await models.createDomainTable(knex);
@@ -21,6 +20,11 @@ export async function up(knex: Knex): Promise<any> {
   ]);
   ]);
 }
 }
 
 
-export async function down(): Promise<any> {
+async function down() {
   // do nothing
   // do nothing
 }
 }
+
+module.exports = {
+  up,
+  down
+}

+ 7 - 4
server/migrations/20200718124944_description.ts → server/migrations/20200718124944_description.js

@@ -1,6 +1,4 @@
-import { Knex } from "knex";
-
-export async function up(knex: Knex): Promise<any> {
+async function up(knex) {
   const hasDescription = await knex.schema.hasColumn("links", "description");
   const hasDescription = await knex.schema.hasColumn("links", "description");
   if (!hasDescription) {
   if (!hasDescription) {
     await knex.schema.alterTable("links", table => {
     await knex.schema.alterTable("links", table => {
@@ -9,6 +7,11 @@ export async function up(knex: Knex): Promise<any> {
   }
   }
 }
 }
 
 
-export async function down(): Promise<any> {
+async function down() {
   return null;
   return null;
 }
 }
+
+module.exports = {
+  up,
+  down
+}

+ 7 - 4
server/migrations/20200730203154_expire_in.ts → server/migrations/20200730203154_expire_in.js

@@ -1,6 +1,4 @@
-import { Knex } from "knex";
-
-export async function up(knex: Knex): Promise<any> {
+async function up(knex) {
   const hasExpireIn = await knex.schema.hasColumn("links", "expire_in");
   const hasExpireIn = await knex.schema.hasColumn("links", "expire_in");
   if (!hasExpireIn) {
   if (!hasExpireIn) {
     await knex.schema.alterTable("links", table => {
     await knex.schema.alterTable("links", table => {
@@ -9,6 +7,11 @@ export async function up(knex: Knex): Promise<any> {
   }
   }
 }
 }
 
 
-export async function down(): Promise<any> {
+async function down() {
   return null;
   return null;
 }
 }
+
+module.exports = {
+  up,
+  down
+}

+ 8 - 4
server/migrations/20200810195255_change_email.ts → server/migrations/20200810195255_change_email.js

@@ -1,6 +1,4 @@
-import { Knex } from "knex";
-
-export async function up(knex: Knex): Promise<any> {
+async function up(knex) {
   const hasChangeEmail = await knex.schema.hasColumn(
   const hasChangeEmail = await knex.schema.hasColumn(
     "users",
     "users",
     "change_email_token"
     "change_email_token"
@@ -14,6 +12,12 @@ export async function up(knex: Knex): Promise<any> {
   }
   }
 }
 }
 
 
-export async function down(): Promise<any> {
+async function down() {
   return null;
   return null;
 }
 }
+
+module.exports = {
+  up,
+  down
+}
+

+ 5 - 3
server/models/domain.ts → server/models/domain.js

@@ -1,6 +1,4 @@
-import { Knex } from "knex";
-
-export async function createDomainTable(knex: Knex) {
+async function createDomainTable(knex) {
   const hasTable = await knex.schema.hasTable("domains");
   const hasTable = await knex.schema.hasTable("domains");
   if (!hasTable) {
   if (!hasTable) {
     await knex.schema.raw('create extension if not exists "uuid-ossp"');
     await knex.schema.raw('create extension if not exists "uuid-ossp"');
@@ -32,3 +30,7 @@ export async function createDomainTable(knex: Knex) {
     });
     });
   }
   }
 }
 }
+
+module.exports = {
+  createDomainTable
+}

+ 5 - 3
server/models/host.ts → server/models/host.js

@@ -1,6 +1,4 @@
-import { Knex } from "knex";
-
-export async function createHostTable(knex: Knex) {
+async function createHostTable(knex) {
   const hasTable = await knex.schema.hasTable("hosts");
   const hasTable = await knex.schema.hasTable("hosts");
   if (!hasTable) {
   if (!hasTable) {
     await knex.schema.createTable("hosts", table => {
     await knex.schema.createTable("hosts", table => {
@@ -21,3 +19,7 @@ export async function createHostTable(knex: Knex) {
     });
     });
   }
   }
 }
 }
+
+module.exports = {
+  createHostTable
+}

+ 8 - 0
server/models/index.js

@@ -0,0 +1,8 @@
+module.exports = {
+  ...require("./domain"),
+  ...require("./host"),
+  ...require("./ip"),
+  ...require("./link"),
+  ...require("./user"),
+  ...require("./visit"),
+}

+ 0 - 6
server/models/index.ts

@@ -1,6 +0,0 @@
-export * from "./domain";
-export * from "./host";
-export * from "./ip";
-export * from "./link";
-export * from "./user";
-export * from "./visit";

+ 5 - 3
server/models/ip.ts → server/models/ip.js

@@ -1,6 +1,4 @@
-import { Knex } from "knex";
-
-export async function createIPTable(knex: Knex) {
+async function createIPTable(knex) {
   const hasTable = await knex.schema.hasTable("ips");
   const hasTable = await knex.schema.hasTable("ips");
   if (!hasTable) {
   if (!hasTable) {
     await knex.schema.createTable("ips", table => {
     await knex.schema.createTable("ips", table => {
@@ -13,3 +11,7 @@ export async function createIPTable(knex: Knex) {
     });
     });
   }
   }
 }
 }
+
+module.exports = {
+  createIPTable
+}

+ 5 - 3
server/models/link.ts → server/models/link.js

@@ -1,6 +1,4 @@
-import { Knex } from "knex";
-
-export async function createLinkTable(knex: Knex) {
+async function createLinkTable(knex) {
   const hasTable = await knex.schema.hasTable("links");
   const hasTable = await knex.schema.hasTable("links");
 
 
   if (!hasTable) {
   if (!hasTable) {
@@ -49,3 +47,7 @@ export async function createLinkTable(knex: Knex) {
     });
     });
   }
   }
 }
 }
+
+module.exports = {
+  createLinkTable
+}

+ 5 - 3
server/models/user.ts → server/models/user.js

@@ -1,6 +1,4 @@
-import { Knex } from "knex";
-
-export async function createUserTable(knex: Knex) {
+async function createUserTable(knex) {
   const hasTable = await knex.schema.hasTable("users");
   const hasTable = await knex.schema.hasTable("users");
   if (!hasTable) {
   if (!hasTable) {
     await knex.schema.createTable("users", table => {
     await knex.schema.createTable("users", table => {
@@ -35,3 +33,7 @@ export async function createUserTable(knex: Knex) {
     });
     });
   }
   }
 }
 }
+
+module.exports = {
+  createUserTable
+};

+ 5 - 3
server/models/visit.ts → server/models/visit.js

@@ -1,6 +1,4 @@
-import { Knex } from "knex";
-
-export async function createVisitTable(knex: Knex) {
+async function createVisitTable(knex) {
   const hasTable = await knex.schema.hasTable("visits");
   const hasTable = await knex.schema.hasTable("visits");
   if (!hasTable) {
   if (!hasTable) {
     await knex.schema.createTable("visits", table => {
     await knex.schema.createTable("visits", table => {
@@ -84,3 +82,7 @@ export async function createVisitTable(knex: Knex) {
     });
     });
   }
   }
 }
 }
+
+module.exports = {
+  createVisitTable
+}

+ 7 - 7
server/passport.ts → server/passport.js

@@ -1,11 +1,11 @@
-import { Strategy as LocalAPIKeyStrategy } from "passport-localapikey-update";
-import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt";
-import { Strategy as LocalStrategy } from "passport-local";
-import passport from "passport";
-import bcrypt from "bcryptjs";
+const { Strategy: LocalAPIKeyStrategy } = require("passport-localapikey-update");
+const { Strategy: JwtStrategy, ExtractJwt } = require("passport-jwt");
+const { Strategy: LocalStrategy } = require("passport-local");
+const passport = require("passport");
+const bcrypt = require("bcryptjs");
 
 
-import query from "./queries";
-import env from "./env";
+const query = require("./queries");
+const env = require("./env");
 
 
 const jwtOptions = {
 const jwtOptions = {
   jwtFromRequest: ExtractJwt.fromHeader("authorization"),
   jwtFromRequest: ExtractJwt.fromHeader("authorization"),

+ 76 - 0
server/queries/domain.js

@@ -0,0 +1,76 @@
+const redis = require("../redis");
+const knex = require("../knex");
+
+async function find(match) {
+  if (match.address) {
+    const cachedDomain = await redis.client.get(redis.key.domain(match.address));
+    if (cachedDomain) return JSON.parse(cachedDomain);
+  }
+  
+  const domain = await knex("domains").where(match).first();
+  
+  if (domain) {
+    redis.client.set(
+      redis.key.domain(domain.address),
+      JSON.stringify(domain),
+      "EX",
+      60 * 60 * 6
+    );
+  }
+  
+  return domain;
+}
+
+function get(match) {
+  return knex("domains").where(match);
+}
+
+async function add(params) {
+  params.address = params.address.toLowerCase();
+  const exists = await knex("domains").where("address", params.address).first();
+  
+  const newDomain = {
+    address: params.address,
+    homepage: params.homepage || null,
+    user_id: params.user_id || null,
+    banned: !!params.banned
+  };
+  
+  let domain;
+  if (exists) {
+    const [response] = await knex("domains")
+      .where("id", exists.id)
+      .update(
+        {
+          ...newDomain,
+          updated_at: params.updated_at || new Date().toISOString()
+        },
+        "*"
+      );
+    domain = response;
+  } else {
+    const [response] = await knex("domains").insert(newDomain, "*");
+    domain = response;
+  }
+  
+  redis.remove.domain(domain);
+  
+  return domain;
+}
+
+async function update(match, update) {
+  const domains = await knex("domains")
+    .where(match)
+    .update({ ...update, updated_at: new Date().toISOString() }, "*");
+  
+  domains.forEach(redis.remove.domain);
+  
+  return domains;
+}
+
+module.exports = {
+  add,
+  find,
+  get,
+  update,
+}

+ 0 - 84
server/queries/domain.ts

@@ -1,84 +0,0 @@
-import redisClient, * as redis from "../redis";
-import knex from "../knex";
-
-export const find = async (match: Partial<Domain>): Promise<Domain> => {
-  if (match.address) {
-    const cachedDomain = await redisClient.get(redis.key.domain(match.address));
-    if (cachedDomain) return JSON.parse(cachedDomain);
-  }
-
-  const domain = await knex<Domain>("domains")
-    .where(match)
-    .first();
-
-  if (domain) {
-    redisClient.set(
-      redis.key.domain(domain.address),
-      JSON.stringify(domain),
-      "EX",
-      60 * 60 * 6
-    );
-  }
-
-  return domain;
-};
-
-export const get = async (match: Partial<Domain>): Promise<Domain[]> => {
-  return knex<Domain>("domains").where(match);
-};
-
-interface Add extends Partial<Domain> {
-  address: string;
-}
-
-export const add = async (params: Add) => {
-  params.address = params.address.toLowerCase();
-
-  const exists = await knex<Domain>("domains")
-    .where("address", params.address)
-    .first();
-
-  const newDomain = {
-    address: params.address,
-    homepage: params.homepage || null,
-    user_id: params.user_id || null,
-    banned: !!params.banned
-  };
-
-  let domain: Domain;
-  if (exists) {
-    const [response]: Domain[] = await knex<Domain>("domains")
-      .where("id", exists.id)
-      .update(
-        {
-          ...newDomain,
-          updated_at: params.updated_at || new Date().toISOString()
-        },
-        "*"
-      );
-    domain = response;
-  } else {
-    const [response]: Domain[] = await knex<Domain>("domains").insert(
-      newDomain,
-      "*"
-    );
-    domain = response;
-  }
-
-  redis.remove.domain(domain);
-
-  return domain;
-};
-
-export const update = async (
-  match: Partial<Domain>,
-  update: Partial<Domain>
-) => {
-  const domains = await knex<Domain>("domains")
-    .where(match)
-    .update({ ...update, updated_at: new Date().toISOString() }, "*");
-
-  domains.forEach(redis.remove.domain);
-
-  return domains;
-};

+ 15 - 0
server/queries/index.js

@@ -0,0 +1,15 @@
+// const visit = require("./visit");
+const domain = require("./domain");
+const link = require("./link");
+const user = require("./user");
+// const host = require("./host");
+const ip = require("./ip");
+
+module.exports = {
+  domain,
+  // host,
+  ip,
+  link,
+  user,
+  // visit
+};

+ 0 - 17
server/queries/index.ts

@@ -1,17 +0,0 @@
-import * as domain from "./domain";
-import * as visit from "./visit";
-import * as link from "./link";
-import * as user from "./user";
-import * as host from "./host";
-import * as ip from "./ip";
-
-const queries = {
-  domain,
-  host,
-  ip,
-  link,
-  user,
-  visit
-};
-
-export default queries;

+ 53 - 0
server/queries/ip.js

@@ -0,0 +1,53 @@
+const { subMinutes } = require("date-fns");
+
+const knex = require("../knex");
+const env = require("../env");
+
+async function add(ipToAdd) {
+  const ip = ipToAdd.toLowerCase();
+  
+  const currentIP = await knex("ips").where("ip", ip).first();
+  
+  if (currentIP) {
+    const currentDate = new Date().toISOString();
+    await knex("ips")
+      .where({ ip })
+      .update({
+        created_at: currentDate,
+        updated_at: currentDate
+      });
+  } else {
+    await knex("ips").insert({ ip });
+  }
+  
+  return ip;
+}
+
+
+async function find(match) {
+  const query = knex("ips");
+  
+  Object.entries(match).forEach(([key, value]) => {
+    query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
+  });
+  
+  const ip = await query.first();
+  
+  return ip;
+}
+
+function clear() {
+  return knex<IP>("ips")
+  .where(
+    "created_at",
+    "<",
+    subMinutes(new Date(), env.NON_USER_COOLDOWN).toISOString()
+  )
+  .delete();
+}
+
+module.exports = {
+  add,
+  clear,
+  find,
+}

+ 0 - 47
server/queries/ip.ts

@@ -1,47 +0,0 @@
-import { subMinutes } from "date-fns";
-
-import knex from "../knex";
-import env from "../env";
-
-export const add = async (ipToAdd: string) => {
-  const ip = ipToAdd.toLowerCase();
-
-  const currentIP = await knex<IP>("ips")
-    .where("ip", ip)
-    .first();
-
-  if (currentIP) {
-    const currentDate = new Date().toISOString();
-    await knex<IP>("ips")
-      .where({ ip })
-      .update({
-        created_at: currentDate,
-        updated_at: currentDate
-      });
-  } else {
-    await knex<IP>("ips").insert({ ip });
-  }
-
-  return ip;
-};
-
-export const find = async (match: Match<IP>) => {
-  const query = knex<IP>("ips");
-
-  Object.entries(match).forEach(([key, value]) => {
-    query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
-  });
-
-  const ip = await query.first();
-
-  return ip;
-};
-
-export const clear = async () =>
-  knex<IP>("ips")
-    .where(
-      "created_at",
-      "<",
-      subMinutes(new Date(), env.NON_USER_COOLDOWN).toISOString()
-    )
-    .delete();

+ 71 - 81
server/queries/link.ts → server/queries/link.js

@@ -1,8 +1,9 @@
-import bcrypt from "bcryptjs";
+const bcrypt = require("bcryptjs");
 
 
-import { CustomError } from "../utils";
-import redisClient, * as redis from "../redis";
-import knex from "../knex";
+// FIXME: circular dependency
+const CustomError = require("../utils").CustomError;
+const redis = require("../redis");
+const knex = require("../knex");
 
 
 const selectable = [
 const selectable = [
   "links.id",
   "links.id",
@@ -21,7 +22,7 @@ const selectable = [
   "domains.address as domain"
   "domains.address as domain"
 ];
 ];
 
 
-const normalizeMatch = (match: Partial<Link>): Partial<Link> => {
+function normalizeMatch(match) {
   const newMatch = { ...match };
   const newMatch = { ...match };
 
 
   if (newMatch.address) {
   if (newMatch.address) {
@@ -42,92 +43,76 @@ const normalizeMatch = (match: Partial<Link>): Partial<Link> => {
   return newMatch;
   return newMatch;
 };
 };
 
 
-interface TotalParams {
-  search?: string;
-}
-
-export const total = async (match: Match<Link>, params: TotalParams = {}) => {
-  const query = knex<Link>("links");
-
+async function total(match, params) {
+  const query = knex("links");
   Object.entries(match).forEach(([key, value]) => {
   Object.entries(match).forEach(([key, value]) => {
     query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
     query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
   });
   });
-
-  if (params.search) {
+  
+  if (params?.search) {
     query.andWhereRaw(
     query.andWhereRaw(
       "links.description || ' '  || links.address || ' ' || target ILIKE '%' || ? || '%'",
       "links.description || ' '  || links.address || ' ' || target ILIKE '%' || ? || '%'",
       [params.search]
       [params.search]
     );
     );
   }
   }
-
+  
   const [{ count }] = await query.count("id");
   const [{ count }] = await query.count("id");
-
+  
   return typeof count === "number" ? count : parseInt(count);
   return typeof count === "number" ? count : parseInt(count);
-};
-
-interface GetParams {
-  limit: number;
-  search?: string;
-  skip: number;
 }
 }
 
 
-export const get = async (match: Partial<Link>, params: GetParams) => {
-  const query = knex<LinkJoinedDomain>("links")
+async function get(match, params) {
+  const query = knex("links")
     .select(...selectable)
     .select(...selectable)
     .where(normalizeMatch(match))
     .where(normalizeMatch(match))
     .offset(params.skip)
     .offset(params.skip)
     .limit(params.limit)
     .limit(params.limit)
     .orderBy("created_at", "desc");
     .orderBy("created_at", "desc");
-
-  if (params.search) {
+  
+  if (params?.search) {
     query.andWhereRaw(
     query.andWhereRaw(
       "concat_ws(' ', description, links.address, target, domains.address) ILIKE '%' || ? || '%'",
       "concat_ws(' ', description, links.address, target, domains.address) ILIKE '%' || ? || '%'",
       [params.search]
       [params.search]
     );
     );
   }
   }
-
+  
   query.leftJoin("domains", "links.domain_id", "domains.id");
   query.leftJoin("domains", "links.domain_id", "domains.id");
-
-  const links: LinkJoinedDomain[] = await query;
-
+  
+  const links = await query;
+  
   return links;
   return links;
-};
+}
 
 
-export const find = async (match: Partial<Link>): Promise<Link> => {
+async function find(match) {
   if (match.address && match.domain_id) {
   if (match.address && match.domain_id) {
     const key = redis.key.link(match.address, match.domain_id);
     const key = redis.key.link(match.address, match.domain_id);
-    const cachedLink = await redisClient.get(key);
+    const cachedLink = await redis.client.get(key);
     if (cachedLink) return JSON.parse(cachedLink);
     if (cachedLink) return JSON.parse(cachedLink);
   }
   }
-
-  const link = await knex<Link>("links")
+  
+  const link = await knex("links")
     .select(...selectable)
     .select(...selectable)
     .where(normalizeMatch(match))
     .where(normalizeMatch(match))
     .leftJoin("domains", "links.domain_id", "domains.id")
     .leftJoin("domains", "links.domain_id", "domains.id")
     .first();
     .first();
-
+  
   if (link) {
   if (link) {
     const key = redis.key.link(link.address, link.domain_id);
     const key = redis.key.link(link.address, link.domain_id);
-    redisClient.set(key, JSON.stringify(link), "EX", 60 * 60 * 2);
+    redis.client.set(key, JSON.stringify(link), "EX", 60 * 60 * 2);
   }
   }
-
+  
   return link;
   return link;
-};
-
-interface Create extends Partial<Link> {
-  address: string;
-  target: string;
 }
 }
 
 
-export const create = async (params: Create) => {
-  let encryptedPassword: string = null;
-
+async function create(params) {
+  let encryptedPassword = null;
+  
   if (params.password) {
   if (params.password) {
     const salt = await bcrypt.genSalt(12);
     const salt = await bcrypt.genSalt(12);
     encryptedPassword = await bcrypt.hash(params.password, salt);
     encryptedPassword = await bcrypt.hash(params.password, salt);
   }
   }
-
-  const [link]: LinkJoinedDomain[] = await knex<LinkJoinedDomain>(
+  
+  const [link] = await knex(
     "links"
     "links"
   ).insert(
   ).insert(
     {
     {
@@ -141,61 +126,66 @@ export const create = async (params: Create) => {
     },
     },
     "*"
     "*"
   );
   );
-
+  
   return link;
   return link;
-};
-
-export const remove = async (match: Partial<Link>) => {
-  const link = await knex<Link>("links")
-    .where(match)
-    .first();
+}
 
 
+async function remove(match) {
+  const link = await knex("links").where(match).first();
+  
   if (!link) {
   if (!link) {
     throw new CustomError("Link was not found.");
     throw new CustomError("Link was not found.");
   }
   }
-
-  const deletedLink = await knex<Link>("links")
-    .where("id", link.id)
-    .delete();
-
+  
+  const deletedLink = await knex("links").where("id", link.id).delete();
   redis.remove.link(link);
   redis.remove.link(link);
-
+  
   return !!deletedLink;
   return !!deletedLink;
-};
-
-export const batchRemove = async (match: Match<Link>) => {
-  const deleteQuery = knex<Link>("links");
-  const findQuery = knex<Link>("links");
+}
 
 
+async function batchRemove(match) {
+  const deleteQuery = knex("links");
+  const findQuery = knex("links");
+  
   Object.entries(match).forEach(([key, value]) => {
   Object.entries(match).forEach(([key, value]) => {
     findQuery.andWhere(key, ...(Array.isArray(value) ? value : [value]));
     findQuery.andWhere(key, ...(Array.isArray(value) ? value : [value]));
     deleteQuery.andWhere(key, ...(Array.isArray(value) ? value : [value]));
     deleteQuery.andWhere(key, ...(Array.isArray(value) ? value : [value]));
   });
   });
-
+  
   const links = await findQuery;
   const links = await findQuery;
-
+  
   links.forEach(redis.remove.link);
   links.forEach(redis.remove.link);
-
+  
   await deleteQuery.delete();
   await deleteQuery.delete();
-};
+}
 
 
-export const update = async (match: Partial<Link>, update: Partial<Link>) => {
+async function update(match, update) {
   if (update.password) {
   if (update.password) {
     const salt = await bcrypt.genSalt(12);
     const salt = await bcrypt.genSalt(12);
     update.password = await bcrypt.hash(update.password, salt);
     update.password = await bcrypt.hash(update.password, salt);
   }
   }
-
-  const links = await knex<Link>("links")
+  
+  const links = await knex("links")
     .where(match)
     .where(match)
     .update({ ...update, updated_at: new Date().toISOString() }, "*");
     .update({ ...update, updated_at: new Date().toISOString() }, "*");
-
+  
   links.forEach(redis.remove.link);
   links.forEach(redis.remove.link);
-
+  
   return links;
   return links;
-};
+}
 
 
-export const incrementVisit = async (match: Partial<Link>) => {
-  return knex<Link>("links")
-    .where(match)
-    .increment("visit_count", 1);
-};
+function incrementVisit(match) {
+  return knex("links").where(match).increment("visit_count", 1);
+}
+
+module.exports = {
+  normalizeMatch,
+  batchRemove,
+  create,
+  find,
+  get,
+  incrementVisit,
+  remove,
+  total,
+  update,
+}

+ 40 - 37
server/queries/user.ts → server/queries/user.js

@@ -1,81 +1,84 @@
-import { v4 as uuid } from "uuid";
-import { addMinutes } from "date-fns";
+const { addMinutes } = require("date-fns");
+const { v4: uuid } = require("uuid");
 
 
-import redisCLient, * as redis from "../redis";
-import knex from "../knex";
+const redis = require("../redis");
+const knex = require("../knex");
 
 
-export const find = async (match: Partial<User>) => {
+async function find(match) {
   if (match.email || match.apikey) {
   if (match.email || match.apikey) {
     const key = redis.key.user(match.email || match.apikey);
     const key = redis.key.user(match.email || match.apikey);
-    const cachedUser = await redisCLient.get(key);
-    if (cachedUser) return JSON.parse(cachedUser) as User;
+    const cachedUser = await redis.client.get(key);
+    if (cachedUser) return JSON.parse(cachedUser);
   }
   }
-
-  const user = await knex<User>("users").where(match).first();
-
+  
+  const user = await knex("users").where(match).first();
+  
   if (user) {
   if (user) {
     const emailKey = redis.key.user(user.email);
     const emailKey = redis.key.user(user.email);
-    redisCLient.set(emailKey, JSON.stringify(user), "EX", 60 * 60 * 1);
-
+    redis.client.set(emailKey, JSON.stringify(user), "EX", 60 * 60 * 1);
+  
     if (user.apikey) {
     if (user.apikey) {
       const apikeyKey = redis.key.user(user.apikey);
       const apikeyKey = redis.key.user(user.apikey);
-      redisCLient.set(apikeyKey, JSON.stringify(user), "EX", 60 * 60 * 1);
+      redis.client.set(apikeyKey, JSON.stringify(user), "EX", 60 * 60 * 1);
     }
     }
   }
   }
-
+  
   return user;
   return user;
-};
 
 
-interface Add {
-  email: string;
-  password: string;
 }
 }
 
 
-export const add = async (params: Add, user?: User) => {
+async function add(params, user) {
   const data = {
   const data = {
     email: params.email,
     email: params.email,
     password: params.password,
     password: params.password,
     verification_token: uuid(),
     verification_token: uuid(),
     verification_expires: addMinutes(new Date(), 60).toISOString()
     verification_expires: addMinutes(new Date(), 60).toISOString()
   };
   };
-
+  
   if (user) {
   if (user) {
-    await knex<User>("users")
+    await knex("users")
       .where("id", user.id)
       .where("id", user.id)
       .update({ ...data, updated_at: new Date().toISOString() });
       .update({ ...data, updated_at: new Date().toISOString() });
   } else {
   } else {
-    await knex<User>("users").insert(data);
+    await knex("users").insert(data);
   }
   }
-
+  
   redis.remove.user(user);
   redis.remove.user(user);
-
+  
   return {
   return {
     ...user,
     ...user,
     ...data
     ...data
   };
   };
-};
-
-export const update = async (match: Match<User>, update: Partial<User>) => {
-  const query = knex<User>("users");
+}
 
 
+async function update(match, update) {
+  const query = knex("users");
+  
   Object.entries(match).forEach(([key, value]) => {
   Object.entries(match).forEach(([key, value]) => {
     query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
     query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
   });
   });
-
+  
   const users = await query.update(
   const users = await query.update(
     { ...update, updated_at: new Date().toISOString() },
     { ...update, updated_at: new Date().toISOString() },
     "*"
     "*"
   );
   );
-
+  
   users.forEach(redis.remove.user);
   users.forEach(redis.remove.user);
-
+  
   return users;
   return users;
-};
-
-export const remove = async (user: User) => {
-  const deletedUser = await knex<User>("users").where("id", user.id).delete();
+}
 
 
+async function remove(user) {
+  const deletedUser = await knex("users").where("id", user.id).delete();
+  
   redis.remove.user(user);
   redis.remove.user(user);
-
+  
   return !!deletedUser;
   return !!deletedUser;
-};
+}
+
+module.exports = {
+  add,
+  find,
+  remove,
+  update,
+}

+ 1 - 1
server/queries/visit.ts

@@ -1,6 +1,6 @@
 import { isAfter, subDays, set } from "date-fns";
 import { isAfter, subDays, set } from "date-fns";
 
 
-import * as utils from "../utils";
+import * as utils from "../utils/utils";
 import redisClient, * as redis from "../redis";
 import redisClient, * as redis from "../redis";
 import knex from "../knex";
 import knex from "../knex";
 
 

+ 1 - 1
server/queues/visit.ts

@@ -3,7 +3,7 @@ import geoip from "geoip-lite";
 import URL from "url";
 import URL from "url";
 
 
 import query from "../queries";
 import query from "../queries";
-import { getStatsLimit, removeWww } from "../utils";
+import { getStatsLimit, removeWww } from "../utils/utils";
 
 
 const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"];
 const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"];
 const osList = ["Windows", "Mac OS", "Linux", "Android", "iOS"];
 const osList = ["Windows", "Mac OS", "Linux", "Android", "iOS"];

+ 20 - 16
server/redis.ts → server/redis.js

@@ -1,6 +1,6 @@
-import Redis from "ioredis";
+const Redis = require("ioredis");
 
 
-import env from "./env";
+const env = require("./env");
 
 
 const client = new Redis({
 const client = new Redis({
   host: env.REDIS_HOST,
   host: env.REDIS_HOST,
@@ -9,31 +9,28 @@ const client = new Redis({
   ...(env.REDIS_PASSWORD && { password: env.REDIS_PASSWORD })
   ...(env.REDIS_PASSWORD && { password: env.REDIS_PASSWORD })
 });
 });
 
 
-export default client;
-
-export const key = {
-  link: (address: string, domain_id?: number, user_id?: number) =>
-    `${address}-${domain_id || ""}-${user_id || ""}`,
-  domain: (address: string) => `d-${address}`,
-  stats: (link_id: number) => `s-${link_id}`,
-  host: (address: string) => `h-${address}`,
-  user: (emailOrKey: string) => `u-${emailOrKey}`
+const key = {
+  link: (address, domain_id, user_id) => `${address}-${domain_id || ""}-${user_id || ""}`,
+  domain: (address) => `d-${address}`,
+  stats: (link_id) => `s-${link_id}`,
+  host: (address) => `h-${address}`,
+  user: (emailOrKey) => `u-${emailOrKey}`
 };
 };
 
 
-export const remove = {
-  domain: (domain?: Domain) => {
+const remove = {
+  domain: (domain) => {
     if (!domain) return;
     if (!domain) return;
     return client.del(key.domain(domain.address));
     return client.del(key.domain(domain.address));
   },
   },
-  host: (host?: Host) => {
+  host: (host) => {
     if (!host) return;
     if (!host) return;
     return client.del(key.host(host.address));
     return client.del(key.host(host.address));
   },
   },
-  link: (link?: Link) => {
+  link: (link) => {
     if (!link) return;
     if (!link) return;
     return client.del(key.link(link.address, link.domain_id));
     return client.del(key.link(link.address, link.domain_id));
   },
   },
-  user: (user?: User) => {
+  user: (user) => {
     if (!user) return;
     if (!user) return;
     return Promise.all([
     return Promise.all([
       client.del(key.user(user.email)),
       client.del(key.user(user.email)),
@@ -41,3 +38,10 @@ export const remove = {
     ]);
     ]);
   }
   }
 };
 };
+
+
+module.exports = {
+  client,
+  key,
+  remove,
+}

+ 1 - 0
server/renders/index.js

@@ -0,0 +1 @@
+module.exports = require("./renders");

+ 18 - 0
server/renders/renders.js

@@ -0,0 +1,18 @@
+const { Router } = require("express");
+
+const router = Router();
+
+router.get("/", function homepage(req, res) {
+  console.log(req.cookies);
+  res.render("homepage", {
+    title: "Modern open source URL shortener"
+  });
+});
+
+router.get("/login", function login(req, res) {
+  res.render("login", {
+    title: "Log in or sign up"
+  });
+});
+
+module.exports = router;

+ 52 - 0
server/routes/auth.js

@@ -0,0 +1,52 @@
+const asyncHandler = require("express-async-handler");
+const { Router } = require("express");
+
+const validators = require("../handlers/validators");
+const helpers = require("../handlers/helpers");
+const auth = require("../handlers/auth");
+
+const router = Router();
+
+router.post(
+  "/login",
+  validators.login,
+  asyncHandler(helpers.verify("partials/login_signup")),
+  asyncHandler(auth.local),
+  asyncHandler(auth.login)
+);
+
+router.post(
+  "/signup",
+  auth.signupAccess,
+  validators.signup,
+  asyncHandler(helpers.verify("partials/login_signup")),
+  asyncHandler(auth.signup)
+);
+
+// router.post("/renew", asyncHandler(auth.jwt), asyncHandler(auth.token));
+
+// router.post(
+//   "/change-password",
+//   asyncHandler(auth.jwt),
+//   validators.changePassword,
+//   asyncHandler(helpers.verify),
+//   asyncHandler(auth.changePassword)
+// );
+
+// router.post(
+//   "/change-email",
+//   asyncHandler(auth.jwt),
+//   validators.changePassword,
+//   asyncHandler(helpers.verify),
+//   asyncHandler(auth.changeEmailRequest)
+// );
+
+// router.post(
+//   "/apikey",
+//   asyncHandler(auth.jwt),
+//   asyncHandler(auth.generateApiKey)
+// );
+
+// router.post("/reset-password", asyncHandler(auth.resetPasswordRequest));
+
+module.exports = router;

+ 0 - 52
server/routes/auth.ts

@@ -1,52 +0,0 @@
-import asyncHandler from "express-async-handler";
-import { Router } from "express";
-
-import * as validators from "../handlers/validators";
-import * as helpers from "../handlers/helpers";
-import * as auth from "../handlers/auth";
-
-const router = Router();
-
-router.post(
-  "/login",
-  validators.login,
-  asyncHandler(helpers.verify),
-  asyncHandler(auth.local),
-  asyncHandler(auth.token)
-);
-
-router.post(
-  "/signup",
-  auth.signupAccess,
-  validators.signup,
-  asyncHandler(helpers.verify),
-  asyncHandler(auth.signup)
-);
-
-router.post("/renew", asyncHandler(auth.jwt), asyncHandler(auth.token));
-
-router.post(
-  "/change-password",
-  asyncHandler(auth.jwt),
-  validators.changePassword,
-  asyncHandler(helpers.verify),
-  asyncHandler(auth.changePassword)
-);
-
-router.post(
-  "/change-email",
-  asyncHandler(auth.jwt),
-  validators.changePassword,
-  asyncHandler(helpers.verify),
-  asyncHandler(auth.changeEmailRequest)
-);
-
-router.post(
-  "/apikey",
-  asyncHandler(auth.jwt),
-  asyncHandler(auth.generateApiKey)
-);
-
-router.post("/reset-password", asyncHandler(auth.resetPasswordRequest));
-
-export default router;

+ 2 - 2
server/routes/domains.ts

@@ -13,7 +13,7 @@ router.post(
   asyncHandler(auth.apikey),
   asyncHandler(auth.apikey),
   asyncHandler(auth.jwt),
   asyncHandler(auth.jwt),
   validators.addDomain,
   validators.addDomain,
-  asyncHandler(helpers.verify),
+  asyncHandler(helpers.verify()),
   asyncHandler(domains.add)
   asyncHandler(domains.add)
 );
 );
 
 
@@ -22,7 +22,7 @@ router.delete(
   asyncHandler(auth.apikey),
   asyncHandler(auth.apikey),
   asyncHandler(auth.jwt),
   asyncHandler(auth.jwt),
   validators.removeDomain,
   validators.removeDomain,
-  asyncHandler(helpers.verify),
+  asyncHandler(helpers.verify()),
   asyncHandler(domains.remove)
   asyncHandler(domains.remove)
 );
 );
 
 

+ 1 - 0
server/routes/index.js

@@ -0,0 +1 @@
+module.exports = require("./routes");

+ 0 - 1
server/routes/index.ts

@@ -1 +0,0 @@
-export { default } from "./routes";

+ 83 - 0
server/routes/links.js

@@ -0,0 +1,83 @@
+const { Router } = require("express");
+const asyncHandler = require("express-async-handler");
+const cors = require("cors");
+
+const validators = require("../handlers/validators");
+
+const helpers = require("../handlers/helpers");
+const link = require("../handlers/links");
+const auth = require("../handlers/auth");
+const env = require("../env");
+
+const router = Router();
+
+// router.get(
+//   "/",
+//   asyncHandler(auth.apikey),
+//   asyncHandler(auth.jwt),
+//   helpers.query,
+//   asyncHandler(link.get)
+// );
+
+router.post(
+  "/",
+  cors(),
+  asyncHandler(auth.apikey),
+  asyncHandler(env.DISALLOW_ANONYMOUS_LINKS ? auth.jwt : auth.jwtLoose),
+  asyncHandler(auth.cooldown),
+  validators.createLink,
+  asyncHandler(helpers.verify()),
+  asyncHandler(link.create)
+);
+
+// router.patch(
+//   "/:id",
+//   asyncHandler(auth.apikey),
+//   asyncHandler(auth.jwt),
+//   validators.editLink,
+//   asyncHandler(helpers.verify),
+//   asyncHandler(link.edit)
+// );
+
+// router.delete(
+//   "/:id",
+//   asyncHandler(auth.apikey),
+//   asyncHandler(auth.jwt),
+//   validators.deleteLink,
+//   asyncHandler(helpers.verify),
+//   asyncHandler(link.remove)
+// );
+
+// router.get(
+//   "/:id/stats",
+//   asyncHandler(auth.apikey),
+//   asyncHandler(auth.jwt),
+//   validators.getStats,
+//   asyncHandler(link.stats)
+// );
+
+// router.post(
+//   "/:id/protected",
+//   validators.redirectProtected,
+//   asyncHandler(helpers.verify),
+//   asyncHandler(link.redirectProtected)
+// );
+
+// router.post(
+//   "/report",
+//   validators.reportLink,
+//   asyncHandler(helpers.verify),
+//   asyncHandler(link.report)
+// );
+
+// router.post(
+//   "/admin/ban/:id",
+//   asyncHandler(auth.apikey),
+//   asyncHandler(auth.jwt),
+//   asyncHandler(auth.admin),
+//   validators.banLink,
+//   asyncHandler(helpers.verify),
+//   asyncHandler(link.ban)
+// );
+
+module.exports = router;

+ 0 - 83
server/routes/links.ts

@@ -1,83 +0,0 @@
-import { Router } from "express";
-import asyncHandler from "express-async-handler";
-import cors from "cors";
-
-import * as validators from "../handlers/validators";
-import * as helpers from "../handlers/helpers";
-import * as link from "../handlers/links";
-import * as auth from "../handlers/auth";
-import env from "../env";
-
-const router = Router();
-
-router.get(
-  "/",
-  asyncHandler(auth.apikey),
-  asyncHandler(auth.jwt),
-  helpers.query,
-  asyncHandler(link.get)
-);
-
-router.post(
-  "/",
-  cors(),
-  asyncHandler(auth.apikey),
-  asyncHandler(env.DISALLOW_ANONYMOUS_LINKS ? auth.jwt : auth.jwtLoose),
-  asyncHandler(auth.recaptcha),
-  asyncHandler(auth.cooldown),
-  validators.createLink,
-  asyncHandler(helpers.verify),
-  asyncHandler(link.create)
-);
-
-router.patch(
-  "/:id",
-  asyncHandler(auth.apikey),
-  asyncHandler(auth.jwt),
-  validators.editLink,
-  asyncHandler(helpers.verify),
-  asyncHandler(link.edit)
-);
-
-router.delete(
-  "/:id",
-  asyncHandler(auth.apikey),
-  asyncHandler(auth.jwt),
-  validators.deleteLink,
-  asyncHandler(helpers.verify),
-  asyncHandler(link.remove)
-);
-
-router.get(
-  "/:id/stats",
-  asyncHandler(auth.apikey),
-  asyncHandler(auth.jwt),
-  validators.getStats,
-  asyncHandler(link.stats)
-);
-
-router.post(
-  "/:id/protected",
-  validators.redirectProtected,
-  asyncHandler(helpers.verify),
-  asyncHandler(link.redirectProtected)
-);
-
-router.post(
-  "/report",
-  validators.reportLink,
-  asyncHandler(helpers.verify),
-  asyncHandler(link.report)
-);
-
-router.post(
-  "/admin/ban/:id",
-  asyncHandler(auth.apikey),
-  asyncHandler(auth.jwt),
-  asyncHandler(auth.admin),
-  validators.banLink,
-  asyncHandler(helpers.verify),
-  asyncHandler(link.ban)
-);
-
-export default router;

+ 17 - 0
server/routes/routes.js

@@ -0,0 +1,17 @@
+const { Router } = require("express");
+
+// import domains from "./domains";
+// import health from "./health";
+const links = require("./links");
+// import user from "./users";
+const auth = require("./auth");
+
+const router = Router();
+
+// router.use("/domains", domains);
+// router.use("/health", health);
+router.use("/links", links);
+// router.use("/users", user);
+router.use("/auth", auth);
+
+module.exports = router;

+ 0 - 17
server/routes/routes.ts

@@ -1,17 +0,0 @@
-import { Router } from "express";
-
-import domains from "./domains";
-import health from "./health";
-import links from "./links";
-import user from "./users";
-import auth from "./auth";
-
-const router = Router();
-
-router.use("/domains", domains);
-router.use("/health", health);
-router.use("/links", links);
-router.use("/users", user);
-router.use("/auth", auth);
-
-export default router;

+ 1 - 1
server/routes/users.ts

@@ -20,7 +20,7 @@ router.post(
   asyncHandler(auth.apikey),
   asyncHandler(auth.apikey),
   asyncHandler(auth.jwt),
   asyncHandler(auth.jwt),
   validators.deleteUser,
   validators.deleteUser,
-  asyncHandler(helpers.verify),
+  asyncHandler(helpers.verif()),
   asyncHandler(user.remove)
   asyncHandler(user.remove)
 );
 );
 
 

+ 81 - 0
server/server.js

@@ -0,0 +1,81 @@
+const env = require("./env");
+
+// import asyncHandler from "express-async-handler";
+// import passport from "passport";
+const cookieParser = require("cookie-parser");
+const express = require("express");
+const helmet = require("helmet");
+const morgan = require("morgan");
+const path = require("path");
+const hbs = require("hbs");
+
+const helpers = require("./handlers/helpers");
+// import * as links from "./handlers/links";
+// import * as auth from "./handlers/auth";
+const routes = require("./routes");
+const renders = require("./renders");
+const utils = require("./utils");
+const { stream } = require("./config/winston")
+
+// import "./cron";
+require("./passport");
+
+const app = express();
+
+// TODO: comments
+app.set("trust proxy", true);
+
+if (env.isDev) {
+  app.use(morgan("combined", { stream }));
+}
+
+app.use(helmet({ contentSecurityPolicy: false }));
+app.use(cookieParser());
+app.use(express.json());
+app.use(express.urlencoded({ extended: true }));
+app.use(express.static("static"));
+
+// app.use(passport.initialize());
+// app.use(helpers.ip);
+
+// template engine / serve html
+app.set("view engine", "hbs");
+app.set("views", path.join(__dirname, "views"));
+utils.extendHbs();
+
+app.use("/", renders);
+
+// app.use(asyncHandler(links.redirectCustomDomain));
+
+app.use("/api/v2", routes);
+app.use("/api", routes);
+
+  // server.get(
+  //   "/reset-password/:resetPasswordToken?",
+  //   asyncHandler(auth.resetPassword),
+  //   (req, res) => app.render(req, res, "/reset-password", { token: req.token })
+  // );
+
+  // server.get(
+  //   "/verify-email/:changeEmailToken",
+  //   asyncHandler(auth.changeEmail),
+  //   (req, res) => app.render(req, res, "/verify-email", { token: req.token })
+  // );
+
+  // server.get(
+  //   "/verify/:verificationToken?",
+  //   asyncHandler(auth.verify),
+  //   (req, res) => app.render(req, res, "/verify", { token: req.token })
+  // );
+
+  // server.get("/:id", asyncHandler(links.redirect(app)));
+
+// Error handler
+app.use(helpers.error);
+
+  // Handler everything else by Next.js
+  // server.get("*", (req, res) => handle(req, res));
+  
+app.listen(env.PORT, () => {
+  console.log(`> Ready on http://localhost:${env.PORT}`);
+});

+ 0 - 74
server/server.ts

@@ -1,74 +0,0 @@
-import env from "./env";
-
-import asyncHandler from "express-async-handler";
-import cookieParser from "cookie-parser";
-import passport from "passport";
-import express from "express";
-import helmet from "helmet";
-import morgan from "morgan";
-import nextApp from "next";
-
-import * as helpers from "./handlers/helpers";
-import * as links from "./handlers/links";
-import * as auth from "./handlers/auth";
-import routes from "./routes";
-import { stream } from "./config/winston";
-
-import "./cron";
-import "./passport";
-
-const port = env.PORT;
-const app = nextApp({ dir: "./client", dev: env.isDev });
-const handle = app.getRequestHandler();
-
-app.prepare().then(async () => {
-  const server = express();
-
-  server.set("trust proxy", true);
-
-  if (env.isDev) {
-    server.use(morgan("combined", { stream }));
-  }
-
-  server.use(helmet({ contentSecurityPolicy: false }));
-  server.use(cookieParser());
-  server.use(express.json());
-  server.use(express.urlencoded({ extended: true }));
-  server.use(passport.initialize());
-  server.use(express.static("static"));
-  server.use(helpers.ip);
-
-  server.use(asyncHandler(links.redirectCustomDomain));
-
-  server.use("/api/v2", routes);
-
-  server.get(
-    "/reset-password/:resetPasswordToken?",
-    asyncHandler(auth.resetPassword),
-    (req, res) => app.render(req, res, "/reset-password", { token: req.token })
-  );
-
-  server.get(
-    "/verify-email/:changeEmailToken",
-    asyncHandler(auth.changeEmail),
-    (req, res) => app.render(req, res, "/verify-email", { token: req.token })
-  );
-
-  server.get(
-    "/verify/:verificationToken?",
-    asyncHandler(auth.verify),
-    (req, res) => app.render(req, res, "/verify", { token: req.token })
-  );
-
-  server.get("/:id", asyncHandler(links.redirect(app)));
-
-  // Error handler
-  server.use(helpers.error);
-
-  // Handler everything else by Next.js
-  server.get("*", (req, res) => handle(req, res));
-
-  server.listen(port, () => {
-    console.log(`> Ready on http://localhost:${port}`);
-  });
-});

+ 1 - 0
server/utils/index.js

@@ -0,0 +1 @@
+module.exports = require("./utils");

+ 0 - 170
server/utils/index.ts

@@ -1,170 +0,0 @@
-import ms from "ms";
-import nanoid from "nanoid/generate";
-import JWT from "jsonwebtoken";
-import {
-  differenceInDays,
-  differenceInHours,
-  differenceInMonths,
-  addDays
-} from "date-fns";
-
-import query from "../queries";
-import env from "../env";
-
-export class CustomError extends Error {
-  public statusCode?: number;
-  public data?: any;
-  public constructor(message: string, statusCode = 500, data?: any) {
-    super(message);
-    this.name = this.constructor.name;
-    this.statusCode = statusCode;
-    this.data = data;
-  }
-}
-
-export const isAdmin = (email: string): boolean =>
-  env.ADMIN_EMAILS.split(",")
-    .map((e) => e.trim())
-    .includes(email);
-
-export const signToken = (user: UserJoined) =>
-  JWT.sign(
-    {
-      iss: "ApiAuth",
-      sub: user.email,
-      domain: user.domain || "",
-      admin: isAdmin(user.email),
-      iat: parseInt((new Date().getTime() / 1000).toFixed(0)),
-      exp: parseInt((addDays(new Date(), 7).getTime() / 1000).toFixed(0))
-    } as Record<string, any>,
-    env.JWT_SECRET
-  );
-
-export const generateId = async (domain_id: number = null) => {
-  const address = nanoid(
-    "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789",
-    env.LINK_LENGTH
-  );
-  const link = await query.link.find({ address, domain_id });
-  if (!link) return address;
-  return generateId(domain_id);
-};
-
-export const addProtocol = (url: string): string => {
-  const hasProtocol = /^\w+:\/\//.test(url);
-  return hasProtocol ? url : `http://${url}`;
-};
-
-export const generateShortLink = (id: string, domain?: string): string => {
-  const protocol =
-    env.CUSTOM_DOMAIN_USE_HTTPS || !domain ? "https://" : "http://";
-  return `${protocol}${domain || env.DEFAULT_DOMAIN}/${id}`;
-};
-
-export const getRedisKey = {
-  // TODO: remove user id and make domain id required
-  link: (address: string, domain_id?: number, user_id?: number) =>
-    `${address}-${domain_id || ""}-${user_id || ""}`,
-  domain: (address: string) => `d-${address}`,
-  host: (address: string) => `h-${address}`,
-  user: (emailOrKey: string) => `u-${emailOrKey}`
-};
-
-// TODO: Add statsLimit
-export const getStatsLimit = (): number =>
-  env.DEFAULT_MAX_STATS_PER_LINK || 100000000;
-
-export const getStatsCacheTime = (total?: number): number => {
-  return (total > 50000 ? ms("5 minutes") : ms("1 minutes")) / 1000;
-};
-
-export const statsObjectToArray = (obj: Stats) => {
-  const objToArr = (key) =>
-    Array.from(Object.keys(obj[key]))
-      .map((name) => ({
-        name,
-        value: obj[key][name]
-      }))
-      .sort((a, b) => b.value - a.value);
-
-  return {
-    browser: objToArr("browser"),
-    os: objToArr("os"),
-    country: objToArr("country"),
-    referrer: objToArr("referrer")
-  };
-};
-
-export const getDifferenceFunction = (
-  type: "lastDay" | "lastWeek" | "lastMonth" | "allTime"
-) => {
-  if (type === "lastDay") return differenceInHours;
-  if (type === "lastWeek") return differenceInDays;
-  if (type === "lastMonth") return differenceInDays;
-  if (type === "allTime") return differenceInMonths;
-  throw new Error("Unknown type.");
-};
-
-export const getUTCDate = (dateString?: Date) => {
-  const date = new Date(dateString || Date.now());
-  return new Date(
-    date.getUTCFullYear(),
-    date.getUTCMonth(),
-    date.getUTCDate(),
-    date.getUTCHours()
-  );
-};
-
-export const STATS_PERIODS: [number, "lastDay" | "lastWeek" | "lastMonth"][] = [
-  [1, "lastDay"],
-  [7, "lastWeek"],
-  [30, "lastMonth"]
-];
-
-export const getInitStats = (): Stats => {
-  return Object.create({
-    browser: {
-      chrome: 0,
-      edge: 0,
-      firefox: 0,
-      ie: 0,
-      opera: 0,
-      other: 0,
-      safari: 0
-    },
-    os: {
-      android: 0,
-      ios: 0,
-      linux: 0,
-      macos: 0,
-      other: 0,
-      windows: 0
-    },
-    country: {},
-    referrer: {}
-  });
-};
-
-export const sanitize = {
-  domain: (domain: Domain): DomainSanitized => ({
-    ...domain,
-    id: domain.uuid,
-    uuid: undefined,
-    user_id: undefined,
-    banned_by_id: undefined
-  }),
-  link: (link: LinkJoinedDomain): LinkSanitized => ({
-    ...link,
-    banned_by_id: undefined,
-    domain_id: undefined,
-    user_id: undefined,
-    uuid: undefined,
-    id: link.uuid,
-    password: !!link.password,
-    link: generateShortLink(link.address, link.domain)
-  })
-};
-
-export const removeWww = (host = "") => {
-  return host.replace("www.", "");
-};

+ 233 - 0
server/utils/utils.js

@@ -0,0 +1,233 @@
+const ms = require("ms");
+const path = require("path");
+const nanoid = require("nanoid/generate");
+const JWT = require("jsonwebtoken");
+const { differenceInDays, differenceInHours, differenceInMonths, addDays } = require("date-fns");
+const hbs = require("hbs");
+
+const env = require("../env");
+
+class CustomError extends Error {
+  constructor(message, statusCode, data) {
+    super(message);
+    this.name = this.constructor.name;
+    this.statusCode = statusCode ?? 500;
+    this.data = data;
+  }
+}
+
+const query = require("../queries");
+
+function isAdmin(email) {
+  return env.ADMIN_EMAILS.split(",")
+    .map((e) => e.trim())
+    .includes(email)
+}
+
+function signToken(user) {
+  return JWT.sign(
+      {
+        iss: "ApiAuth",
+        sub: user.email,
+        domain: user.domain || "",
+        admin: isAdmin(user.email),
+        iat: parseInt((new Date().getTime() / 1000).toFixed(0)),
+        exp: parseInt((addDays(new Date(), 7).getTime() / 1000).toFixed(0))
+      },
+      env.JWT_SECRET
+    )
+}
+
+async function generateId(domain_id) {
+  const address = nanoid(
+    "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789",
+    env.LINK_LENGTH
+  );
+  const link = await query.link.find({ address, domain_id });
+  if (!link) return address;
+  return generateId(domain_id);
+}
+
+function addProtocol(url) {
+  const hasProtocol = /^\w+:\/\//.test(url);
+  return hasProtocol ? url : `http://${url}`;
+}
+
+function getShortURL(id, domain) {
+  const protocol = env.CUSTOM_DOMAIN_USE_HTTPS || !domain ? "https://" : "http://";
+  const link = `${domain || env.DEFAULT_DOMAIN}/${id}`;
+  const url = `${protocol}${link}`;
+  return { link, url };
+}
+
+const getRedisKey = {
+  // TODO: remove user id and make domain id required
+  link: (address, domain_id, user_id) => `${address}-${domain_id || ""}-${user_id || ""}`,
+  domain: (address) => `d-${address}`,
+  host: (address) => `h-${address}`,
+  user: (emailOrKey) => `u-${emailOrKey}`
+};
+
+function getStatsLimit() {
+  return env.DEFAULT_MAX_STATS_PER_LINK || 100000000;
+};
+
+function getStatsCacheTime(total) {
+  return (total > 50000 ? ms("5 minutes") : ms("1 minutes")) / 1000
+};
+
+function statsObjectToArray(obj) {
+  const objToArr = (key) =>
+    Array.from(Object.keys(obj[key]))
+      .map((name) => ({
+        name,
+        value: obj[key][name]
+      }))
+      .sort((a, b) => b.value - a.value);
+  
+  return {
+    browser: objToArr("browser"),
+    os: objToArr("os"),
+    country: objToArr("country"),
+    referrer: objToArr("referrer")
+  };
+}
+
+function getDifferenceFunction(type) {
+  if (type === "lastDay") return differenceInHours;
+  if (type === "lastWeek") return differenceInDays;
+  if (type === "lastMonth") return differenceInDays;
+  if (type === "allTime") return differenceInMonths;
+  throw new Error("Unknown type.");
+}
+
+function getUTCDate(dateString) {
+  const date = new Date(dateString || Date.now());
+  return new Date(
+    date.getUTCFullYear(),
+    date.getUTCMonth(),
+    date.getUTCDate(),
+    date.getUTCHours()
+  );
+}
+
+const STATS_PERIODS = [
+  [1, "lastDay"],
+  [7, "lastWeek"],
+  [30, "lastMonth"]
+];
+
+const preservedURLs = [
+  "login",
+  "logout",
+  "signup",
+  "reset-password",
+  "resetpassword",
+  "url-password",
+  "url-info",
+  "settings",
+  "stats",
+  "verify",
+  "api",
+  "404",
+  "static",
+  "images",
+  "banned",
+  "terms",
+  "privacy",
+  "protected",
+  "report",
+  "pricing"
+];
+
+function getInitStats() {
+  return Object.create({
+    browser: {
+      chrome: 0,
+      edge: 0,
+      firefox: 0,
+      ie: 0,
+      opera: 0,
+      other: 0,
+      safari: 0
+    },
+    os: {
+      android: 0,
+      ios: 0,
+      linux: 0,
+      macos: 0,
+      other: 0,
+      windows: 0
+    },
+    country: {},
+    referrer: {}
+  });
+}
+
+const sanitize = {
+  domain: domain => ({
+    ...domain,
+    id: domain.uuid,
+    uuid: undefined,
+    user_id: undefined,
+    banned_by_id: undefined
+  }),
+  link: link => ({
+    ...link,
+    banned_by_id: undefined,
+    domain_id: undefined,
+    user_id: undefined,
+    uuid: undefined,
+    id: link.uuid,
+    password: !!link.password,
+    link: getShortURL(link.address, link.domain)
+  })
+};
+
+function sleep(ms) {
+  return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+function removeWww(host) {
+  return host.replace("www.", "");
+};
+
+function extendHbs() {
+  const blocks = {};
+  hbs.registerHelper("extend", function(name, context) {
+      let block = blocks[name];
+      if (!block) {
+          block = blocks[name] = [];
+      }
+      block.push(context.fn(this));
+  });
+
+  hbs.registerHelper("block", function(name) {
+      const val = (blocks[name] || []).join('\n');
+      blocks[name] = [];
+      return val;
+  });
+  hbs.registerPartials(path.join(__dirname, "../views/partials"), function (err) {});
+}
+
+module.exports = {
+  addProtocol,
+  CustomError,
+  generateId,
+  getShortURL,
+  getDifferenceFunction,
+  getInitStats,
+  getRedisKey,
+  getStatsCacheTime,
+  getStatsLimit,
+  getUTCDate,
+  extendHbs,
+  isAdmin,
+  preservedURLs,
+  removeWww,
+  sanitize,
+  signToken,
+  sleep,
+  STATS_PERIODS,
+  statsObjectToArray,
+}

+ 81 - 0
server/views/homepage.hbs

@@ -0,0 +1,81 @@
+{{> header}}
+<main>
+  <div id="shorturl">
+    <h1>Kutt your links <span>shorter</span>.</h1>
+  </div>
+  <form hx-post="/api/links" hx-trigger="submit queue:none" hx-target="#shorturl">
+    <div class="target-wrapper">
+      <input
+        id="target"
+        name="target"
+        type="text"
+        placeholder="Paste your long URL"
+        aria-label="target"
+        autofocus="true"
+        data-lpignore="true"
+      />
+      <button class="submit">
+        <svg class="send" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><path d="m2 21 21-9L2 3v7l15 2-15 2z"/></svg>
+        <svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
+      </button>
+    </div>
+    <label id="advanced" class="checkbox">
+      <input type="checkbox" />
+      Show advanced options
+    </label>
+  </form>
+</main>
+<section class="introduction">
+  <div class="text-wrapper">
+    <h2>Manage links, set custom <b>domains</b> and view <b>stats</b>.</h2>
+    <a class="button primary">Log in / Sign up</a>
+  </div>
+  <img src="/images/callout.png" alt="callout image" />
+</section>
+<section class="features">
+  <h3>Kutting edge features.</h3>
+  <ul>
+    <li>
+      <div class="icon">
+        <svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 14.7V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5.3"/><path d="m18 2 4 4-10 10H8v-4z"/></svg>
+      </div>
+      <h4>Managing links</h4>
+      <p>Create, protect and delete your links and monitor them with detailed statistics.</p>
+    </li>
+    <li>
+      <div class="icon">
+        <svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 3h5v5M4 20 20.2 3.8M21 16v5h-5m-1-6 5.1 5.1M4 4l5 5"/></svg>
+      </div>
+      <h4>Custom domain</h4>
+      <p>Use custom domains for your links. Add or remove them for free.</p>
+    </li>
+    <li>
+      <div class="icon">
+        <svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M13 2 3 14h9l-1 8 10-12h-9z"/></svg>
+      </div>
+      <h4>API</h4>
+      <p>Use the provided API to create, delete, and get URLs from anywhere.</p>
+    </li>
+    <li>
+      <div class="icon">
+        <svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.7 0l-1.1 1-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1 7.8 7.8 7.8-7.7 1-1.1a5.5 5.5 0 0 0 0-7.8z"/></svg>
+      </div>
+      <h4>Free & open source</h4>
+      <p>Completely open source and free. You can host it on your own server.</p>
+    </li>
+  </ul>
+</section>
+<section class="extensions">
+  <h3>Browser extentions.</h3>
+  <div class="extenstions-wrapper">
+    <a class="extension-button chrome" href="https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd" target="_blank" rel="noopener noreferrer" title="Chrome extension">
+      <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M16.2 8.7 23 7a12 12 0 0 1 1.1 5 12 12 0 0 1-13 12l5-8.4.8-1.3a6 6 0 0 0 0-4.7zM13 17.3l-2.1 6.6A12 12 0 0 1 2 5.3l5 8.4c.2.5 1 2.5 3 3.3q1.5.6 3 .3m-1-9.7c-2 0-3.9 1.6-4.3 3.5a5 5 0 0 0 1.2 4 5 5 0 0 0 4.8 1c1.4-.6 2.4-2 2.7-3.4.2-1.9-.8-3.9-2.5-4.7a4 4 0 0 0-2-.4M7 10 2.3 5A12 12 0 0 1 12 0a12 12 0 0 1 10.8 6.7H12.6Q9.8 6.6 8.3 8A5 5 0 0 0 7 10"/></svg>
+      Download for Chrome
+    </a>
+    <a class="extension-button firefox" href="https://addons.mozilla.org/en-US/firefox/addon/kutt/" target="_blank" rel="noopener noreferrer" title="Firefox extension">
+      <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M23.4 11v-.4l-.3.3-.3-1.5a10 10 0 0 0-1.3-2.9l-.2-.3-1.5-2q-.6-1-.8-2l-.3 1.3-1.4-1.2C15.8.9 16 0 16 0s-2.8 3.2-1.6 6.4q.6 1.6 2 2.8c1.3 1 2.5 1.7 3.2 3.7q-.9-1.6-2.4-2.5.5 1 .5 2.2a5.3 5.3 0 0 1-6.5 5.2l-1.3-.5q-1-.5-1.6-1.4h.1l.7.2q1.4.2 2.6-.3 1.3-.8 1.8-.7.7 0 .4-.7-.8-1-2-.8c-1 .1-1.7.7-2.8.1H9h.2l-.7-.5h.1l-.7-.7q-.3-.6 0-1.1 0-.3.4-.4h.2l.5.3.3.2v-.1q0-.3-.3-.4l.4.2v-1h.1V10l.2-.2 1-.6.9-.4q.4-.3.5-.8v-.2c0-.2-.3-.3-1.8-.5q-1-.1-1.1-1v.2-.2q.5-1.1 1.5-1.8h-.1l.3-.2-.6-.2-.5.2.2-.2-1.1.5v-.1q-.4.1-.7.5l-.4.3Q6.5 4.7 5.1 5l-.4-.5-.2-.3-.2-.3Q4 3.5 4 2.8q-.5.3-.6.7l-.1.2v-.2q0 .2-.2.3V4v-.1H3a7 7 0 0 0-.6 2.3v.4l-.6.8Q1 8.8.6 10.6l.7-1.2a11 11 0 0 0-.8 4l.3-1.2q0 2.6 1 4.8 1.4 3.3 4.4 5 1.2.9 2.6 1.3l.3.1q1.5.5 3.3.5c4 0 5.3-1.6 5.4-1.7l.5-.7h.2l.2-.1 1.7-1q1.1-1 1.5-2.4.3-.5 0-1l.2-.3q1.2-2 1.4-4.6z"/></svg>
+      Download for Firefox
+    </a>
+  </div>
+</section>
+{{> footer}}

+ 62 - 0
server/views/layout.hbs

@@ -0,0 +1,62 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+  <link rel="icon" sizes="196x196" href="/images/favicon-196x196.png" />
+  <link rel="icon" sizes="32x32" href="/images/favicon-32x32.png" />
+  <link rel="icon" sizes="16x16" href="/images/favicon-16x16.png" />
+  <link rel="apple-touch-icon" href="/images/favicon-196x196.png" />
+  <link rel="mask-icon" href="/images/icon.svg" color="blue" />
+  <link rel="manifest" href="manifest.webmanifest" />
+  <meta name="theme-color" content="#f3f3f3" />
+  <meta property="fb:app_id" content="123456789" />
+  <meta name="htmx-config" content='{"withCredentials":true}'>
+  {{!-- TODO: meta tags --}}
+  {{!-- <meta
+    property="og:url"
+    content={`https://${publicRuntimeConfig.DEFAULT_DOMAIN}`}
+  />
+  <meta property="og:type" content="website" />
+  <meta property="og:title" content={publicRuntimeConfig.SITE_NAME} />
+  <meta
+    property="og:image"
+    content={`https://${publicRuntimeConfig.DEFAULT_DOMAIN}/images/card.png`}
+  />
+  <meta
+    property="og:description"
+    content="Free & Open Source Modern URL Shortener"
+  />
+  <meta
+    name="twitter:url"
+    content={`https://${publicRuntimeConfig.DEFAULT_DOMAIN}`}
+  />
+  <meta name="twitter:title" content={publicRuntimeConfig.SITE_NAME} />
+  <meta
+    name="twitter:description"
+    content="Free & Open Source Modern URL Shortener"
+  />
+  <meta
+    name="twitter:image"
+    content={`https://${publicRuntimeConfig.DEFAULT_DOMAIN}/images/card.png`}
+  /> --}}
+  {{!-- TODO: configurable title --}}
+  <meta name="description" content="Kutt is a free and open source URL shortener with custom domains and stats." />
+  {{!-- TODO: configurable title --}}
+  <title>Kutt | {{title}}</title>
+  <link rel="stylesheet" href="/css/styles.css">
+  {{{block "stylesheets"}}}
+</head>
+<body>
+  <div class="main-wrapper">
+    {{{body}}}
+  </div>
+
+  {{{block "scripts"}}}
+  <script src="/libs/htmx.min.js"></script>
+  <script src="/scripts/main.js"></script>
+  <script>
+    htmx.logAll();
+  </script>
+</body>
+</html>

+ 3 - 0
server/views/login.hbs

@@ -0,0 +1,3 @@
+{{> header}}
+{{> login_signup}}
+{{> footer}}

+ 9 - 0
server/views/partials/footer.hbs

@@ -0,0 +1,9 @@
+<footer>
+  <p>
+    Made with love by <a href="https://thedevs.network" title="The Devs" target="_blank" rel="noopener noreferrer">The Devs</a>. <span>|</span> 
+    <a href="https://github.com/thedevs-network/kutt" title="GitHub" target="_blank" rel="noopener noreferrer">GitHub</a> <span>|</span> 
+    <a href="/terms" title="Terms of Service">Terms of Service</a> <span>|</span>
+    <a href="/report" title="Report abuse">Report Abuse</a> <span>|</span> 
+    <a href="mailto:support@kutt.it" title="Contact us">Contact us</a>
+  </p>
+</footer>

+ 40 - 0
server/views/partials/header.hbs

@@ -0,0 +1,40 @@
+<header hx-boost="true">
+  <div class="logo-wrapper">
+    <a class="logo nav" href="/" title="Kutt">
+      <img src="/images/logo.svg" alt="kutt" width="18" height="24" />
+      {{!-- TODO: configurable site name --}}
+      Kutt
+    </a>
+    <ul class="logo-links">
+      <li>
+        <a class="nav" href="https://github.com/thedevs-network/kutt" target="_blank" rel="noopener noreferrer" title="GitHub">
+          GitHub
+        </a>
+      </li>
+      <li>
+        <a class="nav" href="/report" title="Report abuse">
+          Report
+        </a>
+      </li>
+    </ul>
+  </div>
+  <nav>
+    <ul>
+      <li>
+        <a class="button primary" href="/login" title="Log in or sign up">
+          Log in / Sign up
+        </a>
+      </li>
+      {{!-- <li>
+        <a class="button primary" href="/settings" title="Settings">
+          Settings
+        </a>
+      </li>
+      <li>
+        <a class="nav" href="/logout" title="Log out">
+          Log out
+        </a>
+      </li> --}}
+    </ul>
+  </nav>
+</header>

+ 50 - 0
server/views/partials/login_signup.hbs

@@ -0,0 +1,50 @@
+<form id="login-signup" hx-post="/api/auth/login" hx-swap="outerHTML">
+  <label>
+    Email address:
+    <input
+      name="email"
+      id="email"
+      type="email"
+      autofocus="true"
+      placeholder="Email address..."
+      hx-preserve="true"
+    />
+  </label>
+  <label>
+    Password:
+    <input
+      name="password"
+      id="password"
+      type="password"
+      placeholder="Password..."
+      hx-preserve="true"
+    />
+  </label>
+  {{!-- TODO: Agree with terms --}}
+  <div class="buttons-wrapper">
+    <button type="submit" class="primary login">
+      <svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4m-5-4 5-5-5-5m3.8 5H3"/></svg>
+      <svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
+      Log in
+    </button>
+    <button 
+      class="secondary signup" 
+      hx-post="/api/auth/signup" 
+      hx-target="#login-signup" 
+      hx-trigger="click" 
+      hx-indicator="#login-signup" 
+      hx-swap="outerHTML"
+      hx-sync="closest form"
+      hx-on:htmx:before-request="htmx.addClass('#login-signup', 'signup')"
+      hx-on:htmx:after-request="htmx.removeClass('#login-signup', 'signup')"
+    >
+        <svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6m3-3h-6"/></svg>
+        <svg class="spinner" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M5 5l2.8 2.8m8.4 8.4 2.9 2.9M2 12h4m12 0h4M5 19l2.8-2.8m8.4-8.4 2.9-2.9"/></svg>
+        Sign up
+    </button>
+  </div>
+  <a class="forgot-password" href="/forgot-password" title="Reset password">Forgot your password?</a>
+  {{#if error}}
+    <p class="error">{{error}}</p>
+  {{/if}}
+</form>

+ 5 - 0
server/views/partials/login_welcome.hbs

@@ -0,0 +1,5 @@
+<div class="login-signup-message" hx-get="/" hx-trigger="load delay:2s" hx-target="body" hx-push-url="/">
+  <h1>
+    Welcome. Redirecting to homepage...
+  </h1>
+</div>

+ 7 - 0
server/views/partials/shorturl.hbs

@@ -0,0 +1,7 @@
+<div class="clipboard">
+  <button aria-label="Copy" hx-on:click="handleShortURLCopyLink(this);" data-url="{{url}}">
+    <svg class="copy" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect width="13" height="13" x="9" y="9" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
+  </button>
+  <svg class="check" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>
+</div>
+<h1 class="link" hx-on:click="handleShortURLCopyLink(this);" data-url="{{url}}">{{link}}</h1>

+ 5 - 0
server/views/partials/signup_verify_email.hbs

@@ -0,0 +1,5 @@
+<div class="login-signup-message">
+  <h1>
+    A verification email has been sent to you.
+  </h1>
+</div>

+ 768 - 0
static/css/styles.css

@@ -0,0 +1,768 @@
+@font-face {
+  font-family: 'Nunito';
+  font-style: normal;
+  font-weight: 200 1000;
+  src: url(/fonts/nunito-variable.woff2) format('woff2');
+}
+
+:root {
+  --bg-color: hsl(206, 12%, 95%);
+  --text-color: hsl(200, 35%, 25%);
+  --color-primary: #2196f3;
+  --outline-color: #14e0ff;
+  --button-bg: linear-gradient(to right, #e0e0e0, #bdbdbd);
+  --button-bg-box-shadow-color: rgba(160, 160, 160, 0.5);
+  --button-bg-primary: linear-gradient(to right, #42a5f5, #2979ff);
+  --button-bg-primary-box-shadow-color: rgba(66, 165, 245, 0.5);
+  --button-bg-secondary: linear-gradient(to right, #7e57c2, #6200ea);
+  --button-bg-secondary-box-shadow-color: rgba(81, 45, 168, 0.5);
+  --button-bg-danger: linear-gradient(to right, #ee3b3b, #e11c1c);
+  --button-bg-danger-box-shadow-color: rgba(168, 45, 45, 0.5);
+  --features-bg: hsl(230, 15%, 92%);
+  --extensions-bg: hsl(230, 15%, 20%);
+  --send-icon-hover-color: #673ab7;
+  --send-spinner-icon-color: hsl(200, 15%, 70%);
+  --copy-icon-color: hsl(144, 40%, 57%);
+  --copy-icon-bg-color: hsl(144, 100%, 96%);
+  --keyframe-slidey-offset: 0;
+}
+
+/* ANIMATIONS */
+@keyframes spin {
+  from { transform: rotate(0deg); }
+  to { transform: rotate(360deg); }
+}
+
+@keyframes fadein {
+  from { opacity: 0 }
+  to { opacity: 1 }
+}
+
+@keyframes slidey {
+  from { transform: translateY(var(--keyframe-slidey-offset)) }
+  to { transform: translateY(0) }
+}
+
+/* GENERAL */
+body {
+  margin: 0;
+  padding: 0;
+  background-color: var(--bg-color);
+  font: 16px/1.45 'Nunito', sans-serif;
+  overflow-x: hidden;
+  color: var(--text-color);
+}
+
+* {
+  box-sizing: border-box;
+  outline-color: var(--outline-color);
+
+}
+
+*::-moz-focus-inner {
+  border: none;
+}
+
+a {
+  color: var(--color-primary);
+  border-bottom: 1px dotted transparent;
+  text-decoration: none;
+  transition: all 0.2s ease-out;
+}
+
+a:hover {
+  border-bottom-color: var(--color-primary);
+}
+
+a.nav {
+  color: inherit;
+  padding-bottom: 2px;
+}
+
+a.nav:hover {
+  color: var(--color-primary);
+}
+
+a.button,
+button {
+  position: relative;
+  width: auto;
+  height: 40px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0 32px;
+  font-size: 13px;
+  font-weight: normal;
+  text-align: center;
+  line-height: 1;
+  word-break: keep-all;
+  color: #444;
+  border: none;
+  border-radius: 100px;
+  transition: all 0.4s ease-out;
+  cursor: pointer;
+  overflow: hidden;
+  background: var(--button-bg);
+  box-shadow: 0 5px 6px var(--button-bg-box-shadow-color);
+}
+
+a.button.primary,
+button.primary {
+  color: white;
+  background: var(--button-bg-primary);
+  box-shadow: 0 5px 6px var(--button-bg-primary-box-shadow-color);
+}
+
+a.button.secondary,
+button.secondary {
+  color: white;
+  background: var(--button-bg-secondary);
+  box-shadow: 0 5px 6px var(--button-bg-secondary-box-shadow-color);
+}
+
+a.button.danger,
+button.danger {
+  color: white;
+  background: var(--button-bg-danger);
+  box-shadow: 0 5px 6px var(--button-bg-danger-box-shadow-color);
+}
+
+a.button:focus,
+a.button:hover,
+button:focus,
+button:hover {
+  box-shadow: 0 6px 15px var(--button-bg-box-shadow-color);
+  transform: translateY(-2px) scale(1.02, 1.02);
+}
+
+a.button.primary:focus,
+a.button.primary:hover,
+button.primary:focus,
+button.primary:hover {
+  box-shadow: 0 6px 15px var(--button-bg-primary-box-shadow-color);
+}
+
+a.button.secondary:focus,
+a.button.secondary:hover,
+button.secondary:focus,
+button.secondary:hover {
+  box-shadow: 0 6px 15px var(--button-bg-secondary-box-shadow-color);
+}
+
+a.button.danger:focus,
+a.button.danger:hover,
+button.danger:focus,
+button.danger:hover {
+  box-shadow: 0 6px 15px var(--button-bg-danger-box-shadow-color);
+}
+
+input {
+  filter: none;
+}
+
+input[type="text"],
+input[type="email"],
+input[type="password"] {
+  box-sizing: border-box;
+  height: 40px;
+  padding: 0 24px;
+  font-size: 15px;
+  letter-spacing: 0.05em;
+  color: #444;
+  background-color: white;
+  border: none;
+  border-radius: 100px;
+  border-bottom: 5px solid #f5f5f5;
+  border-bottom-width: 5px;
+  box-shadow: 0 10px 35px hsla(200, 15%, 70%, 0.2);
+  transition: all 0.5s ease-out;
+}
+
+input[type="text"]:focus,
+input[type="email"]:focus,
+input[type="password"]:focus {
+  outline: none;
+  box-shadow: 0 20px 35px hsla(200, 15%, 70%, 0.4);
+}
+
+input[type="text"]::placeholder,
+input[type="email"]::placeholder,
+input[type="password"]::placeholder {
+  font-size: 14px;
+  letter-spacing: 0.05em;
+  color: #888;
+}
+
+input[type="checkbox"] {
+  position: relative;
+  width: 1rem;
+  height: 1rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 4px;
+  background-color: white;
+  box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2);
+  margin: 0;
+  -webkit-appearance: none;
+  appearance: none;
+  cursor: pointer;
+}
+
+input[type="checkbox"]:focus {
+  outline: 3px solid rgba(65, 164, 245, 0.5);
+}
+
+input[type="checkbox"]::after {
+  content: "";
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  width: 80%;
+  height: 80%;
+  display: block;
+  border-radius: 2px;
+  background-color: #9575cd;
+  box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2);
+  cursor: pointer;
+  opacity: 0;
+  transform: translate(-50%, -50%) scale(0);
+  transition: all 0.1s ease-in-out;
+}
+
+input[type="checkbox"]:checked:after {
+  opacity: 1;
+  transform: translate(-50%, -50%) scale(1);
+}
+
+label.checkbox { 
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+}
+
+label.checkbox input[type="checkbox"] {
+  margin: 0 0.75rem 2px 0;
+}
+
+label {
+  color: #555;
+}
+
+/* DISTINCT */
+
+.main-wrapper {
+  min-height: 100vh;
+  width: 100%;
+  display: flex;
+  flex: 0 0 auto;
+  align-items: center;
+  flex-direction: column;
+}
+
+/* HEADER */
+
+header {
+  box-sizing: border-box;
+  margin: 0;
+  width: 1232px;
+  max-width: 100%;
+  padding: 0 32px;
+  height: 102px;
+  justify-content: space-between;
+  align-items: center;
+  display: flex;
+}
+
+header .logo-wrapper {
+  display: flex;
+  align-items: center;
+}
+
+header a.logo {
+  position: relative;
+  display: flex;
+  align-items: center;
+  font-size: 22px;
+  font-weight: bold;
+  text-decoration: none;
+  border: none;
+  margin: 0;
+  padding: 0;
+}
+
+header a.logo:hover { border: none; color: inherit; }
+
+header .logo img {
+  margin: 0 12px 0 0;
+  padding: 0;
+}
+
+header ul.logo-links {
+  list-style: none;
+  display: flex;
+  align-items: flex-end;
+  margin: 0 0 0 0.5rem;
+  padding: 0;
+}
+
+header ul.logo-links li {
+  padding: 2px 0 0;
+  margin: 0 0 0 32px;
+}
+
+header ul.logo-links li a {
+  font-size: 16px;
+}
+
+header nav ul {
+  display: flex;
+  flex-direction: row-reverse;
+  align-items: center;
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+
+header nav ul li {
+  margin: 0 0 0 32px;
+  padding: 0;
+}
+
+header nav ul li:last-child { margin-left: 0; }
+
+/* SHORTENER */
+
+main {
+  width: 800px;
+  max-width: 100%;
+  flex: 0 0 auto;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 0 1rem;
+  margin-top: 1rem;
+}
+
+main #shorturl {
+  display: flex;
+  align-items: center;
+  margin-bottom: 3rem;
+}
+
+main #shorturl h1 {
+  border-bottom: 1px dotted transparent;
+  font-weight: 300;
+  font-size: 2rem;
+}
+
+main #shorturl h1.link {
+  cursor: pointer;
+  border-bottom-color: hsl(200, 35%, 65%);
+  transition: opacity 0.2s ease-in-out;
+}
+
+main #shorturl h1.link:hover {
+  opacity: 0.8;
+}
+
+main #shorturl .clipboard {
+  width: 35px;
+  display: flex;
+  margin-right: 1rem;
+}
+
+main #shorturl button {
+  width: 100%;
+  display: flex;
+  margin: 0;
+  padding: 7px;
+  box-shadow: none;
+  outline: none;
+  border: none;
+  background: none;
+  border-radius: 100%;
+  background-color: var(--copy-icon-bg-color);
+  transition: transform 0.4s ease-out;
+  box-shadow: 0 2px 1px hsla(200, 15%, 60%, 0.12);
+  cursor: pointer;
+  --keyframe-slidey-offset: 10px;
+  animation: slidey 0.2s ease-in-out;
+}
+
+main #shorturl button:hover,
+main #shorturl button:focus {
+  transform: translateY(-2px) scale(1.02, 1.02);
+}
+
+main #shorturl button:focus {
+  outline: 3px solid rgba(65, 164, 245, 0.5);
+}
+
+main #shorturl svg {
+  stroke: var(--copy-icon-color);
+  width: 100%;
+  height: auto;
+}
+
+main #shorturl svg.copy {
+  stroke-width: 2.5;
+}
+
+main #shorturl svg.check {
+  display: none;
+  padding: 3px;
+  stroke-width: 3;
+  --keyframe-slidey-offset: -10px;
+  animation: slidey 0.2s ease-in-out;
+}
+
+main #shorturl.copied button {
+  background-color: transparent;
+  box-shadow: none;
+}
+
+
+main #shorturl.copied button { display: none; }
+main #shorturl.copied svg.check { display: block; }
+
+main #shorturl h1 span {
+  border-bottom: 1px dotted #999;
+}
+
+main form {
+  position: relative;
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+main form input#target {
+  position: relative;
+  width: 100%;
+  height: 72px;
+  display: flex;
+  padding: 0 84px 0 40px;
+  font-size: 20px;
+}
+
+main form input#target::placeholder {
+  font-size: 17px;
+}
+
+main form .target-wrapper {
+  position: relative;
+  width: 100%;
+  height: auto;
+}
+
+main form button.submit {
+  box-sizing: content-box;
+  position: absolute;
+  cursor: pointer;
+  width: 28px;
+  height: auto;
+  right: 0;
+  top: 50%;
+  padding: 4px;
+  margin: 0 2rem 0;
+  background: none;
+  box-shadow: none;
+  outline: none;
+  border: none;
+  transform: translateY(-52%);
+}
+
+main form button.submit:focus,
+main form button.submit:hover {
+  outline: none;
+}
+
+main form button.submit svg.send {
+  fill: #aaa;
+  animation: fadein 0.3s ease-in-out;
+  transition: fill 0.2s ease-in-out;
+}
+
+main form button.submit:hover svg.send {
+  fill: var(--send-icon-hover-color);
+}
+
+main form button.submit svg.spinner {
+  display: none;
+  fill: none;
+  stroke: var(--send-spinner-icon-color);
+  stroke-width: 2;
+  animation: spin 1s linear infinite, fadein 0.3s ease-in-out;
+}
+
+main form.htmx-request button.submit svg.send {
+  display: none;
+}
+
+main form.htmx-request button.submit svg.spinner {
+  display: block;
+}
+
+main form label#advanced {
+  margin-top: 2rem;
+}
+
+/* INTRO */
+
+.introduction {
+  width: 1200px;
+  max-width: 98%;
+  display: flex;
+  align-items: center;
+  margin: 150px 0 0;
+}
+
+.introduction .text-wrapper {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  align-items: flex-start;
+  margin-top: -32px;
+}
+
+.introduction h2 {
+  font-weight: 300;
+  font-size: 28px;
+  padding-right: 2rem;
+  margin-bottom: 2.5rem
+}
+
+.introduction img {
+  width: 60%;
+  flex: 0 0 60%;
+  max-width: 100%;
+  height: auto;
+}
+
+/* FEATURES */
+
+.features {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+  flex: 0 0 auto;
+  padding: 5rem 0;
+  margin: 0;
+  background-color: var(--features-bg);
+}
+
+.features h3 {
+  font-weight: 300;
+  font-size: 28px;
+  margin-bottom: 72px;
+}
+
+.features ul {
+  width: 1200px;
+  max-width: 100%;
+  flex: 1 1 auto;
+  justify-content: center;
+  flex-wrap: nowrap;
+  display: flex;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.features ul li {
+  max-width: 25%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 0 1.5rem;
+  margin: 0;
+}
+
+.features ul li .icon {
+  width: 48px;
+  height: 48px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 100%;
+  background-color: var(--color-primary);
+}
+
+.features ul li .icon svg {
+  width: 16px;
+  height: auto;
+  stroke: white;
+  stroke-width: 2px;
+}
+
+.features ul li h4 {
+  margin: 1rem;
+  padding: 0;
+  font-size: 15px;
+}
+
+.features ul li p {
+  margin: 0;
+  padding: 0;
+  font-size: 14px;
+  font-weight: 300;
+  text-align: center;
+}
+
+/* EXTENSIONS */
+
+.extensions {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  flex: 0 0 auto;
+  flex-wrap: nowrap;
+  padding: 5rem 0;
+  background-color: var(--extensions-bg);
+  color: white;
+}
+
+.extensions h3 {
+  font-size: 28px;
+  font-weight: 300;
+  margin-bottom: 4rem;
+}
+
+.extensions .extenstions-wrapper {
+  width: 1200px;
+  max-width: 100%;
+  display: flex;
+  flex: 1 1 auto;
+  justify-content: center;
+  flex-wrap: nowrap;
+}
+
+.extensions a.extension-button {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin: 0 1rem;
+  padding: 0.75rem 1.75rem;
+  background-color: #eee;
+  border: 1px solid #aaa;
+  font-size: 14px;
+  font-weight: bold;
+  text-decoration: none;
+  border-radius: 4px;
+  outline: none;
+  box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
+  transition: transform 0.3s ease-out;
+  cursor: pointer;
+}
+
+.extensions a.extension-button:hover {
+  transform: translateY(-2px);
+}
+
+.extensions a.extension-button.chrome { color: #4285f4; }
+.extensions a.extension-button.firefox { color: #e0890f; }
+
+.extensions a.extension-button svg {
+  width: 18px;
+  height: auto;
+  margin: 0 1rem 2px 0;
+}
+
+.extensions a.extension-button.chrome svg { fill: #4285f4;  }
+.extensions a.extension-button.firefox svg { fill: #e0890f;  }
+
+/* LOGIN & SIGNUP */
+
+form#login-signup {
+  max-width: 100%;
+  flex: 1 1 auto;
+  display: flex;
+  flex-direction: column;
+  width: 400px;
+  margin: 3rem 0 0;
+}
+
+form#login-signup label {
+  display: flex;
+  flex-direction: column;
+  font-size: 16px;
+  font-weight: bold;
+  margin-bottom: 2rem;
+}
+
+form#login-signup input {
+  width: 100%;
+  height: 72px;
+  margin-top: 1rem;
+  padding: 0 3rem;
+  font-size: 16px;
+}
+
+form#login-signup .buttons-wrapper {
+  display: flex;
+  align-items: center;
+  margin-bottom: 1.5rem;
+}
+
+form#login-signup .buttons-wrapper button {
+  height: 56px;
+  flex: 1 1 auto;
+  padding: 0 1rem 2px;
+  margin-right: 1rem;
+}
+
+form#login-signup .buttons-wrapper button:last-child { margin-right: 0; }
+
+form#login-signup .buttons-wrapper button svg {
+  width: 16px;
+  height: auto;
+  margin-right: 0.5rem;
+  stroke: white;
+  stroke-width: 2;
+}
+
+form#login-signup a.forgot-password {
+  align-self: flex-start;
+  font-size: 14px;
+}
+
+form#login-signup svg.spinner {
+  display: none;
+  animation: fadein 0.3s ease-in-out, spin 1s linear infinite;
+}
+
+form#login-signup.htmx-request:not(.signup) .login svg.spinner { display: block; } 
+form#login-signup.htmx-request:not(.signup) .login svg.icon { display: none; } 
+form#login-signup.htmx-request.signup .signup svg.spinner { display: block; } 
+form#login-signup.htmx-request.signup .signup svg.icon { display: none; } 
+form#login-signup.htmx-request .error { opacity: 0; }
+
+form#login-signup .error {
+  color: red;
+  animation: fadein 0.3s ease-in-out;
+}
+
+.login-signup-message {
+  flex: 1 1 auto;
+  margin-top: 3rem;
+}
+
+.login-signup-message h1 {
+  font-weight: 300;
+  font-size: 24px;
+}
+
+/* FOOTER */
+
+footer {
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  padding: 1rem 0;
+  font-size: 13px;
+  text-align: center;
+}

BIN
static/fonts/nunito-variable.woff2


BIN
static/images/callout.png


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/libs/htmx.min.js


+ 21 - 0
static/scripts/main.js

@@ -0,0 +1,21 @@
+// add text/html accept header to receive html instead of json for the requests
+document.body.addEventListener('htmx:configRequest', function(evt) {
+  evt.detail.headers["Accept"] = "text/html,*/*";
+  console.log(evt.detail.headers);
+});
+
+// copy the link to clipboard
+function handleCopyLink(element) {
+  navigator.clipboard.writeText(element.dataset.url);
+}
+
+// copy the link and toggle copy button style
+function handleShortURLCopyLink(element) {
+  handleCopyLink(element);
+  const parent = document.querySelector("#shorturl");
+  if (!parent || parent.classList.contains("copied")) return;
+  parent.classList.add("copied");
+  setTimeout(function() {
+    parent.classList.remove("copied");
+  }, 1000);
+}

+ 1 - 1
tsconfig.json

@@ -4,7 +4,7 @@
     "module": "commonjs",
     "module": "commonjs",
     "sourceMap": true,
     "sourceMap": true,
     "outDir": "production-server",
     "outDir": "production-server",
-    "noUnusedLocals": true,
+    "noUnusedLocals": false,
     "resolveJsonModule": true,
     "resolveJsonModule": true,
     "esModuleInterop": true,
     "esModuleInterop": true,
     "noEmit": false,
     "noEmit": false,

Някои файлове не бяха показани, защото твърде много файлове са промени