Kaynağa Gözat

(wip): nextjs to htmx

Pouria Ezzati 1 yıl önce
ebeveyn
işleme
8fe106c2d6
81 değiştirilmiş dosya ile 3462 ekleme ve 2288 silme
  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 = {
   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"
-  }
-}

Dosya farkı çok büyük olduğundan ihmal edildi
+ 366 - 374
package-lock.json


+ 5 - 22
package.json

@@ -7,15 +7,13 @@
     "test": "jest --passWithNoTests",
     "docker:build": "docker build -t kutt .",
     "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/ ",
     "start": "npm run migrate && cross-env NODE_ENV=production node production-server/server.js",
     "migrate": "knex migrate:latest --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": {
     "type": "git",
@@ -48,6 +46,7 @@
     "express-async-handler": "1.1.4",
     "express-validator": "^6.14.2",
     "geoip-lite": "^1.4.6",
+    "hbs": "^4.2.0",
     "helmet": "^6.0.0",
     "ioredis": "^5.2.4",
     "isbot": "^3.6.3",
@@ -58,7 +57,6 @@
     "morgan": "^1.10.0",
     "ms": "^2.1.3",
     "nanoid": "^2.1.11",
-    "next": "^12.3.3",
     "node-cron": "^3.0.2",
     "nodemailer": "^6.8.0",
     "p-queue": "^7.3.0",
@@ -70,13 +68,6 @@
     "pg-query-stream": "^4.2.4",
     "qrcode.react": "^3.1.0",
     "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",
     "recharts": "^2.1.16",
     "redis": "^4.5.0",
@@ -95,6 +86,7 @@
     "@types/cookie-parser": "^1.4.3",
     "@types/cors": "^2.8.12",
     "@types/express": "^4.17.14",
+    "@types/hbs": "^4.0.4",
     "@types/jest": "^26.0.20",
     "@types/jsonwebtoken": "^7.2.8",
     "@types/morgan": "^1.7.37",
@@ -104,19 +96,10 @@
     "@types/node-cron": "^2.0.2",
     "@types/nodemailer": "^6.4.6",
     "@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/signale": "^1.4.4",
     "@types/styled-components": "^5.1.7",
-    "@typescript-eslint/eslint-plugin": "^5.42.1",
-    "@typescript-eslint/parser": "^5.42.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",
     "jest": "^29.3.1",
     "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;
 
@@ -44,7 +44,7 @@ const options = {
 };
 
 // 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),
   transports: [
     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`
-export const stream = {
+const stream = {
   write: message => {
     logger.info(message);
   }
@@ -67,3 +67,8 @@ winston.addColors({
   info: "green",
   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, {
   PORT: num({ default: 3000 }),
@@ -30,7 +28,7 @@ const env = cleanEnv(process.env, {
   ADMIN_EMAILS: str({ default: "" }),
   RECAPTCHA_SITE_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_PORT: num(),
   MAIL_SECURE: bool({ default: false }),
@@ -41,4 +39,4 @@ const env = cleanEnv(process.env, {
   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();
 
     passport.authenticate(type, (err, user) => {
       if (err) return next(err);
+      const accepts = req.accepts(["json", "html"]);
 
       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) {
-        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) {
-        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) {
@@ -49,27 +67,27 @@ const authenticate = (
       }
       return 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();
   const cooldownConfig = env.NON_USER_COOLDOWN;
   if (req.user || !cooldownConfig) return next();
-
+  
   const ip = await query.ip.find({
     ip: req.realIP.toLowerCase(),
     created_at: [">", subMinutes(new Date(), cooldownConfig).toISOString()]
   });
-
+  
   if (ip) {
     const timeToWait =
       cooldownConfig - differenceInMinutes(new Date(), new Date(ip.created_at));
@@ -79,60 +97,66 @@ export const cooldown: Handler = async (req, res, 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();
   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 password = await bcrypt.hash(req.body.password, salt);
 
+  const accepts = req.accepts(["json", "html"]);
+  
   const user = await query.user.add(
     { email: req.body.email, password },
     req.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 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 });
-};
+}
 
-export const verify: Handler = async (req, res, next) => {
+/**
+ * @type {import("express").Handler}
+ */
+async function verify(req, res, next) {
   if (!req.params.verificationToken) return next();
-
+  
   const [user] = await query.user.update(
     {
       verification_token: req.params.verificationToken,
@@ -144,45 +168,54 @@ export const verify: Handler = async (req, res, next) => {
       verification_expires: null
     }
   );
-
+  
   if (user) {
     const token = utils.signToken(user);
     req.token = token;
   }
-
+  
   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 password = await bcrypt.hash(req.body.password, salt);
-
+  
   const [user] = await query.user.update({ id: req.user.id }, { password });
-
+  
   if (!user) {
     throw new CustomError("Couldn't change the password. Try again later.");
   }
-
+  
   return res
     .status(200)
     .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);
-
+  
   redis.remove.user(req.user);
-
+  
   const [user] = await query.user.update({ id: req.user.id }, { apikey });
-
+  
   if (!user) {
     throw new CustomError("Couldn't generate API key. Please try again later.");
   }
-
+  
   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(
     { email: req.body.email },
     {
@@ -190,19 +223,22 @@ export const resetPasswordRequest: Handler = async (req, res) => {
       reset_password_expires: addMinutes(new Date(), 30).toISOString()
     }
   );
-
+  
   if (user) {
     await mail.resetPasswordToken(user);
   }
-
+  
   return res.status(200).send({
     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;
-
+  
   if (resetPasswordToken) {
     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 }
     );
-
+  
     if (user) {
-      const token = utils.signToken(user as UserJoined);
+      const token = utils.signToken(user);
       req.token = token;
     }
   }
   return next();
-};
+}
 
-export const signupAccess: Handler = (req, res, next) => {
+/**
+ * @type {import("express").Handler}
+ */
+function signupAccess(req, res, next) {
   if (!env.DISALLOW_REGISTRATION) return next();
   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 isMatch = await bcrypt.compare(password, req.user.password);
-
+  
   if (!isMatch) {
     throw new CustomError("Password is wrong.", 400);
   }
-
+  
   const currentUser = await query.user.find({ email });
-
+  
   if (currentUser) {
     throw new CustomError("Can't use this email address.", 400);
   }
-
+  
   const [updatedUser] = await query.user.update(
     { id: req.user.id },
     {
@@ -248,30 +290,33 @@ export const changeEmailRequest: Handler = async (req, res) => {
       change_email_expires: addMinutes(new Date(), 30).toISOString()
     }
   );
-
+  
   redis.remove.user(updatedUser);
-
+  
   if (updatedUser) {
     await mail.changeEmail({ ...updatedUser, email });
   }
-
+  
   return res.status(200).send({
     message:
       "If email address exists, an email " +
       "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;
-
+  
   if (changeEmailToken) {
     const foundUser = await query.user.find({
       change_email_token: changeEmailToken
     });
-
+  
     if (!foundUser) return next();
-
+  
     const [user] = await query.user.update(
       {
         change_email_token: changeEmailToken,
@@ -284,13 +329,32 @@ export const changeEmail: Handler = async (req, res, next) => {
         email: foundUser.change_email_address
       }
     );
-
+  
     redis.remove.user(foundUser);
-
+  
     if (user) {
-      const token = utils.signToken(user as UserJoined);
+      const token = utils.signToken(user);
       req.token = token;
     }
   }
   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 query from "../queries";
 import * as redis from "../redis";
-import { CustomError, sanitize } from "../utils";
+import { CustomError, sanitize } from "../utils/utils";
 
 export const add: Handler = async (req, res) => {
   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 * as utils from "../utils";
+import * as utils from "../utils/utils";
 
 export const get = async (req, res) => {
   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({
   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 = {
   host: env.MAIL_HOST,
@@ -20,8 +20,6 @@ const mailConfig = {
 
 const transporter = nodemailer.createTransport(mailConfig);
 
-export default transporter;
-
 // Read email templates
 const resetEmailTemplatePath = path.join(__dirname, "template-reset.html");
 const verifyEmailTemplatePath = path.join(__dirname, "template-verify.html");
@@ -42,7 +40,7 @@ const changeEmailTemplate = fs
   .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
   .replace(/{{site_name}}/gm, env.SITE_NAME);
 
-export const verification = async (user: User) => {
+async function verification(user) {
   const mail = await transporter.sendMail({
     from: env.MAIL_FROM || env.MAIL_USER,
     to: user.email,
@@ -60,9 +58,9 @@ export const verification = async (user: User) => {
   if (!mail.accepted.length) {
     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({
     from: env.MAIL_FROM || env.MAIL_USER,
     to: user.change_email_address,
@@ -76,13 +74,13 @@ export const changeEmail = async (user: User) => {
       .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
       .replace(/{{site_name}}/gm, env.SITE_NAME)
   });
-
+  
   if (!mail.accepted.length) {
     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({
     from: env.MAIL_FROM || env.MAIL_USER,
     to: user.email,
@@ -94,10 +92,16 @@ export const resetPasswordToken = async (user: User) => {
       .replace(/{{resetpassword}}/gm, user.reset_password_token)
       .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
   });
-
+  
   if (!mail.accepted.length) {
     throw new CustomError(
       "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.
 
 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.
 
 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.
 

+ 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.createIPTable(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
 }
+
+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.createIPTable(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
 }
+
+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");
   if (!hasDescription) {
     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;
 }
+
+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");
   if (!hasExpireIn) {
     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;
 }
+
+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(
     "users",
     "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;
 }
+
+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");
   if (!hasTable) {
     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");
   if (!hasTable) {
     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");
   if (!hasTable) {
     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");
 
   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");
   if (!hasTable) {
     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");
   if (!hasTable) {
     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 = {
   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 = [
   "links.id",
@@ -21,7 +22,7 @@ const selectable = [
   "domains.address as domain"
 ];
 
-const normalizeMatch = (match: Partial<Link>): Partial<Link> => {
+function normalizeMatch(match) {
   const newMatch = { ...match };
 
   if (newMatch.address) {
@@ -42,92 +43,76 @@ const normalizeMatch = (match: Partial<Link>): Partial<Link> => {
   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]) => {
     query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
   });
-
-  if (params.search) {
+  
+  if (params?.search) {
     query.andWhereRaw(
       "links.description || ' '  || links.address || ' ' || target ILIKE '%' || ? || '%'",
       [params.search]
     );
   }
-
+  
   const [{ count }] = await query.count("id");
-
+  
   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)
     .where(normalizeMatch(match))
     .offset(params.skip)
     .limit(params.limit)
     .orderBy("created_at", "desc");
-
-  if (params.search) {
+  
+  if (params?.search) {
     query.andWhereRaw(
       "concat_ws(' ', description, links.address, target, domains.address) ILIKE '%' || ? || '%'",
       [params.search]
     );
   }
-
+  
   query.leftJoin("domains", "links.domain_id", "domains.id");
-
-  const links: LinkJoinedDomain[] = await query;
-
+  
+  const links = await query;
+  
   return links;
-};
+}
 
-export const find = async (match: Partial<Link>): Promise<Link> => {
+async function find(match) {
   if (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);
   }
-
-  const link = await knex<Link>("links")
+  
+  const link = await knex("links")
     .select(...selectable)
     .where(normalizeMatch(match))
     .leftJoin("domains", "links.domain_id", "domains.id")
     .first();
-
+  
   if (link) {
     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;
-};
-
-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) {
     const salt = await bcrypt.genSalt(12);
     encryptedPassword = await bcrypt.hash(params.password, salt);
   }
-
-  const [link]: LinkJoinedDomain[] = await knex<LinkJoinedDomain>(
+  
+  const [link] = await knex(
     "links"
   ).insert(
     {
@@ -141,61 +126,66 @@ export const create = async (params: Create) => {
     },
     "*"
   );
-
+  
   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) {
     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);
-
+  
   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]) => {
     findQuery.andWhere(key, ...(Array.isArray(value) ? value : [value]));
     deleteQuery.andWhere(key, ...(Array.isArray(value) ? value : [value]));
   });
-
+  
   const links = await findQuery;
-
+  
   links.forEach(redis.remove.link);
-
+  
   await deleteQuery.delete();
-};
+}
 
-export const update = async (match: Partial<Link>, update: Partial<Link>) => {
+async function update(match, update) {
   if (update.password) {
     const salt = await bcrypt.genSalt(12);
     update.password = await bcrypt.hash(update.password, salt);
   }
-
-  const links = await knex<Link>("links")
+  
+  const links = await knex("links")
     .where(match)
     .update({ ...update, updated_at: new Date().toISOString() }, "*");
-
+  
   links.forEach(redis.remove.link);
-
+  
   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) {
     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) {
     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) {
       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;
-};
 
-interface Add {
-  email: string;
-  password: string;
 }
 
-export const add = async (params: Add, user?: User) => {
+async function add(params, user) {
   const data = {
     email: params.email,
     password: params.password,
     verification_token: uuid(),
     verification_expires: addMinutes(new Date(), 60).toISOString()
   };
-
+  
   if (user) {
-    await knex<User>("users")
+    await knex("users")
       .where("id", user.id)
       .update({ ...data, updated_at: new Date().toISOString() });
   } else {
-    await knex<User>("users").insert(data);
+    await knex("users").insert(data);
   }
-
+  
   redis.remove.user(user);
-
+  
   return {
     ...user,
     ...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]) => {
     query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
   });
-
+  
   const users = await query.update(
     { ...update, updated_at: new Date().toISOString() },
     "*"
   );
-
+  
   users.forEach(redis.remove.user);
-
+  
   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);
-
+  
   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 * as utils from "../utils";
+import * as utils from "../utils/utils";
 import redisClient, * as redis from "../redis";
 import knex from "../knex";
 

+ 1 - 1
server/queues/visit.ts

@@ -3,7 +3,7 @@ import geoip from "geoip-lite";
 import URL from "url";
 
 import query from "../queries";
-import { getStatsLimit, removeWww } from "../utils";
+import { getStatsLimit, removeWww } from "../utils/utils";
 
 const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"];
 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({
   host: env.REDIS_HOST,
@@ -9,31 +9,28 @@ const client = new Redis({
   ...(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;
     return client.del(key.domain(domain.address));
   },
-  host: (host?: Host) => {
+  host: (host) => {
     if (!host) return;
     return client.del(key.host(host.address));
   },
-  link: (link?: Link) => {
+  link: (link) => {
     if (!link) return;
     return client.del(key.link(link.address, link.domain_id));
   },
-  user: (user?: User) => {
+  user: (user) => {
     if (!user) return;
     return Promise.all([
       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.jwt),
   validators.addDomain,
-  asyncHandler(helpers.verify),
+  asyncHandler(helpers.verify()),
   asyncHandler(domains.add)
 );
 
@@ -22,7 +22,7 @@ router.delete(
   asyncHandler(auth.apikey),
   asyncHandler(auth.jwt),
   validators.removeDomain,
-  asyncHandler(helpers.verify),
+  asyncHandler(helpers.verify()),
   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.jwt),
   validators.deleteUser,
-  asyncHandler(helpers.verify),
+  asyncHandler(helpers.verif()),
   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


Dosya farkı çok büyük olduğundan ihmal edildi
+ 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",
     "sourceMap": true,
     "outDir": "production-server",
-    "noUnusedLocals": true,
+    "noUnusedLocals": false,
     "resolveJsonModule": true,
     "esModuleInterop": true,
     "noEmit": false,

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor