Pouria Ezzati 1 anno fa
parent
commit
5ea233f06b

+ 10 - 0
jsconfig.json

@@ -0,0 +1,10 @@
+{
+  "compilerOptions": {
+    "module": "CommonJS",
+    "allowImportingTsExtensions": false
+  },
+  "exclude": [
+    "node_modules",
+    "**/node_modules/*"
+  ]
+}

+ 1 - 80
package-lock.json

@@ -10,7 +10,6 @@
       "license": "MIT",
       "dependencies": {
         "app-root-path": "3.1.0",
-        "axios": "1.7.7",
         "bcryptjs": "2.4.3",
         "bull": "4.16.2",
         "cookie-parser": "1.4.6",
@@ -41,7 +40,7 @@
         "pg-query-stream": "4.6.0",
         "signale": "1.4.0",
         "useragent": "2.3.0",
-        "uuid": "^10.0.0",
+        "uuid": "10.0.0",
         "winston": "3.3.3",
         "winston-daily-rotate-file": "4.7.1"
       },
@@ -1478,23 +1477,6 @@
         "lodash": "^4.17.14"
       }
     },
-    "node_modules/asynckit": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
-      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
-      "license": "MIT"
-    },
-    "node_modules/axios": {
-      "version": "1.7.7",
-      "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
-      "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
-      "license": "MIT",
-      "dependencies": {
-        "follow-redirects": "^1.15.6",
-        "form-data": "^4.0.0",
-        "proxy-from-env": "^1.1.0"
-      }
-    },
     "node_modules/babel-plugin-styled-components": {
       "version": "2.1.4",
       "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.1.4.tgz",
@@ -1907,18 +1889,6 @@
         "text-hex": "1.0.x"
       }
     },
-    "node_modules/combined-stream": {
-      "version": "1.0.8",
-      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
-      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
-      "license": "MIT",
-      "dependencies": {
-        "delayed-stream": "~1.0.0"
-      },
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
     "node_modules/commander": {
       "version": "10.0.1",
       "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
@@ -2146,15 +2116,6 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/delayed-stream": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
-      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.4.0"
-      }
-    },
     "node_modules/denque": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -2595,26 +2556,6 @@
       "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
       "license": "MIT"
     },
-    "node_modules/follow-redirects": {
-      "version": "1.15.9",
-      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
-      "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
-      "funding": [
-        {
-          "type": "individual",
-          "url": "https://github.com/sponsors/RubenVerborgh"
-        }
-      ],
-      "license": "MIT",
-      "engines": {
-        "node": ">=4.0"
-      },
-      "peerDependenciesMeta": {
-        "debug": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/foreach": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz",
@@ -2628,20 +2569,6 @@
       "integrity": "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw==",
       "license": "Apache2"
     },
-    "node_modules/form-data": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
-      "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
-      "license": "MIT",
-      "dependencies": {
-        "asynckit": "^0.4.0",
-        "combined-stream": "^1.0.8",
-        "mime-types": "^2.1.12"
-      },
-      "engines": {
-        "node": ">= 6"
-      }
-    },
     "node_modules/forwarded": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -4600,12 +4527,6 @@
         "node": ">= 0.10"
       }
     },
-    "node_modules/proxy-from-env": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
-      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
-      "license": "MIT"
-    },
     "node_modules/pseudomap": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",

+ 0 - 1
package.json

@@ -27,7 +27,6 @@
   "homepage": "https://github.com/thedevs-network/kutt#readme",
   "dependencies": {
     "app-root-path": "3.1.0",
-    "axios": "1.7.7",
     "bcryptjs": "2.4.3",
     "bull": "4.16.2",
     "cookie-parser": "1.4.6",

+ 1 - 0
server/env.js

@@ -23,6 +23,7 @@ const env = cleanEnv(process.env, {
   DEFAULT_MAX_STATS_PER_LINK: num({ default: 5000 }),
   DISALLOW_ANONYMOUS_LINKS: bool({ default: false }),
   DISALLOW_REGISTRATION: bool({ default: false }),
+  SERVER_IP_ADDRESS: str({ default: "" }),
   CUSTOM_DOMAIN_USE_HTTPS: bool({ default: false }),
   JWT_SECRET: str(),
   ADMIN_EMAILS: str({ default: "" }),

+ 34 - 38
server/handlers/auth.handler.js

@@ -3,36 +3,49 @@ 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) {
+const CustomError = utils.CustomError;
+
+function authenticate(type, error, isStrict, redirect) {
   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 (
+        redirect &&
+        ((!user && isStrict) ||
+        (user && isStrict && !user.verified) ||
+        (user && user.banned))
+      ) {
+        if (redirect === "page") {
+          res.redirect("/login");
+          return;
+        }
+        if (redirect === "header") {
+          res.setHeader("HX-Redirect", "/login");
+          res.send("NOT_AUTHENTICATED");
+          return;
+        }
+      }
+      
       if (!user && isStrict) {
-        req.viewTemplate = "partials/auth/form";
         throw new CustomError(error, 401);
       }
 
       if (user && isStrict && !user.verified) {
-        req.viewTemplate = "partials/auth/form";
         throw new CustomError("Your email address is not verified. " +
           "Sign up to get the verification link again.", 400);
       }
 
       if (user && user.banned) {
-        req.viewTemplate = "partials/auth/form";
         throw new CustomError("You're banned from using this website.", 403);
       }
 
@@ -49,10 +62,11 @@ function authenticate(type, error, isStrict) {
   }
 }
 
-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);
+const local = authenticate("local", "Login credentials are wrong.", true, null);
+const jwt = authenticate("jwt", "Unauthorized.", true, "header");
+const jwtPage = authenticate("jwt", "Unauthorized.", true, "page");
+const jwtLoose = authenticate("jwt", "Unauthorized.", false, null);
+const apikey = authenticate("localapikey", "API key is not correct.", false, null);
 
 async function cooldown(req, res, next) {
   if (env.DISALLOW_ANONYMOUS_LINKS) return next();
@@ -76,7 +90,6 @@ async function cooldown(req, res, next) {
 }
 
 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);
 }
@@ -104,11 +117,7 @@ function login(req, res) {
   const token = utils.signToken(req.user);
 
   if (req.isHTML) {
-    res.cookie("token", token, {
-      maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
-      httpOnly: true,
-      secure: env.isProd
-    });
+    utils.setToken(res, token);
     res.render("partials/auth/welcome");
     return;
   }
@@ -133,12 +142,8 @@ async function verify(req, res, next) {
   
   if (user) {
     const token = utils.signToken(user);
-    res.clearCookie("token", { httpOnly: true, secure: env.isProd });
-    res.cookie("token", token, {
-      maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
-      httpOnly: true,
-      secure: env.isProd
-    });
+    utils.deleteCurrentToken(res);
+    utils.setToken(res, token);
     res.locals.token_verified = true;
     req.cookies.token = token;
   }
@@ -208,7 +213,7 @@ async function resetPasswordRequest(req, res) {
   
   if (user) {
     // TODO: handle error
-    await mail.resetPasswordToken(user).catch(() => null);
+    mail.resetPasswordToken(user).catch(() => null);
   }
 
   if (req.isHTML) {
@@ -237,12 +242,8 @@ async function resetPassword(req, res, next) {
   
     if (user) {
       const token = utils.signToken(user);
-      res.clearCookie("token", { httpOnly: true, secure: env.isProd });
-      res.cookie("token", token, {
-        maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
-        httpOnly: true,
-        secure: env.isProd
-      });
+      utils.deleteCurrentToken(res);
+      utils.setToken(res, token);
       res.locals.token_verified = true;
       req.cookies.token = token;
     }
@@ -305,8 +306,6 @@ async function changeEmailRequest(req, res) {
 
 async function changeEmail(req, res, next) {
   const changeEmailToken = req.params.changeEmailToken;
-
-  console.log("-", changeEmailToken, "-");
   
   if (changeEmailToken) {
     const foundUser = await query.user.find({
@@ -332,12 +331,8 @@ async function changeEmail(req, res, next) {
   
     if (user) {
       const token = utils.signToken(user);
-      res.clearCookie("token", { httpOnly: true, secure: env.isProd });
-      res.cookie("token", token, {
-        maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
-        httpOnly: true,
-        secure: env.isProd
-      });
+      utils.deleteCurrentToken(res);
+      utils.setToken(res, token);
       res.locals.token_verified = true;
       req.cookies.token = token;
     }
@@ -355,6 +350,7 @@ module.exports = {
   generateApiKey,
   jwt,
   jwtLoose,
+  jwtPage,
   local,
   login,
   resetPassword,

+ 1 - 1
server/handlers/domains.handler.js

@@ -1,6 +1,6 @@
 const { Handler } = require("express");
 
-const { CustomError, sanitize, sleep } = require("../utils");
+const { CustomError, sanitize } = require("../utils");
 const query = require("../queries");
 const redis = require("../redis");
 

+ 7 - 10
server/handlers/helpers.handler.js

@@ -1,16 +1,14 @@
 const { validationResult } = require("express-validator");
 const signale = require("signale");
 
-const { CustomError, sanitize } = require("../utils");
 const { logger } = require("../config/winston");
-const query = require("../queries")
+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();
-// };
+function ip(req, res, next) {
+  req.realIP = req.headers["x-real-ip"] || req.connection.remoteAddress || "";
+  return next();
+};
 
 function error(error, req, res, _next) {
   if (env.isDev) {
@@ -42,7 +40,6 @@ function verify(req, res, next) {
     res.locals.errors[e.param] = e.msg;
   });
 
-
   throw new CustomError(error, 400);
 }
 
@@ -71,11 +68,10 @@ function parseQuery(req, res, next) {
   }
 
   const limit = parseInt(req.query.limit) || 10;
-  const skip = parseInt(req.query.skip) || 0;
 
   req.context = {
     limit: limit > 50 ? 50 : limit,
-    skip,
+    skip: parseInt(req.query.skip) || 0,
     all: admin ? req.query.all === "true" || req.query.all === "on" : false
   };
 
@@ -84,6 +80,7 @@ function parseQuery(req, res, next) {
 
 module.exports = {
   error,
+  ip,
   parseQuery,
   verify,
 }

+ 32 - 22
server/handlers/links.handler.js

@@ -1,3 +1,4 @@
+const { differenceInSeconds } = require("date-fns");
 const promisify = require("util").promisify;
 const bcrypt = require("bcryptjs");
 const isbot = require("isbot");
@@ -11,7 +12,6 @@ const query = require("../queries");
 const queue = require("../queues");
 const utils = require("../utils");
 const env = require("../env");
-const { differenceInSeconds } = require("date-fns");
 
 const CustomError = utils.CustomError;
 const dnsLookup = promisify(dns.lookup);
@@ -56,7 +56,7 @@ async function create(req, res) {
   
   const targetDomain = utils.removeWww(URL.parse(target).hostname);
   
-  const queries = await Promise.all([
+  const tasks = await Promise.all([
     validators.cooldown(req.user),
     validators.malware(req.user, target),
     validators.linksCount(req.user),
@@ -78,19 +78,19 @@ async function create(req, res) {
   
   // if "reuse" is true, try to return
   // the existent URL without creating one
-  if (queries[3]) {
-    return res.json(utils.sanitize.link(queries[3]));
+  if (tasks[3]) {
+    return res.json(utils.sanitize.link(tasks[3]));
   }
   
   // Check if custom link already exists
-  if (queries[4]) {
+  if (tasks[4]) {
     const error = "Custom URL is already in use.";
     res.locals.errors = { customurl: error };
     throw new CustomError(error);
   }
 
   // Create new link
-  const address = customurl || queries[5];
+  const address = customurl || tasks[5];
   const link = await query.link.create({
     password,
     address,
@@ -122,7 +122,6 @@ async function create(req, res) {
 }
 
 async function edit(req, res) {
-  const { address, target, description, expire_in, password } = req.body;
   const link = await query.link.find({
     uuid: req.params.id,
     ...(!req.user.admin && { user_id: req.user.id })
@@ -134,23 +133,32 @@ async function edit(req, res) {
 
   let isChanged = false;
   [
-    [address, "address"], 
-    [target, "target"], 
-    [description, "description"], 
-    [expire_in, "expire_in"], 
-    [password, "password"]
+    [req.body.address, "address"], 
+    [req.body.target, "target"], 
+    [req.body.description, "description"], 
+    [req.body.expire_in, "expire_in"], 
+    [req.body.password, "password"]
   ].forEach(([value, name]) => {
     if (!value) {
-      delete req.body[name];
-      return;
+      if (name === "password" && link.password) 
+        req.body.password = null;
+      else {
+        delete req.body[name];
+        return;
+      }
     }
-    if (value === link[name]) {
+    if (value === link[name] && name !== "password") {
       delete req.body[name];
       return;
     }
     if (name === "expire_in")
       if (differenceInSeconds(new Date(value), new Date(link.expire_in)) <= 60) 
           return;
+    if (name === "password")
+      if (value && value.replace(/•/ig, "").length === 0) {
+        delete req.body.password;
+        return;
+      }
     isChanged = true;
   });
 
@@ -158,23 +166,25 @@ async function edit(req, res) {
     throw new CustomError("Should at least update one field.");
   }
 
-  const targetDomain = utils.removeWww(URL.parse(target).hostname);
+  const { address, target, description, expire_in, password } = req.body;
+  
+  const targetDomain = target && utils.removeWww(URL.parse(target).hostname);
   const domain_id = link.domain_id || null;
 
-  const queries = await Promise.all([
+  const tasks = await Promise.all([
     validators.cooldown(req.user),
     target && validators.malware(req.user, target),
-    address && address !== link.address &&
+    address &&
       query.link.find({
         address,
         domain_id
       }),
-    validators.bannedDomain(targetDomain),
-    validators.bannedHost(targetDomain)
+    target && validators.bannedDomain(targetDomain),
+    target && validators.bannedHost(targetDomain)
   ]);
 
   // Check if custom link already exists
-  if (queries[2]) {
+  if (tasks[2]) {
     const error = "Custom URL is already in use.";
     res.locals.errors = { address: error };
     throw new CustomError("Custom URL is already in use.");
@@ -190,7 +200,7 @@ async function edit(req, res) {
       ...(description && { description }),
       ...(target && { target }),
       ...(expire_in && { expire_in }),
-      ...(password && { password })
+      ...((password || password === null) && { password })
     }
   );
 

+ 7 - 6
server/handlers/locals.handler.js

@@ -8,7 +8,7 @@ function isHTML(req, res, next) {
   next();
 }
 
-function addNoLayoutLocals(req, res, next) {
+function noLayout(req, res, next) {
   res.locals.layout = null;
   next();
 }
@@ -20,13 +20,14 @@ function viewTemplate(template) {
   }
 }
 
-function addConfigLocals(req, res, next) {
+function config(req, res, next) {
   res.locals.default_domain = env.DEFAULT_DOMAIN;
   res.locals.site_name = env.SITE_NAME;
+  res.locals.server_ip_address = env.SERVER_IP_ADDRESS;
   next();
 }
 
-async function addUserLocals(req, res, next) {
+async function user(req, res, next) {
   const user = req.user;
   res.locals.user = user;
   res.locals.domains = user && (await query.domain.get({ user_id: user.id })).map(utils.sanitize.domain);
@@ -50,12 +51,12 @@ function protected(req, res, next) {
 }
 
 module.exports = {
-  addConfigLocals,
-  addNoLayoutLocals,
-  addUserLocals,
+  config,
   createLink,
   editLink,
   isHTML,
+  noLayout,
   protected,
+  user,
   viewTemplate,
 }

+ 5 - 21
server/handlers/renders.handler.js

@@ -1,5 +1,5 @@
+const query = require("../queries");
 const utils = require("../utils");
-const query = require("../queries")
 const env = require("../env");
 
 async function homepage(req, res) {
@@ -10,7 +10,8 @@ async function homepage(req, res) {
 
 function login(req, res) {
   if (req.user) {
-    return res.redirect("/");
+    res.redirect("/");
+    return;
   }
   res.render("login", {
     title: "Log in or sign up"
@@ -18,7 +19,7 @@ function login(req, res) {
 }
 
 function logout(req, res) {
-  res.clearCookie("token", { httpOnly: true, secure: env.isProd });
+  utils.deleteCurrentToken(res);
   res.render("logout", {
     title: "Logging out.."
   });
@@ -31,21 +32,12 @@ function notFound(req, res) {
 }
 
 function settings(req, res) {
-  // TODO: make this a middelware function, apply it to where it's necessary
-  if (!req.user) {
-    return res.redirect("/");
-  }
   res.render("settings", {
     title: "Settings"
   });
 }
 
 function stats(req, res) {
-  // TODO: make this a middelware function, apply it to where it's necessary
-  if (!req.user) {
-    return res.redirect("/");
-  }
-  const id = req.query.id;
   res.render("stats", {
     title: "Stats"
   });
@@ -154,21 +146,13 @@ async function getReportEmail(req, res) {
   });
 }
 
-
 async function linkEdit(req, res) {
   const link = await query.link.find({
     uuid: req.params.id,
     ...(!req.user.admin && { user_id: req.user.id })
   });
-  // TODO: handle when no link
-  // if (!link) {
-  //   return res.render("partials/links/dialog/message", {
-  //     layout: false,
-  //     message: "Could not find the link."
-  //   });
-  // }
   res.render("partials/links/edit", {
-    ...utils.sanitize.link(link),
+    ...(!link && utils.sanitize.link(link)),
   });
 }
 

+ 1 - 1
server/handlers/users.handler.js

@@ -18,7 +18,7 @@ async function remove(req, res) {
   await query.user.remove(req.user);
 
   if (req.isHTML) {
-    res.clearCookie("token", { httpOnly: true, secure: env.isProd });
+    utils.deleteCurrentToken(res);
     res.setHeader("HX-Trigger-After-Swap", "redirectToHomepage");
     res.render("partials/settings/delete_account", {
       success: "Account has been deleted. Logging out..."

+ 34 - 44
server/handlers/validators.handler.js

@@ -1,8 +1,7 @@
-const { body, param } = require("express-validator");
 const { isAfter, subDays, subHours, addMilliseconds } = require("date-fns");
-const { promisify } = require("util");
+const { body, param } = require("express-validator");
+const promisify = require("util").promisify;
 const bcrypt = require("bcryptjs");
-const axios = require("axios");
 const dns = require("dns");
 const URL = require("url");
 const ms = require("ms");
@@ -109,11 +108,7 @@ const editLink = [
     .isLength({ min: 1, max: 2040 })
     .withMessage("Maximum URL length is 2040.")
     .customSanitizer(utils.addProtocol)
-    .custom(
-      value =>
-        urlRegex({ exact: true, strict: false }).test(value) ||
-        /^(?!https?)(\w+):\/\//.test(value)
-    )
+    .custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value))
     .withMessage("URL is not valid.")
     .custom(value => utils.removeWww(URL.parse(value).host) !== env.DEFAULT_DOMAIN)
     .withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
@@ -176,11 +171,12 @@ const addDomain = [
     .isLength({ min: 3, max: 64 })
     .withMessage("Domain length must be between 3 and 64.")
     .trim()
+    .customSanitizer(utils.addProtocol)
+    .custom(value => utils.urlRegex.test(value))
     .customSanitizer(value => {
       const parsed = URL.parse(value);
       return utils.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 => {
@@ -191,7 +187,7 @@ const addDomain = [
   body("homepage")
     .optional({ checkFalsy: true, nullable: true })
     .customSanitizer(utils.addProtocol)
-    .custom(value => urlRegex({ exact: true, strict: false }).test(value))
+    .custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value))
     .withMessage("Homepage is not valid.")
 ];
 
@@ -282,11 +278,11 @@ const signup = [
     .custom(async (value, { req }) => {
       const user = await query.user.find({ email: value });
 
-      if (user) {
+      if (user)
         req.user = user;
-      }
 
-      if (user?.verified) return Promise.reject();
+      if (user?.verified) 
+        return Promise.reject();
     })
     .withMessage("You can't use this email address.")
 ];
@@ -337,15 +333,6 @@ const resetPassword = [
     .withMessage("Email length must be max 255.")
 ];
 
-// 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.")
-// ];
-
 const deleteUser = [
   body("password", "Password is not valid.")
     .exists({ checkFalsy: true, checkNull: true })
@@ -375,31 +362,34 @@ function cooldown(user) {
 async function malware(user, target) {
   if (!env.GOOGLE_SAFE_BROWSING_KEY) return;
 
-  const isMalware = await axios.post(
+  const isMalware = await fetch(
     `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 }]
-      }
+      method: "post",
+      body: JSON.stringify({
+        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 }]
+        }
+      })
     }
-  );
+  ).then(res => res.json());
   if (!isMalware.data || !isMalware.data.matches) return;
 
   if (user) {

+ 1 - 4
server/mail/mail.js

@@ -23,10 +23,7 @@ const transporter = nodemailer.createTransport(mailConfig);
 // Read email templates
 const resetEmailTemplatePath = path.join(__dirname, "template-reset.html");
 const verifyEmailTemplatePath = path.join(__dirname, "template-verify.html");
-const changeEmailTemplatePath = path.join(
-  __dirname,
-  "template-change-email.html"
-);
+const changeEmailTemplatePath = path.join(__dirname,"template-change-email.html");
 const resetEmailTemplate = fs
   .readFileSync(resetEmailTemplatePath, { encoding: "utf-8" })
   .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)

+ 0 - 1
server/queries/link.queries.js

@@ -1,6 +1,5 @@
 const bcrypt = require("bcryptjs");
 
-// FIXME: circular dependency
 const CustomError = require("../utils").CustomError;
 const redis = require("../redis");
 const knex = require("../knex");

+ 5 - 5
server/queries/visit.queries.js

@@ -52,11 +52,11 @@ async function add(params) {
 };
 
 async function find(match, total) {
-  // if (match.link_id) {
-  //   const key = redis.key.stats(match.link_id);
-  //   const cached = await redis.client.get(key);
-  //   if (cached) return JSON.parse(cached);
-  // }
+  if (match.link_id) {
+    const key = redis.key.stats(match.link_id);
+    const cached = await redis.client.get(key);
+    if (cached) return JSON.parse(cached);
+  }
 
   const stats = {
     lastDay: {

+ 23 - 14
server/routes/renders.routes.js

@@ -12,7 +12,7 @@ const router = Router();
 router.get(
   "/",
   asyncHandler(auth.jwtLoose),
-  asyncHandler(locals.addUserLocals), 
+  asyncHandler(locals.user), 
   asyncHandler(renders.homepage)
 );
 
@@ -31,35 +31,42 @@ router.get(
 router.get(
   "/404", 
   asyncHandler(auth.jwtLoose),
+  asyncHandler(locals.user),
   asyncHandler(renders.notFound)
 );
 
 router.get(
   "/settings",
-  asyncHandler(auth.jwtLoose),
-  asyncHandler(locals.addUserLocals),
+  asyncHandler(auth.jwtPage),
+  asyncHandler(locals.user),
   asyncHandler(renders.settings)
 );
 
 router.get(
   "/stats",
-  asyncHandler(auth.jwtLoose),
-  asyncHandler(locals.addUserLocals),
+  asyncHandler(auth.jwtPage),
+  asyncHandler(locals.user),
   asyncHandler(renders.stats)
 );
 
 router.get(
   "/banned",
+  asyncHandler(auth.jwtLoose),
+  asyncHandler(locals.user),
   asyncHandler(renders.banned)
 );
 
 router.get(
   "/report",
+  asyncHandler(auth.jwtLoose),
+  asyncHandler(locals.user),
   asyncHandler(renders.report)
 );
 
 router.get(
   "/reset-password",
+  asyncHandler(auth.jwtLoose),
+  asyncHandler(locals.user),
   asyncHandler(renders.resetPassword)
 );
 
@@ -67,7 +74,7 @@ router.get(
   "/reset-password/:resetPasswordToken",
   asyncHandler(auth.resetPassword),
   asyncHandler(auth.jwtLoose),
-  asyncHandler(locals.addUserLocals),
+  asyncHandler(locals.user),
   asyncHandler(renders.resetPasswordResult)
 );
 
@@ -75,7 +82,7 @@ router.get(
   "/verify-email/:changeEmailToken",
   asyncHandler(auth.changeEmail),
   asyncHandler(auth.jwtLoose),
-  asyncHandler(locals.addUserLocals),
+  asyncHandler(locals.user),
   asyncHandler(renders.verifyChangeEmail)
 );
 
@@ -83,26 +90,28 @@ router.get(
   "/verify/:verificationToken",
   asyncHandler(auth.verify),
   asyncHandler(auth.jwtLoose),
-  asyncHandler(locals.addUserLocals),
+  asyncHandler(locals.user),
   asyncHandler(renders.verify)
 );
 
 router.get(
   "/terms",
+  asyncHandler(auth.jwtLoose),
+  asyncHandler(locals.user),
   asyncHandler(renders.terms)
 );
 
 // partial renders
 router.get(
   "/confirm-link-delete", 
-  locals.addNoLayoutLocals,
+  locals.noLayout,
   asyncHandler(auth.jwt),
   asyncHandler(renders.confirmLinkDelete)
 );
 
 router.get(
   "/confirm-link-ban", 
-  locals.addNoLayoutLocals,
+  locals.noLayout,
   locals.viewTemplate("partials/links/dialog/message"),
   asyncHandler(auth.jwt),
   asyncHandler(auth.admin), 
@@ -111,21 +120,21 @@ router.get(
 
 router.get(
   "/link/edit/:id",
-  locals.addNoLayoutLocals,
+  locals.noLayout,
   asyncHandler(auth.jwt),
   asyncHandler(renders.linkEdit)
 );
 
 router.get(
   "/add-domain-form", 
-  locals.addNoLayoutLocals,
+  locals.noLayout,
   asyncHandler(auth.jwt),
   asyncHandler(renders.addDomainForm)
 );
 
 router.get(
   "/confirm-domain-delete", 
-  locals.addNoLayoutLocals,
+  locals.noLayout,
   locals.viewTemplate("partials/settings/domain/delete"),
   asyncHandler(auth.jwt),
   asyncHandler(renders.confirmDomainDelete)
@@ -133,7 +142,7 @@ router.get(
 
 router.get(
   "/get-report-email", 
-  locals.addNoLayoutLocals,
+  locals.noLayout,
   locals.viewTemplate("partials/report/email"),
   asyncHandler(renders.getReportEmail)
 );

+ 3 - 3
server/routes/routes.js

@@ -9,11 +9,11 @@ const link = require("./link.routes");
 const user = require("./user.routes");
 const auth = require("./auth.routes");
 
-const apiRouter = Router();
 const renderRouter = Router();
-
 renderRouter.use(renders);
-apiRouter.use(locals.addNoLayoutLocals);
+
+const apiRouter = Router();
+apiRouter.use(locals.noLayout);
 apiRouter.use("/domains", domains);
 apiRouter.use("/health", health);
 apiRouter.use("/links", link);

+ 7 - 4
server/server.js

@@ -22,7 +22,8 @@ require("./passport");
 // create express app
 const app = express();
 
-// TODO: comments
+// stating that this app is running behind a proxy
+// and the express app should get the IP address from the proxy server
 app.set("trust proxy", true);
 
 if (env.isDev) {
@@ -36,27 +37,29 @@ app.use(express.urlencoded({ extended: true }));
 app.use(express.static("static"));
 
 app.use(passport.initialize());
-// app.use(helpers.ip);
+app.use(helpers.ip);
 app.use(locals.isHTML);
-app.use(locals.addConfigLocals);
+app.use(locals.config);
 
 // template engine / serve html
 app.set("view engine", "hbs");
 app.set("views", path.join(__dirname, "views"));
 utils.registerHandlebarsHelpers();
 
+// render html pages
 app.use("/", routes.render);
 
 // if is custom domain, redirect to the set homepage
 app.use(asyncHandler(links.redirectCustomDomainHomepage));
 
+// handle api requests
 app.use("/api/v2", routes.api);
 app.use("/api", routes.api);
 
 // finally, redirect the short link to the target
 app.get("/:id", asyncHandler(links.redirect));
 
-// Error handler
+// handle errors coming from above routes
 app.use(helpers.error);
   
 app.listen(env.PORT, () => {

+ 17 - 3
server/utils/utils.js

@@ -1,9 +1,9 @@
-const ms = require("ms");
-const path = require("path");
+const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays, subHours, subDays, subMonths, subYears } = require("date-fns");
 const nanoid = require("nanoid/generate");
 const JWT = require("jsonwebtoken");
-const { differenceInDays, differenceInHours, differenceInMonths, differenceInMilliseconds, addDays, subHours, subDays, subMonths, subYears } = require("date-fns");
+const path = require("path");
 const hbs = require("hbs");
+const ms = require("ms");
 
 const env = require("../env");
 
@@ -37,6 +37,18 @@ function signToken(user) {
     )
 }
 
+function setToken(res, token) {
+  res.cookie("token", token, {
+    maxAge: 1000 * 60 * 60 * 24 * 7, // expire after seven days
+    httpOnly: true,
+    secure: env.isProd
+  });
+}
+
+function deleteCurrentToken(res) {
+  res.clearCookie("token", { httpOnly: true, secure: env.isProd });
+}
+
 async function generateId(query, domain_id) {
   const address = nanoid(
     "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789",
@@ -262,6 +274,7 @@ function registerHandlebarsHelpers() {
 module.exports = {
   addProtocol,
   CustomError,
+  deleteCurrentToken,
   generateId,
   getDifferenceFunction,
   getInitStats,
@@ -276,6 +289,7 @@ module.exports = {
   registerHandlebarsHelpers,
   removeWww,
   sanitize,
+  setToken,
   signToken,
   sleep,
   statsObjectToArray,

+ 1 - 2
server/views/partials/header.hbs

@@ -2,8 +2,7 @@
   <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
+      {{site_name}}
     </a>
     <ul class="logo-links">
       <li>

+ 115 - 111
server/views/partials/links/edit.hbs

@@ -1,113 +1,117 @@
 <td class="content">
-  <form 
-    id="edit-form-{{id}}"
-    hx-patch="/api/links/{id}"
-    hx-ext="path-params"
-    hx-vals='{"id":"{{id}}"}' 
-    hx-select="form"
-    hx-swap="outerHTML"
-    hx-sync="this:replace"
-    class="{{class}}"
-  >
-    <div>
-      <label class="{{#if errors.target}}error{{/if}}">
-        Target:
-        <input 
-          id="edit-target-{{id}}"
-          name="target" 
-          type="text" 
-          placeholder="Target..." 
-          required="true"
-          value="{{target}}"
-          hx-preserve="true"
-        />
-        {{#if errors.target}}<p class="error">{{errors.target}}</p>{{/if}}
-      </label>
-      <label class="{{#if errors.address}}error{{/if}}">
-        localhost:3000/
-        <input 
-          id="edit-address-{{id}}"
-          name="address" 
-          type="text" 
-          placeholder="Custom URL..." 
-          required="true"
-          value="{{address}}"
-          hx-preserve="true"
-        />
-        {{#if errors.address}}<p class="error">{{errors.address}}</p>{{/if}}
-      </label>
-      <label class="{{#if errors.password}}error{{/if}}">
-        Password:
-        <input 
-          id="edit-password-{{id}}"
-          name="password" 
-          type="password" 
-          placeholder="Password..." 
-          value="{{#if password}}••••••••{{/if}}"
-          hx-preserve="true"
-        />
-        {{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
-      </label>
-    </div>
-    <div>
-      <label class="{{#if errors.description}}error{{/if}}">
-        Description:
-        <input 
-          id="edit-description-{{id}}"
-          name="description" 
-          type="text" 
-          placeholder="Description..." 
-          value="{{description}}"
-          hx-preserve="true"
-        />
-        {{#if errors.description}}<p class="error">{{errors.description}}</p>{{/if}}
-      </label>
-      <label class="{{#if errors.expire_in}}error{{/if}}">
-        Expire in:
-        <input 
-          id="edit-expire_in-{{id}}"
-          name="expire_in" 
-          type="text" 
-          placeholder="2 minutes/hours/days"
-          value="{{relative_expire_in}}"
-          hx-preserve="true"
-        />
-        {{#if errors.expire_in}}<p class="error">{{errors.expire_in}}</p>{{/if}}
-      </label>
-    </div>
-    <div>
-      <button 
-        type="button"
-        onclick="
-          const tr = closest('tr');
-          if (!tr) return;
-          tr.classList.remove('show');
-          tr.removeChild(tr.querySelector('.content'));
-        "
-      >
-        Close
-      </button>
-      <button type="submit" class="primary">
-        <span class="reload">
-          {{> icons/reload}}
-        </span>
-        <span class="loader">
-          {{> icons/spinner}}
-        </span>
-        Update
-      </button>
-    </div>
-    <div class="response">
-      {{#if error}}
-        {{#unless errors}}
-          <p class="error">{{error}}</p>
-        {{/unless}}
-      {{else if success}}
-        <p class="success">{{success}}</p>
-      {{/if}}
-    </div>
-    <template>
-      {{> links/tr}}
-    </template>
-  </form>
+  {{#if id}}
+    <form 
+      id="edit-form-{{id}}"
+      hx-patch="/api/links/{id}"
+      hx-ext="path-params"
+      hx-vals='{"id":"{{id}}"}' 
+      hx-select="form"
+      hx-swap="outerHTML"
+      hx-sync="this:replace"
+      class="{{class}}"
+    >
+      <div>
+        <label class="{{#if errors.target}}error{{/if}}">
+          Target:
+          <input 
+            id="edit-target-{{id}}"
+            name="target" 
+            type="text" 
+            placeholder="Target..." 
+            required="true"
+            value="{{target}}"
+            hx-preserve="true"
+          />
+          {{#if errors.target}}<p class="error">{{errors.target}}</p>{{/if}}
+        </label>
+        <label class="{{#if errors.address}}error{{/if}}">
+          localhost:3000/
+          <input 
+            id="edit-address-{{id}}"
+            name="address" 
+            type="text" 
+            placeholder="Custom URL..." 
+            required="true"
+            value="{{address}}"
+            hx-preserve="true"
+          />
+          {{#if errors.address}}<p class="error">{{errors.address}}</p>{{/if}}
+        </label>
+        <label class="{{#if errors.password}}error{{/if}}">
+          Password:
+          <input 
+            id="edit-password-{{id}}"
+            name="password" 
+            type="password" 
+            placeholder="Password..." 
+            value="{{#if password}}••••••••{{/if}}"
+            hx-preserve="true"
+          />
+          {{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
+        </label>
+      </div>
+      <div>
+        <label class="{{#if errors.description}}error{{/if}}">
+          Description:
+          <input 
+            id="edit-description-{{id}}"
+            name="description" 
+            type="text" 
+            placeholder="Description..." 
+            value="{{description}}"
+            hx-preserve="true"
+          />
+          {{#if errors.description}}<p class="error">{{errors.description}}</p>{{/if}}
+        </label>
+        <label class="{{#if errors.expire_in}}error{{/if}}">
+          Expire in:
+          <input 
+            id="edit-expire_in-{{id}}"
+            name="expire_in" 
+            type="text" 
+            placeholder="2 minutes/hours/days"
+            value="{{relative_expire_in}}"
+            hx-preserve="true"
+          />
+          {{#if errors.expire_in}}<p class="error">{{errors.expire_in}}</p>{{/if}}
+        </label>
+      </div>
+      <div>
+        <button 
+          type="button"
+          onclick="
+            const tr = closest('tr');
+            if (!tr) return;
+            tr.classList.remove('show');
+            tr.removeChild(tr.querySelector('.content'));
+          "
+        >
+          Close
+        </button>
+        <button type="submit" class="primary">
+          <span class="reload">
+            {{> icons/reload}}
+          </span>
+          <span class="loader">
+            {{> icons/spinner}}
+          </span>
+          Update
+        </button>
+      </div>
+      <div class="response">
+        {{#if error}}
+          {{#unless errors}}
+            <p class="error">{{error}}</p>
+          {{/unless}}
+        {{else if success}}
+          <p class="success">{{success}}</p>
+        {{/if}}
+      </div>
+      <template>
+        {{> links/tr}}
+      </template>
+    </form>
+  {{else}}
+    <p class="no-links">No link was found.</p>
+  {{/if}}
 </td>

+ 9 - 2
server/views/partials/settings/domain/index.hbs

@@ -6,10 +6,17 @@
   <b>{{default_domain}}/shorturl</b> you can have
   <b>yoursite.com/shorturl.</b>
 </p>
+
 <p>
-  Point your domain's A record to <b>192.64.116.170</b> then add the domain
-  via the form below:
+  Point your domain's A record to 
+  {{#if server_ip_address}}
+    <b>{{server_ip_address}}</b>
+  {{else}}
+    our <b>IP address</b>
+  {{/if}} 
+  then add the domain via the form below:
 </p>
+
 {{> settings/domain/table}}
 <div class="add-domain-wrapper">
   <button

+ 5 - 0
static/css/styles.css

@@ -1407,6 +1407,11 @@ main form label#advanced input {
 
 #links-table-wrapper table tr.edit form .response p { margin: 2rem 0 0; }
 
+#links-table-wrapper table tr.edit p.no-links {
+  width: 100%;
+  text-align: center;
+}
+
 .dialog .ban-checklist {
   display: flex;
   align-items: center;

+ 0 - 1
static/scripts/main.js

@@ -117,7 +117,6 @@ function handleShortURLCopyLink(element) {
   }, 1000);
 }
 
-// TODO: make it an extension
 // open and close dialog
 function openDialog(id, name) {
   const dialog = document.getElementById(id);