Przeglądaj źródła

:boom: Move to Postgres from MongoDB

poeti8 6 lat temu
rodzic
commit
752dc7cd6c

+ 4 - 3
.eslintrc

@@ -1,10 +1,8 @@
 {
   "extends": [
     "eslint:recommended",
-    "plugin:@typescript-eslint/eslint-recommended",
     "plugin:@typescript-eslint/recommended",
-    "prettier",
-    "prettier/@typescript-eslint"
+    "plugin:prettier/recommended"
   ],
   "parser": "@typescript-eslint/parser",
   "parserOptions": {
@@ -15,6 +13,7 @@
     "eqeqeq": ["warn", "always", { "null": "ignore" }],
     "no-useless-return": "warn",
     "no-var": "warn",
+    "no-console": "warn",
     "max-len": ["warn", { "comments": 80 }],
     "no-param-reassign": ["warn", { "props": false }],
     "require-atomic-updates": 0,
@@ -22,11 +21,13 @@
     "@typescript-eslint/no-unused-vars": "off", // "warn" for production
     "@typescript-eslint/no-explicit-any": "off", // "warn" for production
     "@typescript-eslint/no-var-requires": "off",
+    "@typescript-eslint/camelcase": "off",
     "@typescript-eslint/no-object-literal-type-assertion": "off",
     "@typescript-eslint/no-parameter-properties": "off",
     "@typescript-eslint/explicit-function-return-type": "off"
   },
   "env": {
+    "es6": true,
     "browser": true,
     "node": true,
     "mocha": true

+ 3 - 2
client/components/Stats/Stats.js

@@ -85,10 +85,10 @@ class Stats extends Component {
   }
 
   componentDidMount() {
-    const { id } = this.props;
+    const { domain, id } = this.props;
     if (!id) return null;
     return axios
-      .get(`/api/url/stats?id=${id}`, { headers: { Authorization: cookie.get('token') } })
+      .get(`/api/url/stats?id=${id}&domain=${domain}`, { headers: { Authorization: cookie.get('token') } })
       .then(({ data }) =>
         this.setState({
           stats: data,
@@ -155,6 +155,7 @@ class Stats extends Component {
 
 Stats.propTypes = {
   isAuthenticated: PropTypes.bool.isRequired,
+  domain: PropTypes.string.isRequired,
   id: PropTypes.string.isRequired,
   showPageLoading: PropTypes.func.isRequired,
 };

+ 2 - 2
client/components/Table/TBody/TBody.js

@@ -93,13 +93,13 @@ const TableBody = ({ copiedIndex, handleCopy, tableLoading, showModal, urls }) =
         <a href={url.target}>{url.target}</a>
       </Td>
       <Td flex="1" date>
-        {`${distanceInWordsToNow(url.createdAt)} ago`}
+        {`${distanceInWordsToNow(url.created_at)} ago`}
       </Td>
       <Td flex="1" withFade>
         <TBodyShortUrl index={index} copiedIndex={copiedIndex} handleCopy={handleCopy} url={url} />
       </Td>
       <Td flex="1">
-        <TBodyCount url={url} showModal={showModal} />
+        <TBodyCount url={url} showModal={showModal(url)} />
       </Td>
     </tr>
   );

+ 4 - 4
client/components/Table/TBody/TBodyCount.js

@@ -50,9 +50,9 @@ class TBodyCount extends Component {
 
   goTo(e) {
     e.preventDefault();
+    const { id, domain } = this.props.url;
     this.props.showLoading();
-    const host = URL.parse(this.props.url.shortLink).hostname;
-    Router.push(`/stats?id=${this.props.url.id}${`&domain=${host}`}`);
+    Router.push(`/stats?id=${id}${domain ? `&domain=${domain}`: ''}`);
   }
 
   render() {
@@ -61,10 +61,10 @@ class TBodyCount extends Component {
 
     return (
       <Wrapper>
-        {url.count || 0}
+        {url.visit_count || 0}
         <Actions>
           {url.password && <Icon src="/images/lock.svg" lowopacity />}
-          {url.count > 0 && (
+          {url.visit_count > 0 && (
             <TBodyButton withText onClick={this.goTo}>
               <Icon src="/images/chart.svg" />
               Stats

+ 9 - 9
client/components/Table/Table.js

@@ -93,15 +93,15 @@ class Table extends Component {
     }, 1500);
   }
 
-  showModal(e) {
-    e.preventDefault();
-    const modalUrlId = e.currentTarget.dataset.id;
-    const modalUrlDomain = e.currentTarget.dataset.host;
-    this.setState({
-      modalUrlId,
-      modalUrlDomain,
-      showModal: true,
-    });
+  showModal(url) {
+    return e => {
+      e.preventDefault();
+      this.setState({
+        modalUrlId: url.address,
+        modalUrlDomain: url.domain,
+        showModal: true,
+      });
+    }
   }
 
   closeModal() {

+ 1 - 13
client/pages/_document.js

@@ -59,20 +59,8 @@ class AppDocument extends Document {
             }}
           />
 
-          <script
-            dangerouslySetInnerHTML={{
-              __html: `
-                if('serviceWorker' in navigator) {
-                  navigator.serviceWorker.register('sw.js', {
-                      scope: './'
-                    })
-                  }
-                `,
-            }}
-          />
-
           <script src="https://www.google.com/recaptcha/api.js?render=explicit" async defer />
-          <script src="/analytics.js" />
+          <script src="static/analytics.js" />
         </Head>
         <body style={style}>
           <Main />

+ 5 - 3
client/pages/stats.js

@@ -4,23 +4,25 @@ import BodyWrapper from '../components/BodyWrapper';
 import Stats from '../components/Stats';
 import { authUser } from '../actions';
 
-const StatsPage = ({ id }) => (
+const StatsPage = ({ domain, id }) => (
   <BodyWrapper>
-    <Stats id={id} />
+    <Stats domain={domain} id={id} />
   </BodyWrapper>
 );
 
 StatsPage.getInitialProps = ({ req, reduxStore, query }) => {
   const token = req && req.cookies && req.cookies.token;
   if (token && reduxStore) reduxStore.dispatch(authUser(token));
-  return { id: query && query.id };
+  return query;
 };
 
 StatsPage.propTypes = {
+  domain: PropTypes.string,
   id: PropTypes.string,
 };
 
 StatsPage.defaultProps = {
+  domain: '',
   id: '',
 };
 

+ 114 - 0
global.d.ts

@@ -0,0 +1,114 @@
+interface User {
+  id: number;
+  apikey?: string;
+  banned: boolean;
+  banned_by_id?: number;
+  cooldowns?: string[];
+  created_at: string;
+  email: string;
+  password: string;
+  reset_password_expires?: string;
+  reset_password_token?: string;
+  updated_at: string;
+  verification_expires?: string;
+  verification_token?: string;
+  verified?: boolean;
+}
+
+interface UserJoined extends User {
+  admin?: boolean;
+  homepage?: string;
+  domain?: string;
+  domain_id?: number;
+}
+
+interface Domain {
+  id: number;
+  address: string;
+  banned: boolean;
+  banned_by_id?: number;
+  created_at: string;
+  homepage?: string;
+  updated_at: string;
+  user_id?: number;
+}
+
+interface Host {
+  id: number;
+  address: string;
+  banned: boolean;
+  banned_by_id?: number;
+  created_at: string;
+  updated_at: string;
+}
+
+interface IP {
+  id: number;
+  created_at: string;
+  updated_at: string;
+  ip: string;
+}
+
+interface Link {
+  id: number;
+  address: string;
+  banned: boolean;
+  banned_by_id?: number;
+  created_at: string;
+  domain_id?: number;
+  password?: string;
+  target: string;
+  updated_at: string;
+  user_id?: number;
+  visit_count: number;
+}
+
+interface LinkJoinedDomain extends Link {
+  domain?: string;
+}
+
+interface Visit {
+  id: number;
+  countries: Record<string, number>;
+  created_at: string;
+  link_id: number;
+  referrers: Record<string, number>;
+  total: number;
+  br_chrome: number;
+  br_edge: number;
+  br_firefox: number;
+  br_ie: number;
+  br_opera: number;
+  br_other: number;
+  br_safari: number;
+  os_android: number;
+  os_ios: number;
+  os_linux: number;
+  os_macos: number;
+  os_other: number;
+  os_windows: number;
+}
+
+interface Stats {
+  browser: Record<
+    'chrome' | 'edge' | 'firefox' | 'ie' | 'opera' | 'other' | 'safari',
+    number
+  >;
+  os: Record<
+    'android' | 'ios' | 'linux' | 'macos' | 'other' | 'windows',
+    number
+  >;
+  country: Record<string, number>;
+  referrer: Record<string, number>;
+}
+
+declare namespace Express {
+  export interface Request {
+    realIP?: string;
+    pageType?: string;
+    linkTarget?: string;
+    protectedLink?: string;
+    token?: string;
+    user: UserJoined;
+  }
+}

+ 1 - 1
nodemon.json

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

Plik diff jest za duży
+ 319 - 266
package-lock.json


+ 28 - 23
package.json

@@ -8,7 +8,7 @@
     "docker:build": "docker build -t kutt .",
     "docker:run": "docker run -p 3000:3000 --env-file .env -d kutt:latest",
     "dev": "nodemon server/server.ts",
-    "build": "next build client/ && tsc --project tsconfig.server.json",
+    "build": "next build client/ && rimraf production-server && tsc --project tsconfig.server.json && copyfiles -f \"server/mail/*.html\" production-server/mail",
     "start": "NODE_ENV=production node production-server/server.js",
     "lint": "eslint server/ --ext .js,.ts --fix",
     "lint:nofix": "eslint server/ --ext .js,.ts"
@@ -32,21 +32,6 @@
   },
   "homepage": "https://github.com/TheDevs-Network/kutt#readme",
   "dependencies": {
-    "@types/body-parser": "^1.17.0",
-    "@types/cookie-parser": "^1.4.1",
-    "@types/date-fns": "^2.6.0",
-    "@types/dotenv": "^4.0.3",
-    "@types/express": "^4.16.0",
-    "@types/helmet": "0.0.38",
-    "@types/jsonwebtoken": "^7.2.8",
-    "@types/jwt-decode": "^2.2.1",
-    "@types/mongodb": "^3.1.17",
-    "@types/mongoose": "^5.3.5",
-    "@types/next": "^7.0.5",
-    "@types/passport": "^0.4.7",
-    "@types/passport-jwt": "^3.0.1",
-    "@types/redis": "^2.8.10",
-    "@zeit/next-typescript": "^1.1.1",
     "axios": "^0.19.0",
     "bcryptjs": "^2.4.3",
     "cookie-parser": "^1.4.4",
@@ -62,6 +47,7 @@
     "js-cookie": "^2.2.0",
     "jsonwebtoken": "^8.4.0",
     "jwt-decode": "^2.2.0",
+    "knex": "^0.19.2",
     "lodash": "^4.17.11",
     "mongoose": "^5.6.4",
     "morgan": "^1.9.1",
@@ -73,10 +59,13 @@
     "next-redux-wrapper": "^2.1.0",
     "node-cron": "^2.0.3",
     "nodemailer": "^6.3.0",
+    "p-queue": "^6.1.1",
     "passport": "^0.4.0",
     "passport-jwt": "^4.0.0",
     "passport-local": "^1.0.0",
     "passport-localapikey-update": "^0.6.0",
+    "pg": "^7.12.1",
+    "pg-query-stream": "^2.0.0",
     "prop-types": "^15.7.2",
     "qrcode.react": "^0.8.0",
     "raven": "^2.6.4",
@@ -104,14 +93,28 @@
     "@babel/preset-env": "^7.3.1",
     "@babel/register": "^7.0.0",
     "@types/bcryptjs": "^2.4.2",
+    "@types/body-parser": "^1.17.0",
+    "@types/cookie-parser": "^1.4.1",
     "@types/cors": "^2.8.5",
+    "@types/date-fns": "^2.6.0",
+    "@types/dotenv": "^4.0.3",
+    "@types/express": "^4.16.0",
+    "@types/helmet": "0.0.38",
+    "@types/jsonwebtoken": "^7.2.8",
+    "@types/jwt-decode": "^2.2.1",
+    "@types/mongodb": "^3.1.17",
+    "@types/mongoose": "^5.3.5",
     "@types/morgan": "^1.7.36",
     "@types/ms": "^0.7.30",
+    "@types/next": "^7.0.5",
     "@types/node-cron": "^2.0.2",
     "@types/nodemailer": "^6.2.1",
-    "@types/passport-local": "^1.0.33",
-    "@typescript-eslint/eslint-plugin": "^1.13.0",
-    "@typescript-eslint/parser": "^1.13.0",
+    "@types/pg": "^7.11.0",
+    "@types/pg-query-stream": "^1.0.3",
+    "@types/redis": "^2.8.10",
+    "@typescript-eslint/eslint-plugin": "^2.0.0",
+    "@typescript-eslint/parser": "^2.0.0",
+    "@zeit/next-typescript": "^1.1.1",
     "babel": "^6.23.0",
     "babel-cli": "^6.26.0",
     "babel-core": "^6.26.3",
@@ -119,20 +122,22 @@
     "babel-plugin-styled-components": "^1.10.0",
     "babel-preset-env": "^1.7.0",
     "chai": "^4.1.2",
+    "copyfiles": "^2.1.1",
     "deep-freeze": "^0.0.1",
-    "eslint": "^6.1.0",
+    "eslint": "^5.4.0",
     "eslint-config-airbnb": "^16.1.0",
-    "eslint-config-prettier": "^6.0.0",
+    "eslint-config-prettier": "^6.1.0",
     "eslint-plugin-import": "^2.16.0",
     "eslint-plugin-jsx-a11y": "^6.2.1",
-    "eslint-plugin-prettier": "^2.7.0",
+    "eslint-plugin-prettier": "^3.1.0",
     "eslint-plugin-react": "^7.14.3",
     "husky": "^0.15.0-rc.13",
     "mocha": "^5.2.0",
     "nock": "^9.3.3",
     "nodemon": "^1.18.10",
-    "prettier": "^1.16.4",
+    "prettier": "^1.18.2",
     "redux-mock-store": "^1.5.3",
+    "rimraf": "^3.0.0",
     "sinon": "^6.0.0",
     "typescript": "^3.5.3"
   }

+ 0 - 27
scripts/migration.ts

@@ -1,27 +0,0 @@
-// 1. Connect to Neo4j database
-// 2. Connect to MongoDB database
-
-// HOSTS
-// 1. [NEO4J] Get all hosts
-// 2. [MONGODB] Create Hosts
-
-// USERS
-// 1. [NEO4J] Get all users
-// 2. [MONGODB] Upsert users
-// 3. [MONGODB] Update bannedBy
-
-// DOMAINS
-// 1. [NEO4J] Get all domains as stream
-// 2. [MONGODB] If domain has user, get user
-// 3. [MONGODB] Upsert domain
-// 4. [MONGODB] Update user to set domain
-
-// LINKS
-// 1. [NEO4J] Get all links as stream
-// 2. [MONGODB] If link has user and domain, get them
-// 3. [MONGODB] Upsert link
-
-// VISISTS
-// 1. [NEO4J] For every link get visists as stream
-// 2. [JAVaSCRIPT] Sum stats for each visist with the same date
-// 3. [MONGODB] Create visits

+ 21 - 21
server/configToEnv.ts

@@ -1,33 +1,33 @@
 /* eslint-disable global-require */
-import fs from 'fs';
-import path from 'path';
+import fs from "fs";
+import path from "path";
 
-const hasServerConfig = fs.existsSync(path.resolve(__dirname, 'config.js'));
+const hasServerConfig = fs.existsSync(path.resolve(__dirname, "config.js"));
 const hasClientConfig = fs.existsSync(
-  path.resolve(__dirname, '../client/config.js')
+  path.resolve(__dirname, "../client/config.js")
 );
 
 if (hasServerConfig && hasClientConfig) {
-  const serverConfig = require('./config.js');
-  const clientConfig = require('../client/config.js');
+  const serverConfig = require("./config.js");
+  const clientConfig = require("../client/config.js");
   let envTemplate = fs.readFileSync(
-    path.resolve(__dirname, '../.template.env'),
-    'utf-8'
+    path.resolve(__dirname, "../.template.env"),
+    "utf-8"
   );
 
   const configs = {
     PORT: serverConfig.PORT || 3000,
-    DEFAULT_DOMAIN: serverConfig.DEFAULT_DOMAIN || 'localhost:3000',
-    DB_URI: serverConfig.DB_URI || 'bolt://localhost',
+    DEFAULT_DOMAIN: serverConfig.DEFAULT_DOMAIN || "localhost:3000",
+    DB_URI: serverConfig.DB_URI || "bolt://localhost",
     DB_USERNAME: serverConfig.DB_USERNAME,
     DB_PASSWORD: serverConfig.DB_PASSWORD,
     REDIS_DISABLED: serverConfig.REDIS_DISABLED || false,
-    REDIS_HOST: serverConfig.REDIS_HOST || '127.0.0.1',
+    REDIS_HOST: serverConfig.REDIS_HOST || "127.0.0.1",
     REDIS_PORT: serverConfig.REDIS_PORT || 6379,
     REDIS_PASSWORD: serverConfig.REDIS_PASSWORD,
     USER_LIMIT_PER_DAY: serverConfig.USER_LIMIT_PER_DAY || 50,
-    JWT_SECRET: serverConfig.JWT_SECRET || 'securekey',
-    ADMIN_EMAILS: serverConfig.ADMIN_EMAILS.join(','),
+    JWT_SECRET: serverConfig.JWT_SECRET || "securekey",
+    ADMIN_EMAILS: serverConfig.ADMIN_EMAILS.join(","),
     RECAPTCHA_SITE_KEY: clientConfig.RECAPTCHA_SITE_KEY,
     RECAPTCHA_SECRET_KEY: serverConfig.RECAPTCHA_SECRET_KEY,
     GOOGLE_SAFE_BROWSING_KEY: serverConfig.GOOGLE_SAFE_BROWSING_KEY,
@@ -40,23 +40,23 @@ if (hasServerConfig && hasClientConfig) {
     MAIL_FROM: serverConfig.MAIL_FROM,
     MAIL_PASSWORD: serverConfig.MAIL_PASSWORD,
     REPORT_MAIL: serverConfig.REPORT_MAIL,
-    CONTACT_EMAIL: clientConfig.CONTACT_EMAIL,
+    CONTACT_EMAIL: clientConfig.CONTACT_EMAIL
   };
 
   Object.keys(configs).forEach(c => {
     envTemplate = envTemplate.replace(
-      new RegExp(`{{${c}}}`, 'gm'),
-      configs[c] || ''
+      new RegExp(`{{${c}}}`, "gm"),
+      configs[c] || ""
     );
   });
 
-  fs.writeFileSync(path.resolve(__dirname, '../.env'), envTemplate);
+  fs.writeFileSync(path.resolve(__dirname, "../.env"), envTemplate);
   fs.renameSync(
-    path.resolve(__dirname, 'config.js'),
-    path.resolve(__dirname, 'old.config.js')
+    path.resolve(__dirname, "config.js"),
+    path.resolve(__dirname, "old.config.js")
   );
   fs.renameSync(
-    path.resolve(__dirname, '../client/config.js'),
-    path.resolve(__dirname, '../client/old.config.js')
+    path.resolve(__dirname, "../client/config.js"),
+    path.resolve(__dirname, "../client/old.config.js")
   );
 }

+ 87 - 86
server/controllers/authController.ts

@@ -1,13 +1,14 @@
-import { RequestHandler } from 'express';
-import fs from 'fs';
-import path from 'path';
-import passport from 'passport';
-import JWT from 'jsonwebtoken';
-import axios from 'axios';
-
-import { isAdmin } from '../utils';
-import transporter from '../mail/mail';
-import { resetMailText, verifyMailText } from '../mail/text';
+import { Handler } from "express";
+import fs from "fs";
+import path from "path";
+import passport from "passport";
+import JWT from "jsonwebtoken";
+import axios from "axios";
+import { addDays } from "date-fns";
+
+import { isAdmin } from "../utils";
+import transporter from "../mail/mail";
+import { resetMailText, verifyMailText } from "../mail/text";
 import {
   createUser,
   changePassword,
@@ -15,45 +16,44 @@ import {
   getUser,
   verifyUser,
   requestPasswordReset,
-  resetPassword,
-} from '../db/user';
-import { IUser } from '../models/user';
+  resetPassword
+} from "../db/user";
 
 /* Read email template */
 const resetEmailTemplatePath = path.join(
   __dirname,
-  '../mail/template-reset.html'
+  "../mail/template-reset.html"
 );
 const verifyEmailTemplatePath = path.join(
   __dirname,
-  '../mail/template-verify.html'
+  "../mail/template-verify.html"
 );
 const resetEmailTemplate = fs
-  .readFileSync(resetEmailTemplatePath, { encoding: 'utf-8' })
+  .readFileSync(resetEmailTemplatePath, { encoding: "utf-8" })
   .replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN);
 const verifyEmailTemplate = fs
-  .readFileSync(verifyEmailTemplatePath, { encoding: 'utf-8' })
+  .readFileSync(verifyEmailTemplatePath, { encoding: "utf-8" })
   .replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN);
 
 /* Function to generate JWT */
-const signToken = (user: IUser) =>
+const signToken = (user: UserJoined) =>
   JWT.sign(
     {
-      iss: 'ApiAuth',
-      sub: () => user.email,
-      domain: (user.domain && user.domain.name) || '',
+      iss: "ApiAuth",
+      sub: user.email,
+      domain: user.domain || "",
       admin: isAdmin(user.email),
-      iat: new Date().getTime(),
-      exp: new Date().setDate(new Date().getDate() + 7),
-    },
+      iat: parseInt((new Date().getTime() / 1000).toFixed(0)),
+      exp: parseInt((addDays(new Date(), 7).getTime() / 1000).toFixed(0))
+    } as Record<string, any>,
     process.env.JWT_SECRET
   );
 
 /* Passport.js authentication controller */
 const authenticate = (
-  type: 'jwt' | 'local' | 'localapikey',
+  type: "jwt" | "local" | "localapikey",
   error: string,
-  isStrict: boolean = true
+  isStrict = true
 ) =>
   function auth(req, res, next) {
     if (req.user) return next();
@@ -63,19 +63,19 @@ const authenticate = (
       if (user && isStrict && !user.verified) {
         return res.status(400).json({
           error:
-            'Your email address is not verified.' +
-            'Click on signup to get the verification link again.',
+            "Your email address is not verified. " +
+            "Click on signup to get the verification link again."
         });
       }
       if (user && user.banned) {
         return res
           .status(400)
-          .json({ error: 'Your are banned from using this website.' });
+          .json({ error: "Your are banned from using this website." });
       }
       if (user) {
         req.user = {
           ...user,
-          admin: isAdmin(user.email),
+          admin: isAdmin(user.email)
         };
         return next();
       }
@@ -84,84 +84,85 @@ const authenticate = (
   };
 
 export const authLocal = authenticate(
-  'local',
-  'Login email and/or password are wrong.'
+  "local",
+  "Login email and/or password are wrong."
 );
-export const authJwt = authenticate('jwt', 'Unauthorized.');
-export const authJwtLoose = authenticate('jwt', 'Unauthorized.', false);
+export const authJwt = authenticate("jwt", "Unauthorized.");
+export const authJwtLoose = authenticate("jwt", "Unauthorized.", false);
 export const authApikey = authenticate(
-  'localapikey',
-  'API key is not correct.',
+  "localapikey",
+  "API key is not correct.",
   false
 );
 
 /* reCaptcha controller */
-export const recaptcha: RequestHandler = async (req, res, next) => {
-  if (process.env.NODE_ENV === 'production' && !req.user) {
+export const recaptcha: Handler = async (req, res, next) => {
+  if (process.env.NODE_ENV === "production" && !req.user) {
     const isReCaptchaValid = await axios({
-      method: 'post',
-      url: 'https://www.google.com/recaptcha/api/siteverify',
+      method: "post",
+      url: "https://www.google.com/recaptcha/api/siteverify",
       headers: {
-        'Content-type': 'application/x-www-form-urlencoded',
+        "Content-type": "application/x-www-form-urlencoded"
       },
       params: {
         secret: process.env.RECAPTCHA_SECRET_KEY,
         response: req.body.reCaptchaToken,
-        remoteip: req.realIP,
-      },
+        remoteip: req.realIP
+      }
     });
     if (!isReCaptchaValid.data.success) {
       return res
         .status(401)
-        .json({ error: 'reCAPTCHA is not valid. Try again.' });
+        .json({ error: "reCAPTCHA is not valid. Try again." });
     }
   }
   return next();
 };
 
-export const authAdmin: RequestHandler = async (req, res, next) => {
+export const authAdmin: Handler = async (req, res, next) => {
   if (!req.user.admin) {
-    return res.status(401).json({ error: 'Unauthorized.' });
+    return res.status(401).json({ error: "Unauthorized." });
   }
   return next();
 };
 
-export const signup: RequestHandler = async (req, res) => {
+export const signup: Handler = async (req, res) => {
   const { email, password } = req.body;
 
   if (password.length > 64) {
-    return res.status(400).json({ error: 'Maximum password length is 64.' });
+    return res.status(400).json({ error: "Maximum password length is 64." });
   }
 
-  if (email.length > 64) {
-    return res.status(400).json({ error: 'Maximum email length is 64.' });
+  if (email.length > 255) {
+    return res.status(400).json({ error: "Maximum email length is 255." });
   }
 
   const user = await getUser(email);
 
-  if (user && user.verified)
-    return res.status(403).json({ error: 'Email is already in use.' });
+  if (user && user.verified) {
+    return res.status(403).json({ error: "Email is already in use." });
+  }
 
-  const newUser = await createUser(email, password);
+  const newUser = await createUser(email, password, user);
 
   const mail = await transporter.sendMail({
     from: process.env.MAIL_FROM || process.env.MAIL_USER,
     to: newUser.email,
-    subject: 'Verify your account',
+    subject: "Verify your account",
     text: verifyMailText.replace(
       /{{verification}}/gim,
-      newUser.verificationToken
+      newUser.verification_token
     ),
     html: verifyEmailTemplate.replace(
       /{{verification}}/gim,
-      newUser.verificationToken
-    ),
+      newUser.verification_token
+    )
   });
 
   if (mail.accepted.length) {
     return res
       .status(201)
-      .json({ email, message: 'Verification email has been sent.' });
+      .json({ email, message: "Verification email has been sent." });
   }
 
   return res
@@ -169,42 +170,42 @@ export const signup: RequestHandler = async (req, res) => {
     .json({ error: "Couldn't send verification email. Try again." });
 };
 
-export const login: RequestHandler = (req, res) => {
+export const login: Handler = (req, res) => {
   const token = signToken(req.user);
   return res.status(200).json({ token });
 };
 
-export const renew: RequestHandler = (req, res) => {
+export const renew: Handler = (req, res) => {
   const token = signToken(req.user);
   return res.status(200).json({ token });
 };
 
-export const verify: RequestHandler = async (req, _res, next) => {
+export const verify: Handler = async (req, _res, next) => {
   const user = await verifyUser(req.params.verificationToken);
   if (user) {
     const token = signToken(user);
-    req.user = { token };
+    req.token = token;
   }
   return next();
 };
 
-export const changeUserPassword: RequestHandler = async (req, res) => {
+export const changeUserPassword: Handler = async (req, res) => {
   if (req.body.password.length < 8) {
     return res
       .status(400)
-      .json({ error: 'Password must be at least 8 chars long.' });
+      .json({ error: "Password must be at least 8 chars long." });
   }
 
   if (req.body.password.length > 64) {
-    return res.status(400).json({ error: 'Maximum password length is 64.' });
+    return res.status(400).json({ error: "Maximum password length is 64." });
   }
 
-  const changedUser = await changePassword(req.user._id, req.body.password);
+  const changedUser = await changePassword(req.user.id, req.body.password);
 
   if (changedUser) {
     return res
       .status(200)
-      .json({ message: 'Your password has been changed successfully.' });
+      .json({ message: "Your password has been changed successfully." });
   }
 
   return res
@@ -212,26 +213,26 @@ export const changeUserPassword: RequestHandler = async (req, res) => {
     .json({ error: "Couldn't change the password. Try again later" });
 };
 
-export const generateUserApiKey: RequestHandler = async (req, res) => {
-  const user = await generateApiKey(req.user._id);
+export const generateUserApiKey = async (req, res) => {
+  const apikey = await generateApiKey(req.user.id);
 
-  if (user.apikey) {
-    return res.status(201).json({ apikey: user.apikey });
+  if (apikey) {
+    return res.status(201).json({ apikey });
   }
 
   return res
     .status(400)
-    .json({ error: 'Sorry, an error occured. Please try again later.' });
+    .json({ error: "Sorry, an error occured. Please try again later." });
 };
 
-export const userSettings: RequestHandler = (req, res) =>
+export const userSettings: Handler = (req, res) =>
   res.status(200).json({
-    apikey: req.user.apikey || '',
-    customDomain: req.user.domain || '',
-    homepage: req.user.homepage || '',
+    apikey: req.user.apikey || "",
+    customDomain: req.user.domain || "",
+    homepage: req.user.homepage || ""
   });
 
-export const requestUserPasswordReset: RequestHandler = async (req, res) => {
+export const requestUserPasswordReset: Handler = async (req, res) => {
   const user = await requestPasswordReset(req.body.email);
 
   if (!user) {
@@ -241,30 +242,30 @@ export const requestUserPasswordReset: RequestHandler = async (req, res) => {
   const mail = await transporter.sendMail({
     from: process.env.MAIL_USER,
     to: user.email,
-    subject: 'Reset your password',
+    subject: "Reset your password",
     text: resetMailText
-      .replace(/{{resetpassword}}/gm, user.resetPasswordToken)
+      .replace(/{{resetpassword}}/gm, user.reset_password_token)
       .replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN),
     html: resetEmailTemplate
-      .replace(/{{resetpassword}}/gm, user.resetPasswordToken)
-      .replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN),
+      .replace(/{{resetpassword}}/gm, user.reset_password_token)
+      .replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN)
   });
 
   if (mail.accepted.length) {
     return res.status(200).json({
       email: user.email,
-      message: 'Reset password email has been sent.',
+      message: "Reset password email has been sent."
     });
   }
 
   return res.status(400).json({ error: "Couldn't reset password." });
 };
 
-export const resetUserPassword: RequestHandler = async (req, _res, next) => {
-  const user = await resetPassword(req.params.resetPasswordToken);
+export const resetUserPassword: Handler = async (req, _res, next) => {
+  const user: UserJoined = await resetPassword(req.params.resetPasswordToken);
   if (user) {
-    const token = signToken(user);
-    req.user = { token };
+    const token = signToken(user as UserJoined);
+    req.token = token;
   }
   return next();
 };

+ 160 - 164
server/controllers/linkController.ts

@@ -1,123 +1,124 @@
-import { Handler } from 'express';
-import { promisify } from 'util';
-import urlRegex from 'url-regex';
-import dns from 'dns';
-import URL from 'url';
-import generate from 'nanoid/generate';
-import useragent from 'useragent';
-import geoip from 'geoip-lite';
-import bcrypt from 'bcryptjs';
-import ua from 'universal-analytics';
-import isbot from 'isbot';
-
-import { addIP } from '../db/ip';
+import bcrypt from "bcryptjs";
+import dns from "dns";
+import { Handler } from "express";
+import geoip from "geoip-lite";
+import isbot from "isbot";
+import generate from "nanoid/generate";
+import ua from "universal-analytics";
+import URL from "url";
+import urlRegex from "url-regex";
+import useragent from "useragent";
+import { promisify } from "util";
+import { deleteDomain, getDomain, setDomain } from "../db/domain";
+import { addIP } from "../db/ip";
 import {
   addLinkCount,
+  banLink,
   createShortLink,
   createVisit,
   deleteLink,
   findLink,
-  getUserLinksCount,
-  getStats,
   getLinks,
-  banLink,
-} from '../db/link';
+  getStats,
+  getUserLinksCount
+} from "../db/link";
+import transporter from "../mail/mail";
+import * as redis from "../redis";
+import {
+  addProtocol,
+  generateShortLink,
+  getStatsCacheTime,
+  getStatsLimit
+} from "../utils";
 import {
   checkBannedDomain,
   checkBannedHost,
   cooldownCheck,
   malwareCheck,
   preservedUrls,
-  urlCountsCheck,
-} from './validateBodyController';
-import { getDomain, setDomain, deleteDomain } from '../db/domain';
-import transporter from '../mail/mail';
-import * as redis from '../redis';
-import {
-  addProtocol,
-  getStatsLimit,
-  generateShortLink,
-  getStatsCacheTime,
-} from '../utils';
-import { IDomain } from '../models/domain';
+  urlCountsCheck
+} from "./validateBodyController";
 
 const dnsLookup = promisify(dns.lookup);
 
 const generateId = async () => {
-  const id = generate(
-    'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890',
+  const address = generate(
+    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890",
     6
   );
-  const link = await findLink({ id });
-  if (!link) return id;
+  const link = await findLink({ address });
+  if (!link) return address;
   return generateId();
 };
 
 export const shortener: Handler = async (req, res) => {
   try {
-    const targetDomain = URL.parse(req.body.target).hostname;
+    const target = addProtocol(req.body.target);
+    const targetDomain = URL.parse(target).hostname;
 
     const queries = await Promise.all([
       process.env.GOOGLE_SAFE_BROWSING_KEY && cooldownCheck(req.user),
       process.env.GOOGLE_SAFE_BROWSING_KEY &&
         malwareCheck(req.user, req.body.target),
-      req.user && urlCountsCheck(req.user._id),
+      req.user && urlCountsCheck(req.user),
       req.user &&
         req.body.reuse &&
-        findLink({ target: addProtocol(req.body.target), user: req.user._id }),
+        findLink({
+          target,
+          user_id: req.user.id
+        }),
       req.user &&
         req.body.customurl &&
-        findLink(
-          {
-            id: req.body.customurl,
-            domain: req.user.domain && req.user.domain._id,
-          },
-          { forceDomainCheck: true }
-        ),
+        findLink({
+          address: req.body.customurl,
+          domain_id: req.user.domain_id || null
+        }),
       (!req.user || !req.body.customurl) && generateId(),
       checkBannedDomain(targetDomain),
-      checkBannedHost(targetDomain),
+      checkBannedHost(targetDomain)
     ]);
 
     // if "reuse" is true, try to return
     // the existent URL without creating one
     if (queries[3]) {
-      const { domain: d, user: u, ...link } = queries[3];
+      const { domain_id: d, user_id: u, ...link } = queries[3];
       const data = {
         ...link,
+        id: link.address,
         password: !!link.password,
         reuse: true,
-        shortLink: generateShortLink(link.id, req.user.domain),
+        shortLink: generateShortLink(link.address, req.user.domain)
       };
       return res.json(data);
     }
 
     // Check if custom link already exists
     if (queries[4]) {
-      throw new Error('Custom URL is already in use.');
+      throw new Error("Custom URL is already in use.");
     }
 
     // Create new link
-    const id = (req.user && req.body.customurl) || queries[5];
-    const target = addProtocol(req.body.target);
-    const link = await createShortLink({
-      ...req.body,
-      id,
-      target,
-      user: req.user,
-    });
+    const address = (req.user && req.body.customurl) || queries[5];
+    const link = await createShortLink(
+      {
+        ...req.body,
+        address,
+        target
+      },
+      req.user
+    );
     if (!req.user && Number(process.env.NON_USER_COOLDOWN)) {
       addIP(req.realIP);
     }
 
-    return res.json(link);
+    return res.json({ ...link, id: link.address });
   } catch (error) {
     return res.status(400).json({ error: error.message });
   }
 };
 
-const browsersList = ['IE', 'Firefox', 'Chrome', 'Opera', 'Safari', 'Edge'];
-const osList = ['Windows', 'Mac Os', 'Linux', 'Android', 'iOS'];
+const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"];
+const osList = ["Windows", "Mac OS", "Linux", "Android", "iOS"];
 const filterInBrowser = agent => item =>
   agent.family.toLowerCase().includes(item.toLocaleLowerCase());
 const filterInOs = agent => item =>
@@ -126,20 +127,21 @@ const filterInOs = agent => item =>
 export const goToLink: Handler = async (req, res, next) => {
   const { host } = req.headers;
   const reqestedId = req.params.id || req.body.id;
-  const id = reqestedId.replace('+', '');
+  const address = reqestedId.replace("+", "");
   const customDomain = host !== process.env.DEFAULT_DOMAIN && host;
-  const agent = useragent.parse(req.headers['user-agent']);
-  const [browser = 'Other'] = browsersList.filter(filterInBrowser(agent));
-  const [os = 'Other'] = osList.filter(filterInOs(agent));
+  // TODO: Extract parsing into their own function
+  const agent = useragent.parse(req.headers["user-agent"]);
+  const [browser = "Other"] = browsersList.filter(filterInBrowser(agent));
+  const [os = "Other"] = osList.filter(filterInOs(agent));
   const referrer =
-    req.header('Referer') && URL.parse(req.header('Referer')).hostname;
+    req.header("Referer") && URL.parse(req.header("Referer")).hostname;
   const location = geoip.lookup(req.realIP);
   const country = location && location.country;
-  const isBot = isbot(req.headers['user-agent']);
+  const isBot = isbot(req.headers["user-agent"]);
 
-  const domain = await (customDomain && getDomain({ name: customDomain }));
+  const domain = await (customDomain && getDomain({ address: customDomain }));
 
-  const link = await findLink({ id, domain: domain && domain._id });
+  const link = await findLink({ address, domain_id: domain && domain.id });
 
   if (!link) {
     if (host !== process.env.DEFAULT_DOMAIN) {
@@ -150,51 +152,51 @@ export const goToLink: Handler = async (req, res, next) => {
   }
 
   if (link.banned) {
-    return res.redirect('/banned');
+    return res.redirect("/banned");
   }
 
   const doesRequestInfo = /.*\+$/gi.test(reqestedId);
   if (doesRequestInfo && !link.password) {
     req.linkTarget = link.target;
-    req.pageType = 'info';
+    req.pageType = "info";
     return next();
   }
 
   if (link.password && !req.body.password) {
-    req.protectedLink = id;
-    req.pageType = 'password';
+    req.protectedLink = address;
+    req.pageType = "password";
     return next();
   }
 
   if (link.password) {
     const isMatch = await bcrypt.compare(req.body.password, link.password);
     if (!isMatch) {
-      return res.status(401).json({ error: 'Password is not correct' });
+      return res.status(401).json({ error: "Password is not correct" });
     }
-    if (link.user && !isBot) {
-      addLinkCount(link.id, customDomain);
+    if (link.user_id && !isBot) {
+      addLinkCount(link.id);
       createVisit({
         browser: browser.toLowerCase(),
-        country: country || 'Unknown',
+        country: country || "Unknown",
         domain: customDomain,
         id: link.id,
-        os: os.toLowerCase().replace(/\s/gi, ''),
-        referrer: referrer.replace(/\./gi, '[dot]') || 'Direct',
-        limit: getStatsLimit(),
+        os: os.toLowerCase().replace(/\s/gi, ""),
+        referrer: referrer.replace(/\./gi, "[dot]") || "Direct",
+        limit: getStatsLimit()
       });
     }
     return res.status(200).json({ target: link.target });
   }
-  if (link.user && !isBot) {
-    addLinkCount(link.id, customDomain);
+  if (link.user_id && !isBot) {
+    addLinkCount(link.id);
     createVisit({
-      browser,
-      country: country || 'Unknown',
+      browser: browser.toLowerCase(),
+      country: (country && country.toLocaleLowerCase()) || "unknown",
       domain: customDomain,
       id: link.id,
-      os,
-      referrer: referrer || 'Direct',
-      limit: getStatsLimit(),
+      os: os.toLowerCase().replace(/\s/gi, ""),
+      referrer: (referrer && referrer.replace(/\./gi, "[dot]")) || "direct",
+      limit: getStatsLimit()
     });
   }
 
@@ -202,10 +204,10 @@ export const goToLink: Handler = async (req, res, next) => {
     const visitor = ua(process.env.GOOGLE_ANALYTICS_UNIVERSAL);
     visitor
       .pageview({
-        dp: `/${id}`,
-        ua: req.headers['user-agent'],
+        dp: `/${address}`,
+        ua: req.headers["user-agent"],
         uip: req.realIP,
-        aip: 1,
+        aip: 1
       })
       .send();
   }
@@ -216,8 +218,8 @@ export const goToLink: Handler = async (req, res, next) => {
 export const getUserLinks: Handler = async (req, res) => {
   // TODO: Use aggregation
   const [countAll, list] = await Promise.all([
-    getUserLinksCount({ user: req.user }),
-    getLinks(req.user, req.query),
+    getUserLinksCount({ user_id: req.user.id }),
+    getLinks(req.user.id, req.query)
   ]);
   return res.json({ list, countAll });
 };
@@ -226,11 +228,11 @@ export const setCustomDomain: Handler = async (req, res) => {
   const parsed = URL.parse(req.body.customDomain);
   const customDomain = parsed.hostname || parsed.href;
   if (!customDomain)
-    return res.status(400).json({ error: 'Domain is not valid.' });
+    return res.status(400).json({ error: "Domain is not valid." });
   if (customDomain.length > 40) {
     return res
       .status(400)
-      .json({ error: 'Maximum custom domain length is 40.' });
+      .json({ error: "Maximum custom domain length is 40." });
   }
   if (customDomain === process.env.DEFAULT_DOMAIN) {
     return res.status(400).json({ error: "You can't use default domain." });
@@ -239,30 +241,34 @@ export const setCustomDomain: Handler = async (req, res) => {
     !req.body.homepage ||
     urlRegex({ exact: true, strict: false }).test(req.body.homepage);
   if (!isValidHomepage)
-    return res.status(400).json({ error: 'Homepage is not valid.' });
+    return res.status(400).json({ error: "Homepage is not valid." });
   const homepage =
     req.body.homepage &&
     (URL.parse(req.body.homepage).protocol
       ? req.body.homepage
       : `http://${req.body.homepage}`);
-  const matchedDomain = await getDomain({ name: customDomain });
+  const matchedDomain = await getDomain({ address: customDomain });
   if (
     matchedDomain &&
-    matchedDomain.user.toString() !== req.user._id.toString()
+    matchedDomain.user_id &&
+    matchedDomain.user_id !== req.user.id
   ) {
     return res.status(400).json({
-      error: 'Domain is already taken. Contact us for multiple users.',
+      error: "Domain is already taken. Contact us for multiple users."
     });
   }
-  const userCustomDomain = await setDomain({
-    user: req.user,
-    name: customDomain,
-    homepage,
-  });
+  const userCustomDomain = await setDomain(
+    {
+      address: customDomain,
+      homepage
+    },
+    req.user,
+    matchedDomain
+  );
   if (userCustomDomain) {
     return res.status(201).json({
-      customDomain: userCustomDomain.name,
-      homepage: userCustomDomain.homepage,
+      customDomain: userCustomDomain.address,
+      homepage: userCustomDomain.homepage
     });
   }
   return res.status(400).json({ error: "Couldn't set custom domain." });
@@ -271,7 +277,7 @@ export const setCustomDomain: Handler = async (req, res) => {
 export const deleteCustomDomain: Handler = async (req, res) => {
   const response = await deleteDomain(req.user);
   if (response)
-    return res.status(200).json({ message: 'Domain deleted successfully' });
+    return res.status(200).json({ message: "Domain deleted successfully" });
   return res.status(400).json({ error: "Couldn't delete custom domain." });
 };
 
@@ -279,12 +285,12 @@ export const customDomainRedirection: Handler = async (req, res, next) => {
   const { headers, path } = req;
   if (
     headers.host !== process.env.DEFAULT_DOMAIN &&
-    (path === '/' ||
+    (path === "/" ||
       preservedUrls
-        .filter(l => l !== 'url-password')
-        .some(item => item === path.replace('/', '')))
+        .filter(l => l !== "url-password")
+        .some(item => item === path.replace("/", "")))
   ) {
-    const domain = await getDomain({ name: headers.host });
+    const domain = await getDomain({ address: headers.host });
     return res.redirect(
       301,
       (domain && domain.homepage) ||
@@ -295,92 +301,82 @@ export const customDomainRedirection: Handler = async (req, res, next) => {
 };
 
 export const deleteUserLink: Handler = async (req, res) => {
-  if (!req.body.id)
-    return res.status(400).json({ error: 'No id has been provided.' });
-  const customDomain =
-    req.body.domain !== process.env.DEFAULT_DOMAIN
-      ? await getDomain({ name: req.body.domain })
-      : ({} as IDomain);
-  const link = await findLink(
-    {
-      id: req.body.id,
-      domain: customDomain._id,
-      user: req.user && req.user._id,
-    },
-    { forceDomainCheck: true }
-  );
-  if (!link) {
-    return res.status(400).json({ error: "Couldn't find the short link." });
+  const { id, domain } = req.body;
+
+  if (!id) {
+    return res.status(400).json({ error: "No id has been provided." });
   }
+
   const response = await deleteLink({
-    id: req.body,
-    domain: customDomain._id,
-    user: req.user,
+    address: id,
+    domain: domain !== process.env.DEFAULT_DOMAIN && domain,
+    user_id: req.user.id
   });
 
   if (response) {
-    return res.status(200).json({ message: 'Short link deleted successfully' });
+    return res.status(200).json({ message: "Short link deleted successfully" });
   }
 
   return res.status(400).json({ error: "Couldn't delete the short link." });
 };
 
 export const getLinkStats: Handler = async (req, res) => {
-  if (!req.query.id)
-    return res.status(400).json({ error: 'No id has been provided.' });
-  const customDomain =
-    req.query.domain !== process.env.DEFAULT_DOMAIN
-      ? await getDomain({ name: req.query.domain })
-      : ({} as IDomain);
-  const redisKey = req.query.id + (customDomain.name || '') + req.user.email;
+  if (!req.query.id) {
+    return res.status(400).json({ error: "No id has been provided." });
+  }
+
+  const { hostname } = URL.parse(req.query.domain);
+  const hasCustomDomain =
+    req.query.domain && hostname !== process.env.DEFAULT_DOMAIN;
+  const customDomain = hasCustomDomain
+    ? (await getDomain({ address: req.query.domain })) || ({ id: -1 } as Domain)
+    : ({} as Domain);
+
+  const redisKey = req.query.id + (customDomain.address || "") + req.user.email;
   const cached = await redis.get(redisKey);
   if (cached) return res.status(200).json(JSON.parse(cached));
-  const link = await findLink(
-    {
-      id: req.query.id,
-      domain: customDomain._id,
-      user: req.user && req.user._id,
-    },
-    { forceDomainCheck: true }
-  );
-  if (!link)
-    return res.status(400).json({ error: "Couldn't find the short link." });
 
-  const stats = await getStats({
-    id: req.query.id,
-    domain: customDomain._id,
-    user: req.user,
+  const link = await findLink({
+    address: req.query.id,
+    domain_id: hasCustomDomain ? customDomain.id : null,
+    user_id: req.user && req.user.id
   });
 
+  if (!link) {
+    return res.status(400).json({ error: "Couldn't find the short link." });
+  }
+
+  const stats = await getStats(link, customDomain);
+
   if (!stats) {
     return res
       .status(400)
-      .json({ error: 'Could not get the short link stats.' });
+      .json({ error: "Could not get the short link stats." });
   }
 
-  const cacheTime = getStatsCacheTime(stats.total);
-  redis.set(redisKey, JSON.stringify(stats), 'EX', cacheTime);
+  const cacheTime = getStatsCacheTime(0);
+  redis.set(redisKey, JSON.stringify(stats), "EX", cacheTime);
   return res.status(200).json(stats);
 };
 
 export const reportLink: Handler = async (req, res) => {
-  // TODO: Change from url to link in front-end
-  if (!req.body.link)
-    return res.status(400).json({ error: 'No URL has been provided.' });
+  if (!req.body.link) {
+    return res.status(400).json({ error: "No URL has been provided." });
+  }
 
   const { hostname } = URL.parse(req.body.link);
   if (hostname !== process.env.DEFAULT_DOMAIN) {
     return res.status(400).json({
-      error: `You can only report a ${process.env.DEFAULT_DOMAIN} link`,
+      error: `You can only report a ${process.env.DEFAULT_DOMAIN} link`
     });
   }
 
   const mail = await transporter.sendMail({
     from: process.env.MAIL_USER,
     to: process.env.REPORT_MAIL,
-    subject: '[REPORT]',
+    subject: "[REPORT]",
     text: req.body.url,
-    html: req.body.url,
+    html: req.body.url
   });
   if (mail.accepted.length) {
     return res
@@ -394,14 +390,14 @@ export const reportLink: Handler = async (req, res) => {
 
 export const ban: Handler = async (req, res) => {
   if (!req.body.id)
-    return res.status(400).json({ error: 'No id has been provided.' });
+    return res.status(400).json({ error: "No id has been provided." });
 
-  const link = await findLink({ id: req.body.id }, { forceDomainCheck: true });
+  const link = await findLink({ address: req.body.id, domain_id: null });
 
   if (!link) return res.status(400).json({ error: "Couldn't find the link." });
 
   if (link.banned) {
-    return res.status(200).json({ message: 'Link was banned already' });
+    return res.status(200).json({ message: "Link was banned already." });
   }
 
   const domain = URL.parse(link.target).hostname;
@@ -417,12 +413,12 @@ export const ban: Handler = async (req, res) => {
   }
 
   await banLink({
-    adminId: req.user,
+    adminId: req.user.id,
     domain,
     host,
-    id: req.body.id,
-    banUser: !!req.body.user,
+    address: req.body.id,
+    banUser: !!req.body.user
   });
 
-  return res.status(200).json({ message: 'Link has been banned successfully' });
+  return res.status(200).json({ message: "Link has been banned successfully" });
 };

+ 80 - 85
server/controllers/validateBodyController.ts

@@ -1,37 +1,36 @@
-import { RequestHandler } from 'express';
-import { promisify } from 'util';
-import dns from 'dns';
-import axios from 'axios';
-import URL from 'url';
-import urlRegex from 'url-regex';
-import validator from 'express-validator/check';
-import { differenceInMinutes, subHours, subDays } from 'date-fns';
-import { validationResult } from 'express-validator/check';
-
-import { IUser } from '../models/user';
-import { addCooldown, banUser } from '../db/user';
-import { getIP } from '../db/ip';
-import { getUserLinksCount } from '../db/link';
-import { getDomain } from '../db/domain';
-import { getHost } from '../db/host';
-import { addProtocol } from '../utils';
+import { RequestHandler } from "express";
+import { promisify } from "util";
+import dns from "dns";
+import axios from "axios";
+import URL from "url";
+import urlRegex from "url-regex";
+import validator from "express-validator/check";
+import { differenceInMinutes, subHours, subDays, isAfter } from "date-fns";
+import { validationResult } from "express-validator/check";
+
+import { addCooldown, banUser } from "../db/user";
+import { getIP } from "../db/ip";
+import { getUserLinksCount } from "../db/link";
+import { getDomain } from "../db/domain";
+import { getHost } from "../db/host";
+import { addProtocol } from "../utils";
 
 const dnsLookup = promisify(dns.lookup);
 
 export const validationCriterias = [
   validator
-    .body('email')
+    .body("email")
     .exists()
-    .withMessage('Email must be provided.')
+    .withMessage("Email must be provided.")
     .isEmail()
-    .withMessage('Email is not valid.')
+    .withMessage("Email is not valid.")
     .trim()
     .normalizeEmail(),
   validator
-    .body('password', 'Password must be at least 8 chars long.')
+    .body("password", "Password must be at least 8 chars long.")
     .exists()
-    .withMessage('Password must be provided.')
-    .isLength({ min: 8 }),
+    .withMessage("Password must be provided.")
+    .isLength({ min: 8 })
 ];
 
 export const validateBody = (req, res, next) => {
@@ -46,35 +45,35 @@ export const validateBody = (req, res, next) => {
 };
 
 export const preservedUrls = [
-  'login',
-  'logout',
-  'signup',
-  'reset-password',
-  'resetpassword',
-  'url-password',
-  'url-info',
-  'settings',
-  'stats',
-  'verify',
-  'api',
-  '404',
-  'static',
-  'images',
-  'banned',
-  'terms',
-  'privacy',
-  'report',
-  'pricing',
+  "login",
+  "logout",
+  "signup",
+  "reset-password",
+  "resetpassword",
+  "url-password",
+  "url-info",
+  "settings",
+  "stats",
+  "verify",
+  "api",
+  "404",
+  "static",
+  "images",
+  "banned",
+  "terms",
+  "privacy",
+  "report",
+  "pricing"
 ];
 
 export const validateUrl: RequestHandler = async (req, res, next) => {
   // Validate URL existence
   if (!req.body.target)
-    return res.status(400).json({ error: 'No target has been provided.' });
+    return res.status(400).json({ error: "No target has been provided." });
 
   // validate URL length
   if (req.body.target.length > 3000) {
-    return res.status(400).json({ error: 'Maximum URL length is 3000.' });
+    return res.status(400).json({ error: "Maximum URL length is 3000." });
   }
 
   // Validate URL
@@ -82,7 +81,7 @@ export const validateUrl: RequestHandler = async (req, res, next) => {
     req.body.target
   );
   if (!isValidUrl && !/^\w+:\/\//.test(req.body.target))
-    return res.status(400).json({ error: 'URL is not valid.' });
+    return res.status(400).json({ error: "URL is not valid." });
 
   // If target is the URL shortener itself
   const { host } = URL.parse(addProtocol(req.body.target));
@@ -94,14 +93,14 @@ export const validateUrl: RequestHandler = async (req, res, next) => {
 
   // Validate password length
   if (req.body.password && req.body.password.length > 64) {
-    return res.status(400).json({ error: 'Maximum password length is 64.' });
+    return res.status(400).json({ error: "Maximum password length is 64." });
   }
 
   // Custom URL validations
   if (req.user && req.body.customurl) {
     // Validate custom URL
     if (!/^[a-zA-Z0-9-_]+$/g.test(req.body.customurl.trim())) {
-      return res.status(400).json({ error: 'Custom URL is not valid.' });
+      return res.status(400).json({ error: "Custom URL is not valid." });
     }
 
     // Prevent from using preserved URLs
@@ -115,24 +114,24 @@ export const validateUrl: RequestHandler = async (req, res, next) => {
     if (req.body.customurl.length > 64) {
       return res
         .status(400)
-        .json({ error: 'Maximum custom URL length is 64.' });
+        .json({ error: "Maximum custom URL length is 64." });
     }
   }
 
   return next();
 };
 
-export const cooldownCheck = async (user: IUser) => {
+export const cooldownCheck = async (user: User) => {
   if (user && user.cooldowns) {
     if (user.cooldowns.length > 4) {
-      await banUser(user._id);
-      throw new Error('Too much malware requests. You are now banned.');
+      await banUser(user.id);
+      throw new Error("Too much malware requests. You are now banned.");
     }
-    const hasCooldownNow = user.cooldowns.some(
-      cooldown => cooldown.toJSON() > subHours(new Date(), 12).toJSON()
+    const hasCooldownNow = user.cooldowns.some(cooldown =>
+      isAfter(subHours(new Date(), 12), cooldown)
     );
     if (hasCooldownNow) {
-      throw new Error('Cooldown because of a malware URL. Wait 12h');
+      throw new Error("Cooldown because of a malware URL. Wait 12h");
     }
   }
 };
@@ -143,72 +142,68 @@ export const ipCooldownCheck: RequestHandler = async (req, res, next) => {
   const ip = await getIP(req.realIP);
   if (ip) {
     const timeToWait =
-      cooldownConfig - differenceInMinutes(new Date(), ip.createdAt);
+      cooldownConfig - differenceInMinutes(new Date(), ip.created_at);
     return res.status(400).json({
       error:
         `Non-logged in users are limited. Wait ${timeToWait} ` +
-        'minutes or log in.',
+        "minutes or log in."
     });
   }
   next();
 };
 
-export const malwareCheck = async (user: IUser, target: string) => {
+export const malwareCheck = async (user: User, target: string) => {
   const isMalware = await axios.post(
-    `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${
-      process.env.GOOGLE_SAFE_BROWSING_KEY
-    }`,
+    `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${process.env.GOOGLE_SAFE_BROWSING_KEY}`,
     {
       client: {
-        clientId: process.env.DEFAULT_DOMAIN.toLowerCase().replace('.', ''),
-        clientVersion: '1.0.0',
+        clientId: process.env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
+        clientVersion: "1.0.0"
       },
       threatInfo: {
         threatTypes: [
-          'THREAT_TYPE_UNSPECIFIED',
-          'MALWARE',
-          'SOCIAL_ENGINEERING',
-          'UNWANTED_SOFTWARE',
-          'POTENTIALLY_HARMFUL_APPLICATION',
+          "THREAT_TYPE_UNSPECIFIED",
+          "MALWARE",
+          "SOCIAL_ENGINEERING",
+          "UNWANTED_SOFTWARE",
+          "POTENTIALLY_HARMFUL_APPLICATION"
         ],
-        platformTypes: ['ANY_PLATFORM', 'PLATFORM_TYPE_UNSPECIFIED'],
+        platformTypes: ["ANY_PLATFORM", "PLATFORM_TYPE_UNSPECIFIED"],
         threatEntryTypes: [
-          'EXECUTABLE',
-          'URL',
-          'THREAT_ENTRY_TYPE_UNSPECIFIED',
+          "EXECUTABLE",
+          "URL",
+          "THREAT_ENTRY_TYPE_UNSPECIFIED"
         ],
-        threatEntries: [{ url: target }],
-      },
+        threatEntries: [{ url: target }]
+      }
     }
   );
   if (isMalware.data && isMalware.data.matches) {
     if (user) {
-      await addCooldown(user._id);
+      await addCooldown(user.id);
     }
     throw new Error(
-      user ? 'Malware detected! Cooldown for 12h.' : 'Malware detected!'
+      user ? "Malware detected! Cooldown for 12h." : "Malware detected!"
     );
   }
 };
 
-export const urlCountsCheck = async (user: IUser) => {
+export const urlCountsCheck = async (user: User) => {
   const count = await getUserLinksCount({
-    user: user._id,
-    date: subDays(new Date(), 1),
+    user_id: user.id,
+    date: subDays(new Date(), 1)
   });
   if (count > Number(process.env.USER_LIMIT_PER_DAY)) {
     throw new Error(
-      `You have reached your daily limit (${
-        process.env.USER_LIMIT_PER_DAY
-      }). Please wait 24h.`
+      `You have reached your daily limit (${process.env.USER_LIMIT_PER_DAY}). Please wait 24h.`
     );
   }
 };
 
 export const checkBannedDomain = async (domain: string) => {
-  const bannedDomain = await getDomain({ name: domain, banned: true });
+  const bannedDomain = await getDomain({ address: domain, banned: true });
   if (bannedDomain) {
-    throw new Error('URL is containing malware/scam.');
+    throw new Error("URL is containing malware/scam.");
   }
 };
 
@@ -218,12 +213,12 @@ export const checkBannedHost = async (domain: string) => {
     const dnsRes = await dnsLookup(domain);
     isHostBanned = await getHost({
       address: dnsRes && dnsRes.address,
-      banned: true,
+      banned: true
     });
   } catch (error) {
     isHostBanned = null;
   }
   if (isHostBanned) {
-    throw new Error('URL is containing malware/scam.');
+    throw new Error("URL is containing malware/scam.");
   }
 };

+ 2 - 2
server/cron.ts

@@ -1,9 +1,9 @@
-import cron from 'node-cron';
+import cron from "node-cron";
 
 import { clearIPs } from "./db/ip";
 
 if (Number(process.env.NON_USER_COOLDOWN)) {
-  cron.schedule('* */24 * * *', () => {
+  cron.schedule("* */24 * * *", () => {
     clearIPs().catch();
   });
 }

+ 87 - 31
server/db/domain.ts

@@ -1,57 +1,113 @@
-import { Types } from 'mongoose';
+import knex from "../knex";
+import * as redis from "../redis";
+import { getRedisKey } from "../utils";
 
-import Domain, { IDomain } from '../models/domain';
-import User from '../models/user';
-import * as redis from '../redis';
+export const getDomain = async (data: Partial<Domain>): Promise<Domain> => {
+  const getData = {
+    ...data,
+    ...(data.address && { address: data.address.toLowerCase() }),
+    ...(data.homepage && { homepage: data.homepage.toLowerCase() })
+  };
 
-export const getDomain = async (data: Partial<IDomain>) => {
-  const redisKey = `${data.name}-${data.banned ? 'y' : 'n'}`;
+  const redisKey = getRedisKey.domain(getData.address);
   const cachedDomain = await redis.get(redisKey);
 
   if (cachedDomain) return JSON.parse(cachedDomain);
 
-  const domain = await Domain.findOne(data);
+  const domain = await knex<Domain>("domains")
+    .where(getData)
+    .first();
 
-  redis.set(redisKey, JSON.stringify(domain), 'EX', 60 * 60 * 6);
+  if (domain) {
+    redis.set(redisKey, JSON.stringify(domain), "EX", 60 * 60 * 6);
+  }
 
   return domain;
 };
 
-export const setDomain = async (data: Partial<IDomain>) => {
-  const [domain] = await Promise.all([
-    Domain.create({
-      name: data.name,
-      homepage: data.homepage,
-      user: data.user,
-    }),
-    Domain.findOneAndUpdate({ user: data.user }, { user: undefined }),
-  ]);
-  await User.findByIdAndUpdate(data.user, { domain });
+export const setDomain = async (
+  data: Partial<Domain>,
+  user: UserJoined,
+  matchedDomain: Domain
+) => {
+  // 1. If user has domain, remove it from their possession
+  await knex<Domain>("domains")
+    .where({ user_id: user.id })
+    .update({ user_id: null });
+
+  // 2. Create or update the domain with user's ID
+  let domain;
+
+  const updateDate: Partial<Domain> = {
+    address: data.address.toLowerCase(),
+    homepage: data.homepage && data.homepage.toLowerCase(),
+    user_id: user.id,
+    updated_at: new Date().toISOString()
+  };
+
+  if (matchedDomain) {
+    const [response]: Domain[] = await knex<Domain>("domains")
+      .where("id", matchedDomain.id)
+      .update(updateDate, "*");
+    domain = response;
+  } else {
+    const [response]: Domain[] = await knex<Domain>("domains").insert(
+      updateDate,
+      "*"
+    );
+    domain = response;
+  }
+
+  redis.del(user.email);
+  redis.del(user.email);
+  redis.del(getRedisKey.domain(updateDate.address));
+
   return domain;
 };
 
-export const deleteDomain = async (user: Types.ObjectId) => {
-  const [domain] = await Promise.all([
-    Domain.findOneAndUpdate({ user }, { user: undefined }),
-    User.findByIdAndUpdate(user, { domain: undefined }),
-  ]);
+export const deleteDomain = async (user: UserJoined) => {
+  // Remove user from domain, do not actually delete the domain
+  const [domain]: Domain[] = await knex<Domain>("domains")
+    .where({ user_id: user.id })
+    .update({ user_id: null, updated_at: new Date().toISOString() }, "*");
 
   if (domain) {
-    redis.del(`${domain.name}-${domain.banned ? 'y' : 'n'}`);
+    redis.del(getRedisKey.domain(domain.address));
   }
 
+  redis.del(user.email);
+  redis.del(user.apikey);
+
   return domain;
 };
 
-export const banDomain = async (name: string, bannedBy?: Types.ObjectId) => {
-  const domain = await Domain.findOneAndUpdate(
-    { name },
-    { banned: true, bannedBy },
-    { upsert: true }
-  );
+export const banDomain = async (
+  addressToban: string,
+  banned_by_id?: number
+): Promise<Domain> => {
+  const address = addressToban.toLowerCase();
+
+  const currentDomain = await getDomain({ address });
+
+  let domain;
+  if (currentDomain) {
+    const updates: Domain[] = await knex<Domain>("domains")
+      .where({ address })
+      .update(
+        { banned: true, banned_by_id, updated_at: new Date().toISOString() },
+        "*"
+      );
+    domain = updates[0];
+  } else {
+    const inserts: Domain[] = await knex<Domain>("domains").insert(
+      { address, banned: true, banned_by_id },
+      "*"
+    );
+    domain = inserts[0];
+  }
 
   if (domain) {
-    redis.del(`${domain.name}-${domain.banned ? 'y' : 'n'}`);
+    redis.del(getRedisKey.domain(domain.address));
   }
 
   return domain;

+ 36 - 16
server/db/host.ts

@@ -1,31 +1,51 @@
-import { Types } from 'mongoose';
+import knex from "../knex";
+import * as redis from "../redis";
+import { getRedisKey } from "../utils";
 
-import Host, { IHost } from '../models/host';
-import * as redis from '../redis';
+export const getHost = async (data: Partial<Host>) => {
+  const getData = {
+    ...data,
+    ...(data.address && { address: data.address.toLowerCase() })
+  };
 
-export const getHost = async (data: Partial<IHost>) => {
-  const redisKey = `${data.address}-${data.banned ? 'y' : 'n'}`;
+  const redisKey = getRedisKey.host(getData.address);
   const cachedHost = await redis.get(redisKey);
 
   if (cachedHost) return JSON.parse(cachedHost);
 
-  const host = await Host.findOne(data);
+  const host = await knex<Host>("hosts")
+    .where(getData)
+    .first();
 
-  redis.set(redisKey, JSON.stringify(host), 'EX', 60 * 60 * 6);
+  if (host) {
+    redis.set(redisKey, JSON.stringify(host), "EX", 60 * 60 * 6);
+  }
 
   return host;
 };
 
-export const banHost = async (address: string, bannedBy?: Types.ObjectId) => {
-  const host = await Host.findOneAndUpdate(
-    { address },
-    { banned: true, bannedBy },
-    { upsert: true }
-  );
+export const banHost = async (addressToBan: string, banned_by_id?: number) => {
+  const address = addressToBan.toLowerCase();
+
+  const currentHost = await knex<Host>("hosts")
+    .where({ address })
+    .first();
+
+  if (currentHost) {
+    await knex<Host>("hosts")
+      .where({ address })
+      .update({
+        banned: true,
+        banned_by_id,
+        updated_at: new Date().toISOString()
+      });
+  } else {
+    await knex<Host>("hosts").insert({ address, banned: true, banned_by_id });
+  }
 
-  if (host) {
-    redis.del(`${host.address}-${host.banned ? 'y' : 'n'}`);
+  if (currentHost) {
+    redis.del(getRedisKey.host(currentHost.address));
   }
 
-  return host;
+  return currentHost;
 };

+ 37 - 19
server/db/ip.ts

@@ -1,26 +1,44 @@
-import subMinutes from 'date-fns/sub_minutes';
-import IP from '../models/ip';
+import subMinutes from "date-fns/sub_minutes";
+
+import knex from "../knex";
+
+export const addIP = async (ipToGet: string) => {
+  const ip = ipToGet.toLowerCase();
+
+  const currentIP = await knex<IP>("ips")
+    .where({ 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 });
+  }
 
-export const addIP = async (newIP: string) => {
-  const ip = await IP.findOneAndUpdate(
-    { ip: newIP },
-    { ip: newIP, createdAt: new Date() },
-    { new: true, upsert: true, runValidators: true }
-  );
   return ip;
 };
 export const getIP = async (ip: string) => {
-  const matchedIp = await IP.findOne({
-    ip,
-    createdAt: {
-      $gt: subMinutes(new Date(), Number(process.env.NON_USER_COOLDOWN)),
-    },
-  });
+  const matchedIp = await knex<IP>("ips")
+    .where({ ip: ip.toLowerCase() })
+    .andWhere("created_at", ">", new Date().toISOString())
+    .first();
+
   return matchedIp;
 };
 export const clearIPs = async () =>
-  IP.deleteMany({
-    createdAt: {
-      $lt: subMinutes(new Date(), Number(process.env.NON_USER_COOLDOWN)),
-    },
-  });
+  knex<IP>("ips")
+    .where(
+      "created_at",
+      "<",
+      subMinutes(
+        new Date(),
+        Number(process.env.NON_USER_COOLDOWN)
+      ).toISOString()
+    )
+    .delete();

+ 314 - 277
server/db/link.ts

@@ -1,141 +1,165 @@
-import bcrypt from 'bcryptjs';
-import _ from 'lodash';
-import { isAfter, subDays } from 'date-fns';
-import { Types } from 'mongoose';
-
-import Link, { ILink } from '../models/link';
-import Visit from '../models/visit';
-import Domain, { IDomain } from '../models/domain';
+import bcrypt from "bcryptjs";
+import { isAfter, subDays } from "date-fns";
+import knex from "../knex";
+import * as redis from "../redis";
 import {
   generateShortLink,
-  statsObjectToArray,
-  getDifferenceFunction,
+  getRedisKey,
   getUTCDate,
-} from '../utils';
-import { getDomain, banDomain } from './domain';
-import * as redis from '../redis';
-import { banHost } from './host';
-import { banUser } from './user';
+  getDifferenceFunction,
+  statsObjectToArray
+} from "../utils";
+import { banDomain } from "./domain";
+import { banHost } from "./host";
+import { banUser } from "./user";
 
-interface ICreateLink extends ILink {
+interface CreateLink extends Link {
   reuse?: boolean;
+  domainName?: string;
 }
 
-export const createShortLink = async (data: ICreateLink) => {
+export const createShortLink = async (data: CreateLink, user: UserJoined) => {
+  const { id: user_id, domain, domain_id } = user;
   let password;
+
   if (data.password) {
     const salt = await bcrypt.genSalt(12);
     password = await bcrypt.hash(data.password, salt);
   }
 
-  const link = await Link.create({
-    id: data.id,
-    password,
-    target: data.target,
-    user: data.user,
-    domain: data.domain,
-  });
+  const [link]: Link[] = await knex<Link>("links").insert(
+    {
+      domain_id,
+      address: data.address,
+      password,
+      target: data.target,
+      user_id
+    },
+    "*"
+  );
 
   return {
     ...link,
     password: !!data.password,
     reuse: !!data.reuse,
-    shortLink: generateShortLink(
-      data.id,
-      data.domain && (data.domain as IDomain).name
-    ),
+    shortLink: generateShortLink(data.address, domain)
   };
 };
 
-export const addLinkCount = async (
-  id: Types.ObjectId,
-  customDomain?: string
-) => {
-  const domain = await (customDomain && getDomain({ name: customDomain }));
-  const url = await Link.findOneAndUpdate(
-    { id, domain: domain || { $exists: false } },
-    { $inc: { count: 1 } }
-  );
-  return url;
+export const addLinkCount = async (id: number) => {
+  return knex<Link>("links")
+    .where({ id })
+    .increment("visit_count", 1);
 };
 
 interface ICreateVisit {
   browser: string;
   country: string;
   domain?: string;
-  id: string;
+  id: number;
   limit: number;
   os: string;
   referrer: string;
 }
 
 export const createVisit = async (params: ICreateVisit) => {
-  const domain = await (params.domain && getDomain({ name: params.domain }));
-  const link = await Link.findOne({
-    id: params.id,
-    domain: domain || { $exists: false },
-  });
+  const data = {
+    ...params,
+    country: params.country.toLowerCase(),
+    referrer: params.referrer.toLowerCase()
+  };
 
-  if (link.count > params.limit) return null;
+  const visit = await knex<Visit>("visits")
+    .where({ link_id: params.id })
+    .andWhere(
+      knex.raw("date_trunc('hour', created_at) = date_trunc('hour', ?)", [
+        knex.fn.now()
+      ])
+    )
+    .first();
+
+  if (visit) {
+    const a = await knex("visits")
+      .where({ id: visit.id })
+      .increment(`br_${data.browser}`, 1)
+      .increment(`os_${data.os}`, 1)
+      .increment("total", 1)
+      .update({
+        updated_at: new Date().toISOString(),
+        countries: knex.raw(
+          "jsonb_set(countries, '{??}', (COALESCE(countries->>?,'0')::int + 1)::text::jsonb)",
+          [data.country, data.country]
+        ),
+        referrers: knex.raw(
+          "jsonb_set(referrers, '{??}', (COALESCE(referrers->>?,'0')::int + 1)::text::jsonb)",
+          [data.referrer, data.referrer]
+        )
+      });
+  } else {
+    await knex<Visit>("visits").insert({
+      [`br_${data.browser}`]: 1,
+      countries: { [data.country]: 1 },
+      referrers: { [data.referrer]: 1 },
+      [`os_${data.os}`]: 1,
+      total: 1,
+      link_id: data.id
+    });
+  }
 
-  const visit = await Visit.findOneAndUpdate(
-    {
-      data: getUTCDate().toJSON(),
-      link,
-    },
-    {
-      $inc: {
-        [`browser.${params.browser}`]: 1,
-        [`country.${params.country}`]: 1,
-        [`os.${params.os}`]: 1,
-        [`referrer.${params.referrer}`]: 1,
-        total: 1,
-      },
-    },
-    { upsert: true }
-  );
   return visit;
 };
 
 interface IFindLink {
-  id?: string;
-  domain?: Types.ObjectId | string;
-  user?: Types.ObjectId | string;
+  address?: string;
+  domain_id?: number | null;
+  user_id?: number | null;
   target?: string;
 }
 
-export const findLink = async (
-  { id = '', domain = '', user = '', target }: IFindLink,
-  options?: { forceDomainCheck?: boolean }
-) => {
-  const redisKey = id + domain.toString() + user.toString();
+export const findLink = async ({
+  address,
+  domain_id,
+  user_id,
+  target
+}: IFindLink): Promise<Link> => {
+  const redisKey = getRedisKey.link(address, domain_id, user_id);
   const cachedLink = await redis.get(redisKey);
 
   if (cachedLink) return JSON.parse(cachedLink);
 
-  const link = await Link.findOne({
-    ...(id && { id }),
-    ...(domain && { domain }),
-    ...(options.forceDomainCheck && { domain: domain || { $exists: false } }),
-    ...(user && { user }),
-    ...(target && { target }),
-  }).populate('domain');
-
-  redis.set(redisKey, JSON.stringify(link), 'EX', 60 * 60 * 2);
+  const link = await knex<Link>("links")
+    .where({
+      ...(address && { address }),
+      ...(domain_id && { domain_id }),
+      ...(user_id && { user_id }),
+      ...(target && { target })
+    })
+    .first();
+
+  if (link) {
+    redis.set(redisKey, JSON.stringify(link), "EX", 60 * 60 * 2);
+  }
 
-  // TODO: Get user?
   return link;
 };
 
 export const getUserLinksCount = async (params: {
-  user: Types.ObjectId;
+  user_id: number;
   date?: Date;
 }) => {
-  const count = await Link.find({
-    user: params.user,
-    ...(params.date && { createdAt: { $gt: params.date } }),
-  }).count();
-  return count;
+  const model = knex<Link>("links").where({ user_id: params.user_id });
+
+  // TODO: Test counts;
+  let res;
+  if (params.date) {
+    res = await model
+      .andWhere("created_at", ">", params.date.toISOString())
+      .count("id");
+  } else {
+    res = await model.count("id");
+  }
+
+  return res[0] && res[0].count;
 };
 
 interface IGetLinksOptions {
@@ -145,321 +169,334 @@ interface IGetLinksOptions {
 }
 
 export const getLinks = async (
-  user: Types.ObjectId,
+  user_id: number,
   options: IGetLinksOptions = {}
 ) => {
-  const { count = '5', page = '1', search = '' } = options;
-  const limit = parseInt(count, 10);
-  const skip = parseInt(page, 10);
-  const $regex = new RegExp(`.*${search}.*`, 'i');
-
-  const matchedLinks = await Link.find({
-    user,
-    $or: [{ id: { $regex } }, { target: { $regex } }],
-  })
-    .sort({ createdAt: -1 })
-    .skip(skip)
+  const { count = "5", page = "1", search = "" } = options;
+  const limit = parseInt(count) > 50 ? parseInt(count) : 50;
+  const offset = (parseInt(page) - 1) * limit;
+
+  const model = knex<LinkJoinedDomain>("links")
+    .select(
+      "links.id",
+      "links.address",
+      "links.banned",
+      "links.created_at",
+      "links.domain_id",
+      "links.updated_at",
+      "links.password",
+      "links.target",
+      "links.visit_count",
+      "links.user_id",
+      "domains.address as domain"
+    )
+    .offset(offset)
     .limit(limit)
-    .populate('domain');
+    .orderBy("created_at", "desc")
+    .where("links.user_id", user_id);
+
+  if (search) {
+    model.andWhereRaw("links.address || ' ' || target ILIKE '%' || ? || '%'", [
+      search
+    ]);
+  }
+
+  const matchedLinks = await model.leftJoin(
+    "domains",
+    "links.domain_id",
+    "domains.id"
+  );
 
   const links = matchedLinks.map(link => ({
     ...link,
+    id: link.address,
     password: !!link.password,
-    shortLink: generateShortLink(
-      link.id,
-      link.domain && (link.domain as IDomain).name
-    ),
+    shortLink: generateShortLink(link.address, link.domain)
   }));
 
   return links;
 };
 
 interface IDeleteLink {
-  id: string;
-  user: Types.ObjectId;
-  domain?: Types.ObjectId;
+  address: string;
+  user_id: number;
+  domain?: string;
 }
 
 export const deleteLink = async (data: IDeleteLink) => {
-  const link = await Link.findOneAndDelete({
-    id: data.id,
-    user: data.user,
-    domain: data.domain || { $exists: false },
-  });
-  await Visit.deleteMany({ link });
+  const link: LinkJoinedDomain = await knex<LinkJoinedDomain>("links")
+    .select("links.id", "domains.address as domain")
+    .where({
+      "links.address": data.address,
+      "links.user_id": data.user_id,
+      ...(!data.domain && { domain_id: null })
+    })
+    .leftJoin("domains", "links.domain_id", "domains.id")
+    .first();
+
+  if (!link) return;
+
+  if (link.domain !== data.domain) {
+    return;
+  }
 
-  const domainKey = link.domain ? link.domain.toString() : '';
-  const userKey = link.user ? link.user.toString() : '';
-  redis.del(link.id + domainKey);
-  redis.del(link.id + domainKey + userKey);
+  const deletedLink = await knex<Link>("links")
+    .where("id", link.id)
+    .delete();
 
-  return link;
+  redis.del(getRedisKey.link(link.address, link.domain_id, link.user_id));
+
+  return !!deletedLink;
 };
 
 /*
  ** Collecting stats
  */
 
-interface IStats {
-  browser: Record<
-    'chrome' | 'edge' | 'firefox' | 'ie' | 'opera' | 'other' | 'safari',
-    number
-  >;
-  os: Record<
-    'android' | 'ios' | 'linux' | 'macos' | 'other' | 'windows',
-    number
-  >;
-  country: Record<string, number>;
-  referrer: Record<string, number>;
-  dates: Date[];
-}
-
-interface Stats {
-  stats: IStats;
+interface StatsResult {
+  stats: {
+    browser: { name: string; value: number }[];
+    os: { name: string; value: number }[];
+    country: { name: string; value: number }[];
+    referrer: { name: string; value: number }[];
+  };
   views: number[];
 }
 
-const INIT_STATS: IStats = {
-  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: {},
-  dates: [],
-};
+const getInitStats = (): Stats =>
+  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 STATS_PERIODS: [number, 'lastDay' | 'lastWeek' | 'lastMonth'][] = [
-  [1, 'lastDay'],
-  [7, 'lastWeek'],
-  [30, 'lastMonth'],
+const STATS_PERIODS: [number, "lastDay" | "lastWeek" | "lastMonth"][] = [
+  [1, "lastDay"],
+  [7, "lastWeek"],
+  [30, "lastMonth"]
 ];
 
-interface IGetStats {
-  domain: Types.ObjectId;
-  id: string;
-  user: Types.ObjectId;
-}
-
 interface IGetStatsResponse {
-  allTime: Stats;
+  allTime: StatsResult;
   id: string;
-  lastDay: Stats;
-  lastMonth: Stats;
-  lastWeek: Stats;
+  lastDay: StatsResult;
+  lastMonth: StatsResult;
+  lastWeek: StatsResult;
   shortLink: string;
   target: string;
   total: number;
   updatedAt: string;
 }
 
-export const getStats = async (data: IGetStats) => {
+export const getStats = async (link: Link, domain: Domain) => {
   const stats = {
     lastDay: {
-      stats: _.cloneDeep(INIT_STATS),
-      views: new Array(24).fill(0),
+      stats: getInitStats(),
+      views: new Array(24).fill(0)
     },
     lastWeek: {
-      stats: _.cloneDeep(INIT_STATS),
-      views: new Array(7).fill(0),
+      stats: getInitStats(),
+      views: new Array(7).fill(0)
     },
     lastMonth: {
-      stats: _.cloneDeep(INIT_STATS),
-      views: new Array(30).fill(0),
+      stats: getInitStats(),
+      views: new Array(30).fill(0)
     },
     allTime: {
-      stats: _.cloneDeep(INIT_STATS),
-      views: new Array(18).fill(0),
-    },
+      stats: getInitStats(),
+      views: new Array(18).fill(0)
+    }
   };
 
-  const domain = await (data.domain && Domain.findOne({ name: data.domain }));
-  const link = await Link.findOne({
-    id: data.id,
-    user: data.user,
-    ...(domain && { domain }),
-  });
-
-  if (!link) throw new Error("Couldn't get stats for this link.");
-
-  const visits = await Visit.find({
-    link: link.id,
-  });
+  const visitsStream: any = knex<Visit>("visits")
+    .where("link_id", link.id)
+    .stream();
+  const nowUTC = getUTCDate();
+  const now = new Date();
 
-  visits.forEach(visit => {
+  for await (const visit of visitsStream as Visit[]) {
     STATS_PERIODS.forEach(([days, type]) => {
-      const isIncluded = isAfter(visit.date, subDays(getUTCDate(), days));
+      const isIncluded = isAfter(visit.created_at, subDays(nowUTC, days));
       if (isIncluded) {
         const diffFunction = getDifferenceFunction(type);
-        const now = new Date();
-        const diff = diffFunction(now, visit.date);
+        const diff = diffFunction(now, visit.created_at);
         const index = stats[type].views.length - diff - 1;
         const view = stats[type].views[index];
         const period = stats[type].stats;
         stats[type].stats = {
           browser: {
-            chrome: period.chrome + visit.browser.chrome,
-            edge: period.edge + visit.browser.edge,
-            firefox: period.firefox + visit.browser.firefox,
-            ie: period.ie + visit.browser.ie,
-            opera: period.opera + visit.browser.opera,
-            other: period.other + visit.browser.other,
-            safari: period.safari + visit.browser.safari,
+            chrome: period.browser.chrome + visit.br_chrome,
+            edge: period.browser.edge + visit.br_edge,
+            firefox: period.browser.firefox + visit.br_firefox,
+            ie: period.browser.ie + visit.br_ie,
+            opera: period.browser.opera + visit.br_opera,
+            other: period.browser.other + visit.br_other,
+            safari: period.browser.safari + visit.br_safari
           },
           os: {
-            android: period.android + visit.os.android,
-            ios: period.ios + visit.os.ios,
-            linux: period.linux + visit.os.linux,
-            macos: period.macos + visit.os.macos,
-            other: period.other + visit.os.other,
-            windows: period.windows + visit.os.windows,
+            android: period.os.android + visit.os_android,
+            ios: period.os.ios + visit.os_ios,
+            linux: period.os.linux + visit.os_linux,
+            macos: period.os.macos + visit.os_macos,
+            other: period.os.other + visit.os_other,
+            windows: period.os.windows + visit.os_windows
           },
           country: {
             ...period.country,
-            ...Object.keys(visit.country).reduce(
-              (obj, key) => ({
+            ...Object.entries(visit.countries).reduce(
+              (obj, [country, count]) => ({
                 ...obj,
-                [key]: period.country[key] + visit.country[key],
+                [country]: (period.country[country] || 0) + count
               }),
               {}
-            ),
+            )
           },
           referrer: {
             ...period.referrer,
-            ...Object.keys(visit.referrer).reduce(
-              (obj, key) => ({
+            ...Object.entries(visit.referrers).reduce(
+              (obj, [referrer, count]) => ({
                 ...obj,
-                [key]: period.referrer[key] + visit.referrer[key],
+                [referrer]: (period.referrer[referrer] || 0) + count
               }),
               {}
-            ),
-          },
+            )
+          }
         };
-        stats[type].views[index] = view + 1 || 1;
+        stats[type].views[index] = view + visit.total;
       }
     });
 
     const allTime = stats.allTime.stats;
-    const diffFunction = getDifferenceFunction('allTime');
-    const now = new Date();
-    const diff = diffFunction(now, visit.date);
+    const diffFunction = getDifferenceFunction("allTime");
+    const diff = diffFunction(now, visit.created_at);
     const index = stats.allTime.views.length - diff - 1;
     const view = stats.allTime.views[index];
     stats.allTime.stats = {
       browser: {
-        chrome: allTime.chrome + visit.browser.chrome,
-        edge: allTime.edge + visit.browser.edge,
-        firefox: allTime.firefox + visit.browser.firefox,
-        ie: allTime.ie + visit.browser.ie,
-        opera: allTime.opera + visit.browser.opera,
-        other: allTime.other + visit.browser.other,
-        safari: allTime.safari + visit.browser.safari,
+        chrome: allTime.browser.chrome + visit.br_chrome,
+        edge: allTime.browser.edge + visit.br_edge,
+        firefox: allTime.browser.firefox + visit.br_firefox,
+        ie: allTime.browser.ie + visit.br_ie,
+        opera: allTime.browser.opera + visit.br_opera,
+        other: allTime.browser.other + visit.br_other,
+        safari: allTime.browser.safari + visit.br_safari
       },
       os: {
-        android: allTime.android + visit.os.android,
-        ios: allTime.ios + visit.os.ios,
-        linux: allTime.linux + visit.os.linux,
-        macos: allTime.macos + visit.os.macos,
-        other: allTime.other + visit.os.other,
-        windows: allTime.windows + visit.os.windows,
+        android: allTime.os.android + visit.os_android,
+        ios: allTime.os.ios + visit.os_ios,
+        linux: allTime.os.linux + visit.os_linux,
+        macos: allTime.os.macos + visit.os_macos,
+        other: allTime.os.other + visit.os_other,
+        windows: allTime.os.windows + visit.os_windows
       },
       country: {
         ...allTime.country,
-        ...Object.keys(visit.country).reduce(
-          (obj, key) => ({
+        ...Object.entries(visit.countries).reduce(
+          (obj, [country, count]) => ({
             ...obj,
-            [key]: allTime.country[key] + visit.country[key],
+            [country]: (allTime.country[country] || 0) + count
           }),
           {}
-        ),
+        )
       },
       referrer: {
         ...allTime.referrer,
-        ...Object.keys(visit.referrer).reduce(
-          (obj, key) => ({
+        ...Object.entries(visit.referrers).reduce(
+          (obj, [referrer, count]) => ({
             ...obj,
-            [key]: allTime.referrer[key] + visit.referrer[key],
+            [referrer]: (allTime.referrer[referrer] || 0) + count
           }),
           {}
-        ),
-      },
+        )
+      }
     };
-    stats.allTime.views[index] = view + 1 || 1;
-  });
+    stats.allTime.views[index] = view + visit.total;
+  }
 
-  stats.lastDay.stats = statsObjectToArray(stats.lastDay.stats);
-  stats.lastWeek.stats = statsObjectToArray(stats.lastWeek.stats);
-  stats.lastMonth.stats = statsObjectToArray(stats.lastMonth.stats);
-  stats.allTime.stats = statsObjectToArray(stats.allTime.stats);
   const response: IGetStatsResponse = {
-    allTime: stats.allTime,
-    id: link.id,
-    lastDay: stats.lastDay,
-    lastMonth: stats.lastMonth,
-    lastWeek: stats.lastWeek,
-    shortLink: generateShortLink(
-      link.id,
-      link.domain && (link.domain as IDomain).name
-    ),
+    allTime: {
+      stats: statsObjectToArray(stats.allTime.stats),
+      views: stats.allTime.views
+    },
+    id: link.address,
+    lastDay: {
+      stats: statsObjectToArray(stats.lastDay.stats),
+      views: stats.lastDay.views
+    },
+    lastMonth: {
+      stats: statsObjectToArray(stats.lastDay.stats),
+      views: stats.lastDay.views
+    },
+    lastWeek: {
+      stats: statsObjectToArray(stats.lastWeek.stats),
+      views: stats.lastWeek.views
+    },
+    shortLink: generateShortLink(link.address, domain.address),
     target: link.target,
-    total: link.count,
-    updatedAt: new Date().toISOString(),
+    total: link.visit_count,
+    updatedAt: new Date().toISOString()
   };
   return response;
 };
 
 interface IBanLink {
-  adminId?: Types.ObjectId;
+  adminId?: number;
   banUser?: boolean;
   domain?: string;
   host?: string;
-  id: string;
+  address: string;
 }
 
 export const banLink = async (data: IBanLink) => {
   const tasks = [];
-  const bannedBy = data.adminId;
+  const banned_by_id = data.adminId;
 
   // Ban link
-  const link = await Link.findOneAndUpdate(
-    { id: data.id },
-    { banned: true, bannedBy },
-    { new: true }
-  );
+  const [link]: Link[] = await knex<Link>("links")
+    .where({ address: data.address, domain_id: null })
+    .update(
+      { banned: true, banned_by_id, updated_at: new Date().toISOString() },
+      "*"
+    );
 
-  if (!link) throw new Error('No link has been found.');
+  if (!link) throw new Error("No link has been found.");
 
   // If user, ban user and all of their links.
-  if (data.banUser && link.user) {
-    tasks.push(banUser(link.user, bannedBy));
+  if (data.banUser && link.user_id) {
+    tasks.push(banUser(link.user_id, banned_by_id));
     tasks.push(
-      Link.updateMany({ user: link.user }, { banned: true, bannedBy })
+      knex<Link>("links")
+        .where({ user_id: link.user_id })
+        .update(
+          { banned: true, banned_by_id, updated_at: new Date().toISOString() },
+          "*"
+        )
     );
   }
 
   // Ban host
-  if (data.host) tasks.push(banHost(data.host, bannedBy));
+  if (data.host) tasks.push(banHost(data.host, banned_by_id));
 
   // Ban domain
-  if (data.domain) tasks.push(banDomain(data.domain, bannedBy));
+  if (data.domain) tasks.push(banDomain(data.domain, banned_by_id));
 
-  const domainKey = link.domain ? link.domain.toString() : '';
-  const userKey = link.user ? link.user.toString() : '';
-  redis.del(link.id + domainKey);
-  redis.del(link.id + domainKey + userKey);
+  redis.del(getRedisKey.link(link.address, link.domain_id, link.user_id));
 
   return Promise.all(tasks);
 };

+ 160 - 102
server/db/user.ts

@@ -1,149 +1,207 @@
-import bcrypt from 'bcryptjs';
-import { Types } from 'mongoose';
-import nanoid from 'nanoid';
-import uuid from 'uuid/v4';
-import addMinutes from 'date-fns/add_minutes';
+import bcrypt from "bcryptjs";
+import nanoid from "nanoid";
+import uuid from "uuid/v4";
+import addMinutes from "date-fns/add_minutes";
 
-import User from '../models/user';
-import * as redis from '../redis';
+import knex from "../knex";
+import * as redis from "../redis";
+import { getRedisKey } from "../utils";
 
-export const getUser = async (emailOrKey: string = '') => {
-  const cachedUser = await redis.get(emailOrKey);
+export const getUser = async (emailOrKey = ""): Promise<User> => {
+  const redisKey = getRedisKey.user(emailOrKey);
+  const cachedUser = await redis.get(redisKey);
 
   if (cachedUser) return JSON.parse(cachedUser);
 
-  const user = await User.findOne({
-    $or: [{ email: emailOrKey }, { apikey: emailOrKey }],
-  })
-    .populate('domain')
-    .lean();
-
-  redis.set(emailOrKey, JSON.parse(user), 'EX', 60 * 60 * 1);
+  const user = await knex<UserJoined>("users")
+    .select(
+      "users.id",
+      "users.apikey",
+      "users.banned",
+      "users.banned_by_id",
+      "users.cooldowns",
+      "users.created_at",
+      "users.email",
+      "users.password",
+      "users.updated_at",
+      "users.verified",
+      "domains.id as domain_id",
+      "domains.homepage as homepage",
+      "domains.address as domain"
+    )
+    .where({ email: emailOrKey.toLowerCase() })
+    .orWhere({ apikey: emailOrKey })
+    .leftJoin("domains", "users.id", "domains.user_id")
+    .first();
+
+  if (user) {
+    redis.set(redisKey, JSON.stringify(user), "EX", 60 * 60 * 1);
+  }
 
   return user;
 };
 
-export const createUser = async (email: string, password: string) => {
+export const createUser = async (
+  emailToCreate: string,
+  password: string,
+  user?: User
+) => {
+  const email = emailToCreate.toLowerCase();
   const salt = await bcrypt.genSalt(12);
   const hashedPassword = await bcrypt.hash(password, salt);
 
-  const user = await User.findOneAndUpdate(
-    { email },
-    {
-      email,
-      password: hashedPassword,
-      verificationToken: uuid(),
-      verificationExpires: addMinutes(new Date(), 60),
-    },
-    { new: true, upsert: true, runValidators: true, setDefaultsOnInsert: true }
-  );
-
-  redis.del(user.email);
-
-  return user;
+  const data = {
+    email,
+    password: hashedPassword,
+    verification_token: uuid(),
+    verification_expires: addMinutes(new Date(), 60).toISOString()
+  };
+
+  if (user) {
+    await knex<User>("users")
+      .where({ email, updated_at: new Date().toISOString() })
+      .update(data);
+  } else {
+    await knex<User>("users").insert(data);
+  }
+
+  redis.del(getRedisKey.user(email));
+
+  return {
+    ...user,
+    ...data
+  };
 };
 
-export const verifyUser = async (verificationToken: string) => {
-  const user = await User.findOneAndUpdate(
-    { verificationToken, verificationExpires: { $gt: new Date() } },
-    {
-      verified: true,
-      verificationToken: undefined,
-      verificationExpires: undefined,
-    },
-    { new: true }
-  );
-
-  redis.del(user.email);
+export const verifyUser = async (verification_token: string) => {
+  const [user]: User[] = await knex<User>("users")
+    .where({ verification_token })
+    .andWhere("verification_expires", ">", new Date().toISOString())
+    .update(
+      {
+        verified: true,
+        verification_token: undefined,
+        verification_expires: undefined,
+        updated_at: new Date().toISOString()
+      },
+      "*"
+    );
+
+  if (user) {
+    redis.del(getRedisKey.user(user.email));
+  }
 
   return user;
 };
 
-export const changePassword = async (
-  id: Types.ObjectId,
-  newPassword: string
-) => {
+export const changePassword = async (id: number, newPassword: string) => {
   const salt = await bcrypt.genSalt(12);
   const password = await bcrypt.hash(newPassword, salt);
 
-  const user = await User.findByIdAndUpdate(id, { password }, { new: true });
+  const [user]: User[] = await knex<User>("users")
+    .where({ id })
+    .update({ password, updated_at: new Date().toISOString() }, "*");
 
-  redis.del(user.email);
-  redis.del(user.apikey);
+  if (user) {
+    redis.del(getRedisKey.user(user.email));
+    redis.del(getRedisKey.user(user.apikey));
+  }
 
   return user;
 };
 
-export const generateApiKey = async (id: Types.ObjectId) => {
+export const generateApiKey = async (id: number) => {
   const apikey = nanoid(40);
 
-  const user = await User.findByIdAndUpdate(id, { apikey });
+  const [user]: User[] = await knex<User>("users")
+    .where({ id })
+    .update({ apikey, updated_at: new Date().toISOString() }, "*");
 
-  redis.del(user.email);
-  redis.del(user.apikey);
+  if (user) {
+    redis.del(getRedisKey.user(user.email));
+    redis.del(getRedisKey.user(user.apikey));
+  }
 
-  return { ...user, apikey };
+  return user && apikey;
 };
 
-export const requestPasswordReset = async (email: string) => {
-  const resetPasswordToken = uuid();
-
-  const user = await User.findOneAndUpdate(
-    { email },
-    {
-      resetPasswordToken,
-      resetPasswordExpires: addMinutes(new Date(), 30),
-    },
-    { new: true }
-  );
-
-  redis.del(user.email);
-  redis.del(user.apikey);
+export const requestPasswordReset = async (emailToMatch: string) => {
+  const email = emailToMatch.toLowerCase();
+  const reset_password_token = uuid();
+
+  const [user]: User[] = await knex<User>("users")
+    .where({ email })
+    .update(
+      {
+        reset_password_token,
+        reset_password_expires: addMinutes(new Date(), 30).toISOString(),
+        updated_at: new Date().toISOString()
+      },
+      "*"
+    );
+
+  if (user) {
+    redis.del(getRedisKey.user(user.email));
+    redis.del(getRedisKey.user(user.apikey));
+  }
 
   return user;
 };
 
-export const resetPassword = async (resetPasswordToken: string) => {
-  const user = await User.findOneAndUpdate(
-    { resetPasswordToken, resetPasswordExpires: { $gt: new Date() } },
-    { resetPasswordExpires: undefined, resetPasswordToken: undefined },
-    { new: true }
-  );
-
-  redis.del(user.email);
-  redis.del(user.apikey);
+export const resetPassword = async (reset_password_token: string) => {
+  const [user]: User[] = await knex<User>("users")
+    .where({ reset_password_token })
+    .andWhere("reset_password_expires", ">", new Date().toISOString())
+    .update(
+      {
+        reset_password_expires: null,
+        reset_password_token: null,
+        updated_at: new Date().toISOString()
+      },
+      "*"
+    );
+
+  if (user) {
+    redis.del(getRedisKey.user(user.email));
+    redis.del(getRedisKey.user(user.apikey));
+  }
 
   return user;
 };
 
-export const addCooldown = async (id: Types.ObjectId) => {
-  const user = await User.findByIdAndUpdate(
-    id,
-    { $push: { cooldowns: new Date() } },
-    { new: true }
-  );
-
-  redis.del(user.email);
-  redis.del(user.apikey);
+export const addCooldown = async (id: number) => {
+  const [user]: User[] = await knex("users")
+    .where({ id })
+    .update(
+      {
+        cooldowns: knex.raw("array_append(cooldowns, ?)", [
+          new Date().toISOString()
+        ]),
+        updated_at: new Date().toISOString()
+      },
+      "*"
+    );
+
+  if (user) {
+    redis.del(getRedisKey.user(user.email));
+    redis.del(getRedisKey.user(user.apikey));
+  }
 
   return user;
 };
 
-export const banUser = async (
-  id: Types.ObjectId,
-  bannedBy?: Types.ObjectId
-) => {
-  const user = await User.findByIdAndUpdate(
-    id,
-    {
-      banned: true,
-      bannedBy,
-    },
-    { new: true }
-  );
-
-  redis.del(user.email);
-  redis.del(user.apikey);
+export const banUser = async (id: number, banned_by_id?: number) => {
+  const [user]: User[] = await knex<User>("users")
+    .where({ id })
+    .update(
+      { banned: true, banned_by_id, updated_at: new Date().toISOString() },
+      "*"
+    );
+
+  if (user) {
+    redis.del(getRedisKey.user(user.email));
+    redis.del(getRedisKey.user(user.apikey));
+  }
 
   return user;
 };

+ 28 - 0
server/knex.ts

@@ -0,0 +1,28 @@
+import knex from "knex";
+import { createUserTable } from "./models/user";
+import { createDomainTable } from "./models/domain";
+import { createLinkTable } from "./models/link";
+import { createVisitTable } from "./models/visit";
+import { createIPTable } from "./models/ip";
+import { createHostTable } from "./models/host";
+
+const db = knex({
+  client: "postgres",
+  connection: {
+    host: process.env.DB_HOST,
+    database: process.env.DB_NAME,
+    user: process.env.DB_USER,
+    password: process.env.DB_PASSWORD
+  }
+});
+
+export async function initializeDb() {
+  await createUserTable(db);
+  await createIPTable(db);
+  await createDomainTable(db);
+  await createHostTable(db);
+  await createLinkTable(db);
+  await createVisitTable(db);
+}
+
+export default db;

+ 6 - 6
server/mail/mail.ts

@@ -1,14 +1,14 @@
-import nodemailer from 'nodemailer';
+import nodemailer from "nodemailer";
 
-const mailConfig = nodemailer.createTransport({
+const mailConfig = {
   host: process.env.MAIL_HOST,
   port: Number(process.env.MAIL_PORT),
-  secure: process.env.MAIL_SECURE === 'true',
+  secure: process.env.MAIL_SECURE === "true",
   auth: {
     user: process.env.MAIL_USER,
-    pass: process.env.MAIL_PASSWORD,
-  },
-});
+    pass: process.env.MAIL_PASSWORD
+  }
+};
 
 const transporter = nodemailer.createTransport(mailConfig);
 

+ 67 - 0
server/migration/01_host.ts

@@ -0,0 +1,67 @@
+require("dotenv").config();
+import { v1 as NEO4J } from "neo4j-driver";
+import knex from "knex";
+import PQueue from "p-queue";
+
+const queue = new PQueue({ concurrency: 1 });
+
+// 1. Connect to Neo4j database
+const neo4j = NEO4J.driver(
+  process.env.NEO4J_DB_URI,
+  NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
+);
+// 2. Connect to Postgres database
+const postgres = knex({
+  client: "postgres",
+  connection: {
+    host: process.env.DB_HOST,
+    database: process.env.DB_NAME,
+    user: process.env.DB_USER,
+    password: process.env.DB_PASSWORD
+  }
+});
+
+(async function() {
+  const startTime = Date.now();
+
+  // 3. [NEO4J] Get all hosts
+  const session = neo4j.session();
+  session.run("MATCH (h:HOST) RETURN h").subscribe({
+    onNext(record) {
+      queue.add(async () => {
+        // 4. [Postgres] Upsert Hosts
+        const host = record.get("h").properties;
+        const address = host.name;
+        const banned = !!host.banned;
+        const exists = await postgres<Host>("hosts")
+          .where({
+            address
+          })
+          .first();
+        if (exists) {
+          await postgres<Host>("hosts")
+            .where("id", exists.id)
+            .update({ banned });
+        } else {
+          await postgres<Host>("hosts").insert({
+            address,
+            banned
+          });
+        }
+      });
+    },
+    onCompleted() {
+      session.close();
+      queue.add(() => {
+        const endTime = Date.now();
+        console.log(
+          `✅ Done! It took ${(endTime - startTime) / 1000} seconds.`
+        );
+      });
+    },
+    onError(error) {
+      session.close();
+      throw error;
+    }
+  });
+})();

+ 86 - 0
server/migration/02_users.ts

@@ -0,0 +1,86 @@
+require("dotenv").config();
+import { v1 as NEO4J } from "neo4j-driver";
+import knex from "knex";
+import PQuque from "p-queue";
+
+const queue = new PQuque({ concurrency: 1 });
+
+// 1. Connect to Neo4j database
+const neo4j = NEO4J.driver(
+  process.env.NEO4J_DB_URI,
+  NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
+);
+// 2. Connect to Postgres database
+const postgres = knex({
+  client: "postgres",
+  connection: {
+    host: process.env.DB_HOST,
+    database: process.env.DB_NAME,
+    user: process.env.DB_USER,
+    password: process.env.DB_PASSWORD
+  }
+});
+
+(async function() {
+  const startTime = Date.now();
+
+  // 3. [NEO4J] Get all users
+  const session = neo4j.session();
+  session
+    .run(
+      "MATCH (u:USER) OPTIONAL MATCH (u)-[r:RECEIVED]->(c) WITH u, collect(c.date) as cooldowns RETURN u, cooldowns"
+    )
+    .subscribe({
+      onNext(record) {
+        queue.add(async () => {
+          // 4. [Postgres] Upsert users
+          const user = record.get("u").properties;
+          const cooldowns = record.get("cooldowns");
+
+          const email = user.email;
+          const password = user.password;
+          const verified = !!user.verified;
+          const banned = !!user.banned;
+          const apikey = user.apikey;
+          const created_at = user.createdAt;
+
+          const data = {
+            email,
+            password,
+            verified,
+            banned,
+            ...(apikey && { apikey }),
+            ...(created_at && { created_at }),
+            ...(cooldowns && cooldowns.length && { cooldowns })
+          };
+
+          const exists = await postgres<User>("users")
+            .where({
+              email
+            })
+            .first();
+          if (exists) {
+            await postgres<User>("users")
+              .where("id", exists.id)
+              .update(data);
+          } else {
+            await postgres<User>("users").insert(data);
+          }
+        });
+      },
+      onCompleted() {
+        session.close();
+        queue.add(() => {
+          const endTime = Date.now();
+          console.log(
+            `✅ Done! It took ${(endTime - startTime) / 1000} seconds.`
+          );
+        });
+      },
+      onError(error) {
+        session.close();
+        throw error;
+      }
+    });
+})();
+// 5. TODO: [Postgres] Update bannedBy

+ 97 - 0
server/migration/03_domains.ts

@@ -0,0 +1,97 @@
+require("dotenv").config();
+import { v1 as NEO4J } from "neo4j-driver";
+import knex from "knex";
+import PQueue from "p-queue";
+
+const queue = new PQueue({ concurrency: 1 });
+
+// 1. Connect to Neo4j database
+const neo4j = NEO4J.driver(
+  process.env.NEO4J_DB_URI,
+  NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
+);
+// 2. Connect to Postgres database
+const postgres = knex({
+  client: "postgres",
+  connection: {
+    host: process.env.DB_HOST,
+    database: process.env.DB_NAME,
+    user: process.env.DB_USER,
+    password: process.env.DB_PASSWORD
+  }
+});
+
+(async function() {
+  const startTime = Date.now();
+
+  // 3. [NEO4J] Get all domain
+  const session = neo4j.session();
+  session
+    .run(
+      "MATCH (d:DOMAIN) OPTIONAL MATCH (u)-[:OWNS]->(d) RETURN d as domain, u.email as email"
+    )
+    .subscribe({
+      onNext(record) {
+        queue.add(async () => {
+          const domain = record.get("domain").properties;
+          const email = record.get("email");
+
+          // 4. [Postgres] Get user ID
+          const user =
+            email &&
+            (await postgres<User>("users")
+              .where({ email })
+              .first());
+
+          // 5. [Postgres] Upsert domains
+          const banned = !!domain.banned;
+          const address = domain.name;
+          const homepage = domain.homepage;
+          const user_id = user ? user.id : null;
+
+          const data = {
+            banned,
+            address,
+            ...(homepage && { homepage }),
+            ...(user_id && { user_id })
+          };
+
+          const exists = await postgres<Domain>("domains")
+            .where({
+              address
+            })
+            .first();
+          if (exists) {
+            await postgres<Domain>("domains")
+              .where("id", exists.id)
+              .update(data);
+          } else {
+            await postgres<Domain>("domains").insert(data);
+          }
+        });
+      },
+      onCompleted() {
+        session.close();
+        queue.add(() => {
+          const endTime = Date.now();
+          console.log(
+            `✅ Done! It took ${(endTime - startTime) / 1000} seconds.`
+          );
+        });
+      },
+      onError(error) {
+        session.close();
+        throw error;
+      }
+    });
+})();
+
+// LINKS
+// 1. [NEO4J] Get all links as stream
+// 2. [Postgres] If link has user and domain, get them
+// 3. [Postgres] Upsert link
+
+// VISISTS
+// 1. [NEO4J] For every link get visists as stream
+// 2. [JAVaSCRIPT] Sum stats for each visist with the same date
+// 3. [Postgres] Create visits

+ 181 - 0
server/migration/04_links.ts

@@ -0,0 +1,181 @@
+require("dotenv").config();
+import { v1 as NEO4J } from "neo4j-driver";
+import knex from "knex";
+import PQueue from "p-queue";
+import { startOfHour } from "date-fns";
+
+const queue = new PQueue({ concurrency: 1 });
+
+// 1. Connect to Neo4j database
+const neo4j = NEO4J.driver(
+  process.env.NEO4J_DB_URI,
+  NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
+);
+// 2. Connect to Postgres database
+const postgres = knex({
+  client: "postgres",
+  connection: {
+    host: process.env.DB_HOST,
+    database: process.env.DB_NAME,
+    user: process.env.DB_USER,
+    password: process.env.DB_PASSWORD
+  }
+});
+
+(async function() {
+  const startTime = Date.now();
+
+  // 3. [NEO4J] Get all links
+  const session = neo4j.session();
+  session
+    .run(
+      "MATCH (l:URL) " +
+        "OPTIONAL MATCH (l)-[:USES]->(d) " +
+        "OPTIONAL MATCH (l)<-[:CREATED]-(u) " +
+        "OPTIONAL MATCH (v)-[:VISITED]->(l) " +
+        "OPTIONAL MATCH (v)-[:BROWSED_BY]->(b) " +
+        "OPTIONAL MATCH (v)-[:OS]->(o) " +
+        "OPTIONAL MATCH (v)-[:LOCATED_IN]->(c) " +
+        "OPTIONAL MATCH (v)-[:REFERRED_BY]->(r) " +
+        "OPTIONAL MATCH (v)-[:VISITED_IN]->(dd) " +
+        "WITH l, u, d, COLLECT([b.browser, o.os, c.country, r.referrer, dd.date]) as stats " +
+        "LIMIT 100 RETURN l, u.email as email, d.name as domain, stats"
+    )
+    .subscribe({
+      onNext(record) {
+        queue.add(async () => {
+          const link = record.get("l").properties;
+          const email = record.get("email");
+          const address = record.get("domain");
+          const stats = record.get("stats");
+
+          // 4. Merge and normalize stats based on hour
+          const visits: Record<
+            string,
+            Record<string, number | Record<string, number>>
+          > = {} as any;
+
+          stats.forEach(([b, o, country, referrer, date]) => {
+            const dateHour = startOfHour(date).toISOString();
+            const browser = b.toLowerCase();
+            const os = o === "Mac Os X" ? "macos" : o.toLowerCase();
+            visits[dateHour] = {
+              ...visits[dateHour],
+              total:
+                (((visits[dateHour] && visits[dateHour].total) as number) ||
+                  0) + 1,
+              [`br_${browser}`]:
+                (((visits[dateHour] &&
+                  visits[dateHour][`br_${browser}`]) as number) || 0) + 1,
+              [`os_${os}`]:
+                (((visits[dateHour] &&
+                  visits[dateHour][`os_${os}`]) as number) || 0) + 1,
+              countries: {
+                ...((visits[dateHour] || {}).countries as {}),
+                [country.toLowerCase()]:
+                  ((visits[dateHour] &&
+                    visits[dateHour].countries[country.toLowerCase()]) ||
+                    0) + 1
+              },
+              referrers: {
+                ...((visits[dateHour] || {}).referrers as {}),
+                [referrer.toLowerCase()]:
+                  ((visits[dateHour] &&
+                    visits[dateHour].countries[referrer.toLowerCase()]) ||
+                    0) + 1
+              }
+            };
+          });
+
+          // 5. [Postgres] Find matching user and or domain
+          const [user, domain] = await Promise.all([
+            email &&
+              postgres<User>("users")
+                .where({ email })
+                .first(),
+            address &&
+              postgres<Domain>("domains")
+                .where({ address })
+                .first()
+          ]);
+
+          // 6. [Postgres] Create link
+          const data = {
+            address: link.id,
+            banned: !!link.banned,
+            domain_id: domain ? domain.id : null,
+            password: link.password,
+            target: link.target,
+            user_id: user ? user.id : null,
+            ...(link.count && { visit_count: link.count.toNumber() }),
+            ...(link.createdAt && { created_at: link.createdAt })
+          };
+
+          const exists = await postgres<Link>("links")
+            .where({ address: link.id })
+            .first();
+
+          let link_id: number;
+          if (exists) {
+            const res = await postgres<Link>("links")
+              .where("id", exists.id)
+              .update(data, "id");
+            link_id = res[0];
+          } else {
+            const res = await postgres<Link>("links").insert(data, "id");
+            link_id = res[0];
+          }
+
+          // 7. [Postgres] Create visits
+          for await (const [date, details] of Object.entries(visits)) {
+            const data = {
+              link_id,
+              created_at: date,
+              countries: details.countries as Record<string, number>,
+              referrers: details.referrers as Record<string, number>,
+              total: details.total as number,
+              br_chrome: details.br_chrome as number,
+              br_edge: details.br_edge as number,
+              br_firefox: details.br_firefox as number,
+              br_ie: details.br_ie as number,
+              br_opera: details.br_opera as number,
+              br_other: details.br_other as number,
+              br_safari: details.br_safari as number,
+              os_android: details.os_android as number,
+              os_ios: details.os_ios as number,
+              os_linux: details.os_linux as number,
+              os_macos: details.os_macos as number,
+              os_other: details.os_other as number,
+              os_windows: details.os_windows as number
+            };
+
+            const visitExists = await postgres<Visit>("visits")
+              .where({ link_id, created_at: data.created_at })
+              .first();
+
+            if (visitExists) {
+              await postgres<Visit>("visits")
+                .where("id", visitExists.id)
+                .update(data);
+            } else {
+              await postgres<Visit>("visits").insert(data);
+            }
+          }
+        });
+      },
+      onCompleted() {
+        session.close();
+        queue.add(() => {
+          const endTime = Date.now();
+          console.log(
+            `✅ Done! It took ${(endTime - startTime) / 1000} seconds.`
+          );
+        });
+      },
+      onError(error) {
+        console.log(error);
+        session.close();
+        throw error;
+      }
+    });
+})();

+ 27 - 23
server/models/domain.ts

@@ -1,25 +1,29 @@
-import { Document, model, Schema, Types } from 'mongoose';
+import * as Knex from "knex";
 
-export interface IDomain extends Document {
-  banned?: boolean;
-  bannedBy?: Types.ObjectId;
-  createdAt: Date;
-  name: string;
-  homepage?: string;
-  updatedAt?: Date;
-  user?: Types.ObjectId;
+export async function createDomainTable(knex: Knex) {
+  const hasTable = await knex.schema.hasTable("domains");
+  if (!hasTable) {
+    await knex.schema.createTable("domains", table => {
+      table.increments("id").primary();
+      table
+        .boolean("banned")
+        .notNullable()
+        .defaultTo(false);
+      table
+        .integer("banned_by_id")
+        .references("id")
+        .inTable("users");
+      table
+        .string("address")
+        .unique()
+        .notNullable();
+      table.string("homepage").nullable();
+      table
+        .integer("user_id")
+        .references("id")
+        .inTable("users")
+        .unique();
+      table.timestamps(false, true);
+    });
+  }
 }
-
-const DomainSchema: Schema = new Schema({
-  banned: { type: Boolean, default: false },
-  bannedBy: { type: Schema.Types.ObjectId, ref: 'user' },
-  createdAt: { type: Date, default: Date.now },
-  name: { type: String, unique: true, trim: true, required: true },
-  homepage: { type: String, trim: true },
-  updatedAt: { type: Date, default: Date.now },
-  user: { type: Schema.Types.ObjectId, ref: 'user' },
-});
-
-const Domain = model<IDomain>('domain', DomainSchema);
-
-export default Domain;

+ 21 - 21
server/models/host.ts

@@ -1,23 +1,23 @@
-import { Document, model, Schema, Types } from 'mongoose';
+import * as Knex from "knex";
 
-export interface IHost extends Document {
-  address: string;
-  banned?: boolean;
-  bannedBy?: Types.ObjectId;
-  createdAt?: Date;
-  updatedAt?: Date;
-  user?: Types.ObjectId;
+export async function createHostTable(knex: Knex) {
+  const hasTable = await knex.schema.hasTable("hosts");
+  if (!hasTable) {
+    await knex.schema.createTable("hosts", table => {
+      table.increments("id").primary();
+      table
+        .string("address")
+        .unique()
+        .notNullable();
+      table
+        .boolean("banned")
+        .notNullable()
+        .defaultTo(false);
+      table
+        .integer("banned_by_id")
+        .references("id")
+        .inTable("users");
+      table.timestamps(false, true);
+    });
+  }
 }
-
-const HostSchema: Schema = new Schema({
-  address: { type: String, unique: true, trim: true, required: true },
-  banned: { type: Boolean, default: false },
-  bannedBy: { type: Schema.Types.ObjectId, ref: 'user' },
-  createdAt: { type: Date, default: Date.now },
-  updatedAt: { type: Date, default: Date.now },
-  user: { type: Schema.Types.ObjectId, ref: 'user' },
-});
-
-const Host = model<IHost>('host', HostSchema);
-
-export default Host;

+ 14 - 16
server/models/ip.ts

@@ -1,17 +1,15 @@
-import { Document, model, Schema } from 'mongoose';
-
-export interface IIP extends Document {
-  createdAt?: Date;
-  updatedAt?: Date;
-  ip: string;
+import * as Knex from "knex";
+
+export async function createIPTable(knex: Knex) {
+  const hasTable = await knex.schema.hasTable("ips");
+  if (!hasTable) {
+    await knex.schema.createTable("ips", table => {
+      table.increments("id").primary();
+      table
+        .string("ip")
+        .unique()
+        .notNullable();
+      table.timestamps(false, true);
+    });
+  }
 }
-
-const IpSchema: Schema = new Schema({
-  createdAt: { type: Date, default: Date.now },
-  updatedAt: { type: Date, default: Date.now },
-  ip: { type: String, required: true, trim: true },
-});
-
-const IP = model<IIP>('ip', IpSchema);
-
-export default IP;

+ 33 - 30
server/models/link.ts

@@ -1,32 +1,35 @@
-import { Document, model, Schema, Types } from 'mongoose';
-import { IDomain } from './domain';
+import * as Knex from "knex";
 
-export interface ILink extends Document {
-  banned?: boolean;
-  bannedBy?: Types.ObjectId;
-  count?: number;
-  createdAt?: Date;
-  domain?: Types.ObjectId | IDomain;
-  id: string;
-  password?: string;
-  target: string;
-  updatedAt?: Date;
-  user?: Types.ObjectId;
-}
-
-const LinkSchema: Schema = new Schema({
-  banned: { type: Boolean, default: false },
-  bannedBy: { type: Schema.Types.ObjectId, ref: 'user' },
-  count: { type: Number, default: 0 },
-  createdAt: { type: Date, default: Date.now },
-  domain: { type: Schema.Types.ObjectId, ref: 'domain' },
-  id: { type: String, required: true, trim: true },
-  password: { type: String, trim: true },
-  target: { type: String, required: true },
-  updatedAt: { type: Date, default: Date.now },
-  user: { type: Schema.Types.ObjectId, ref: 'user' },
-});
+export async function createLinkTable(knex: Knex) {
+  const hasTable = await knex.schema.hasTable("links");
 
-const Link = model<ILink>('link', LinkSchema);
-
-export default Link;
+  if (!hasTable) {
+    await knex.schema.createTable("links", table => {
+      table.increments("id").primary();
+      table.string("address").notNullable();
+      table
+        .boolean("banned")
+        .notNullable()
+        .defaultTo(false);
+      table
+        .integer("banned_by_id")
+        .references("id")
+        .inTable("users");
+      table
+        .integer("domain_id")
+        .references("id")
+        .inTable("domains");
+      table.string("password");
+      table.string("target").notNullable();
+      table
+        .integer("user_id")
+        .references("id")
+        .inTable("users");
+      table
+        .integer("visit_count")
+        .notNullable()
+        .defaultTo(0);
+      table.timestamps(false, true);
+    });
+  }
+}

+ 32 - 45
server/models/user.ts

@@ -1,47 +1,34 @@
-import { Document, model, Schema, Types } from 'mongoose';
+import * as Knex from "knex";
 
-import { IDomain } from './domain';
-
-export interface IUser extends Document {
-  apikey?: string;
-  banned?: boolean;
-  bannedBy?: Types.ObjectId;
-  cooldowns?: Date[];
-  createdAt?: Date;
-  domain?: Types.ObjectId | IDomain;
-  email: string;
-  password: string;
-  resetPasswordExpires?: Date;
-  resetPasswordToken?: string;
-  updatedAt?: Date;
-  verificationExpires?: Date;
-  verificationToken?: string;
-  verified?: boolean;
+export async function createUserTable(knex: Knex) {
+  const hasTable = await knex.schema.hasTable("users");
+  if (!hasTable) {
+    await knex.schema.createTable("users", table => {
+      table.increments("id").primary();
+      table.string("apikey");
+      table
+        .boolean("banned")
+        .notNullable()
+        .defaultTo(false);
+      table
+        .integer("banned_by_id")
+        .references("id")
+        .inTable("users");
+      table.specificType("cooldowns", "timestamptz[]");
+      table
+        .string("email")
+        .unique()
+        .notNullable();
+      table.string("password").notNullable();
+      table.dateTime("reset_password_expires");
+      table.string("reset_password_token");
+      table.dateTime("verification_expires");
+      table.string("verification_token");
+      table
+        .boolean("verified")
+        .notNullable()
+        .defaultTo(false);
+      table.timestamps(false, true);
+    });
+  }
 }
-
-const UserSchema: Schema = new Schema({
-  apikey: { type: String, unique: true },
-  banned: { type: Boolean, default: false },
-  bannedBy: { type: Schema.Types.ObjectId, ref: 'user' },
-  cooldowns: [Date],
-  createdAt: { type: Date, default: Date.now },
-  domain: { type: Schema.Types.ObjectId, ref: 'domain' },
-  email: {
-    type: String,
-    required: true,
-    trim: true,
-    lowercase: true,
-    unique: true,
-  },
-  password: { type: String, required: true },
-  resetPasswordExpires: { type: Date },
-  resetPasswordToken: { type: String },
-  updatedAt: { type: Date, default: Date.now },
-  verificationExpires: { type: Date },
-  verificationToken: { type: String },
-  verified: { type: Boolean, default: false },
-});
-
-const User = model<IUser>('user', UserSchema);
-
-export default User;

+ 75 - 53
server/models/visit.ts

@@ -1,55 +1,77 @@
-import { Document, model, Schema, Types } from 'mongoose';
+import * as Knex from "knex";
 
-export interface IVisit extends Document {
-  browser: {
-    chrome: number;
-    edge: number;
-    firefox: number;
-    ie: number;
-    opera: number;
-    other: number;
-    safari: number;
-  };
-  country: Record<string, number>;
-  date: Date;
-  link: Types.ObjectId;
-  os: {
-    android: number;
-    ios: number;
-    linux: number;
-    macos: number;
-    other: number;
-    windows: number;
-  };
-  referrer: Record<string, number>;
-  total: number;
+export async function createVisitTable(knex: Knex) {
+  const hasTable = await knex.schema.hasTable("visits");
+  if (!hasTable) {
+    await knex.schema.createTable("visits", table => {
+      table.increments("id").primary();
+      table.jsonb("countries").defaultTo("{}");
+      table
+        .dateTime("created_at")
+        .notNullable()
+        .defaultTo(knex.fn.now());
+      table
+        .integer("link_id")
+        .references("id")
+        .inTable("links")
+        .notNullable();
+      table.jsonb("referrers").defaultTo("{}");
+      table
+        .integer("total")
+        .notNullable()
+        .defaultTo(0);
+      table
+        .integer("br_chrome")
+        .notNullable()
+        .defaultTo(0);
+      table
+        .integer("br_edge")
+        .notNullable()
+        .defaultTo(0);
+      table
+        .integer("br_firefox")
+        .notNullable()
+        .defaultTo(0);
+      table
+        .integer("br_ie")
+        .notNullable()
+        .defaultTo(0);
+      table
+        .integer("br_opera")
+        .notNullable()
+        .defaultTo(0);
+      table
+        .integer("br_other")
+        .notNullable()
+        .defaultTo(0);
+      table
+        .integer("br_safari")
+        .notNullable()
+        .defaultTo(0);
+      table
+        .integer("os_android")
+        .notNullable()
+        .defaultTo(0);
+      table
+        .integer("os_ios")
+        .notNullable()
+        .defaultTo(0);
+      table
+        .integer("os_linux")
+        .notNullable()
+        .defaultTo(0);
+      table
+        .integer("os_macos")
+        .notNullable()
+        .defaultTo(0);
+      table
+        .integer("os_other")
+        .notNullable()
+        .defaultTo(0);
+      table
+        .integer("os_windows")
+        .notNullable()
+        .defaultTo(0);
+    });
+  }
 }
-
-const VisitSchema: Schema = new Schema({
-  browser: {
-    chrome: { type: Number, default: 0 },
-    edge: { type: Number, default: 0 },
-    firefox: { type: Number, default: 0 },
-    ie: { type: Number, default: 0 },
-    opera: { type: Number, default: 0 },
-    other: { type: Number, default: 0 },
-    safari: { type: Number, default: 0 },
-  },
-  country: Schema.Types.Mixed,
-  date: { type: Date },
-  link: { type: Schema.Types.ObjectId, ref: 'link' },
-  os: {
-    android: { type: Number, default: 0 },
-    ios: { type: Number, default: 0 },
-    linux: { type: Number, default: 0 },
-    macos: { type: Number, default: 0 },
-    other: { type: Number, default: 0 },
-    windows: { type: Number, default: 0 },
-  },
-  referrer: Schema.Types.Mixed,
-  total: { type: Number, default: 0 },
-});
-
-const Visit = model<IVisit>('visit', VisitSchema);
-
-export default Visit;

+ 0 - 8
server/module.d.ts

@@ -1,8 +0,0 @@
-declare namespace Express {
-  interface Request {
-    realIP?: string;
-    pageType?: string;
-    linkTarget?: string;
-    protectedLink?: string;
-  }
-}

+ 11 - 11
server/passport.ts

@@ -1,14 +1,14 @@
-import passport  from 'passport';
-import { Strategy as JwtStrategy, ExtractJwt }  from 'passport-jwt';
-import { Strategy as LocalStratergy }  from 'passport-local';
-import { Strategy as LocalAPIKeyStrategy }  from 'passport-localapikey-update';
-import bcrypt  from 'bcryptjs';
+import passport from "passport";
+import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt";
+import { Strategy as LocalStratergy } from "passport-local";
+import { Strategy as LocalAPIKeyStrategy } from "passport-localapikey-update";
+import bcrypt from "bcryptjs";
 
-import { getUser }  from './db/user';
+import { getUser } from "./db/user";
 
 const jwtOptions = {
-  jwtFromRequest: ExtractJwt.fromHeader('authorization'),
-  secretOrKey: process.env.JWT_SECRET,
+  jwtFromRequest: ExtractJwt.fromHeader("authorization"),
+  secretOrKey: process.env.JWT_SECRET
 };
 
 passport.use(
@@ -24,7 +24,7 @@ passport.use(
 );
 
 const localOptions = {
-  usernameField: 'email',
+  usernameField: "email"
 };
 
 passport.use(
@@ -46,8 +46,8 @@ passport.use(
 );
 
 const localAPIKeyOptions = {
-  apiKeyField: 'apikey',
-  apiKeyHeader: 'x-api-key',
+  apiKeyField: "apikey",
+  apiKeyHeader: "x-api-key"
 };
 
 passport.use(

+ 5 - 5
server/redis.ts

@@ -1,14 +1,14 @@
-import { promisify } from 'util';
-import redis from 'redis';
+import { promisify } from "util";
+import redis from "redis";
 
-const disabled = process.env.REDIS_DISABLED === 'true';
+const disabled = process.env.REDIS_DISABLED === "true";
 
 const client =
   !disabled &&
   redis.createClient({
-    host: process.env.REDIS_HOST || '127.0.0.1',
+    host: process.env.REDIS_HOST || "127.0.0.1",
     port: Number(process.env.REDIS_PORT) || 6379,
-    ...(process.env.REDIS_PASSWORD && { password: process.env.REDIS_PASSWORD }),
+    ...(process.env.REDIS_PASSWORD && { password: process.env.REDIS_PASSWORD })
   });
 
 const defaultResolver: () => Promise<null> = () => Promise.resolve(null);

+ 107 - 88
server/server.ts

@@ -1,63 +1,51 @@
-import './configToEnv';
+import "./configToEnv";
 
-import dotenv from 'dotenv';
+import dotenv from "dotenv";
 dotenv.config();
 
-import nextApp from 'next';
-import express, { Request, Response } from 'express';
-import mongoose from 'mongoose';
-import helmet from 'helmet';
-import morgan from 'morgan';
-import Raven from 'raven';
-import cookieParser from 'cookie-parser';
-import passport from 'passport';
-import cors from 'cors';
+import nextApp from "next";
+import express, { Request, Response } from "express";
+import helmet from "helmet";
+import morgan from "morgan";
+import Raven from "raven";
+import cookieParser from "cookie-parser";
+import passport from "passport";
+import cors from "cors";
 
 import {
   validateBody,
   validationCriterias,
   validateUrl,
-  ipCooldownCheck,
-} from './controllers/validateBodyController';
-import * as auth from './controllers/authController';
-import * as link from './controllers/linkController';
+  ipCooldownCheck
+} from "./controllers/validateBodyController";
+import * as auth from "./controllers/authController";
+import * as link from "./controllers/linkController";
+import { initializeDb } from "./knex";
 
-import './cron';
-import './passport';
-
-mongoose.connect(process.env.MONGODB_URI, { useNewUrlParser: true });
+import "./cron";
+import "./passport";
 
 if (process.env.RAVEN_DSN) {
   Raven.config(process.env.RAVEN_DSN).install();
 }
 
 const catchErrors = fn => (req, res, next) =>
-  fn(req, res, next).catch(err => {
-    res
-      .status(500)
-      .json({ error: 'Sorry an error ocurred. Please try again later.' });
-    if (process.env.RAVEN_DSN) {
-      Raven.captureException(err, {
-        user: { email: req.user && req.user.email },
-      });
-      throw new Error(err);
-    } else {
-      throw new Error(err);
-    }
-  });
+  Promise.resolve(fn(req, res, next)).catch(next);
 
 const port = Number(process.env.PORT) || 3000;
-const dev = process.env.NODE_ENV !== 'production';
-const app = nextApp({ dir: './client', dev });
+const dev = process.env.NODE_ENV !== "production";
+const app = nextApp({ dir: "./client", dev });
 const handle = app.getRequestHandler();
 
-app.prepare().then(() => {
+app.prepare().then(async () => {
+  await initializeDb();
+
   const server = express();
 
-  server.set('trust proxy', true);
+  server.set("trust proxy", true);
 
-  if (process.env.NODE_ENV !== 'production') {
-    server.use(morgan('dev'));
+  if (process.env.NODE_ENV !== "production") {
+    server.use(morgan("dev"));
   }
 
   server.use(helmet());
@@ -65,128 +53,159 @@ app.prepare().then(() => {
   server.use(express.json());
   server.use(express.urlencoded({ extended: true }));
   server.use(passport.initialize());
-  server.use(express.static('static'));
+  server.use(express.static("static"));
+
+  server.use((error, req, res, next) => {
+    console.log({ error });
+    res
+      .status(500)
+      .json({ error: "Sorry an error ocurred. Please try again later." });
+    if (process.env.RAVEN_DSN) {
+      Raven.captureException(error, {
+        user: { email: req.user && req.user.email }
+      });
+    }
+    next();
+  });
 
   server.use((req, _res, next) => {
     req.realIP =
-      (req.headers['x-real-ip'] as string) ||
+      (req.headers["x-real-ip"] as string) ||
       req.connection.remoteAddress ||
-      '';
+      "";
     return next();
   });
 
   server.use(link.customDomainRedirection);
 
+  server.get("/", (req, res) => app.render(req, res, "/"));
+  server.get("/login", (req, res) => app.render(req, res, "/login"));
+  server.get("/logout", (req, res) => app.render(req, res, "/logout"));
+  server.get("/settings", (req, res) => app.render(req, res, "/settings"));
+  server.get("/stats", (req, res) => app.render(req, res, "/stats", req.query));
+  server.get("/terms", (req, res) => app.render(req, res, "/terms"));
+  server.get("/report", (req, res) => app.render(req, res, "/report"));
+  server.get("/banned", (req, res) => app.render(req, res, "/banned"));
+  server.get("/offline", (req, res) => app.render(req, res, "/offline"));
+
   /* View routes */
   server.get(
-    '/reset-password/:resetPasswordToken?',
+    "/reset-password/:resetPasswordToken?",
     catchErrors(auth.resetUserPassword),
-    (req, res) => app.render(req, res, '/reset-password', req.user)
+    (req, res) => app.render(req, res, "/reset-password", { token: req.token })
   );
   server.get(
-    '/verify/:verificationToken?',
+    "/verify/:verificationToken?",
     catchErrors(auth.verify),
-    (req, res) => app.render(req, res, '/verify', req.user)
+    (req, res) => app.render(req, res, "/verify", { token: req.token })
   );
 
   /* User and authentication */
   server.post(
-    '/api/auth/signup',
+    "/api/auth/signup",
     validationCriterias,
-    validateBody,
+    catchErrors(validateBody),
     catchErrors(auth.signup)
   );
   server.post(
-    '/api/auth/login',
+    "/api/auth/login",
     validationCriterias,
-    validateBody,
-    auth.authLocal,
-    auth.login
+    catchErrors(validateBody),
+    catchErrors(auth.authLocal),
+    catchErrors(auth.login)
+  );
+  server.post(
+    "/api/auth/renew",
+    catchErrors(auth.authJwt),
+    catchErrors(auth.renew)
   );
-  server.post('/api/auth/renew', auth.authJwt, auth.renew);
   server.post(
-    '/api/auth/changepassword',
-    auth.authJwt,
+    "/api/auth/changepassword",
+    catchErrors(auth.authJwt),
     catchErrors(auth.changeUserPassword)
   );
   server.post(
-    '/api/auth/generateapikey',
-    auth.authJwt,
+    "/api/auth/generateapikey",
+    catchErrors(auth.authJwt),
     catchErrors(auth.generateUserApiKey)
   );
   server.post(
-    '/api/auth/resetpassword',
+    "/api/auth/resetpassword",
     catchErrors(auth.requestUserPasswordReset)
   );
-  server.get('/api/auth/usersettings', auth.authJwt, auth.userSettings);
+  server.get(
+    "/api/auth/usersettings",
+    catchErrors(auth.authJwt),
+    catchErrors(auth.userSettings)
+  );
 
   /* URL shortener */
   server.post(
-    '/api/url/submit',
+    "/api/url/submit",
     cors(),
-    auth.authApikey,
-    auth.authJwtLoose,
+    catchErrors(auth.authApikey),
+    catchErrors(auth.authJwtLoose),
     catchErrors(auth.recaptcha),
     catchErrors(validateUrl),
     catchErrors(ipCooldownCheck),
     catchErrors(link.shortener)
   );
   server.post(
-    '/api/url/deleteurl',
-    auth.authApikey,
-    auth.authJwt,
+    "/api/url/deleteurl",
+    catchErrors(auth.authApikey),
+    catchErrors(auth.authJwt),
     catchErrors(link.deleteUserLink)
   );
   server.get(
-    '/api/url/geturls',
-    auth.authApikey,
-    auth.authJwt,
+    "/api/url/geturls",
+    catchErrors(auth.authApikey),
+    catchErrors(auth.authJwt),
     catchErrors(link.getUserLinks)
   );
   server.post(
-    '/api/url/customdomain',
-    auth.authJwt,
+    "/api/url/customdomain",
+    catchErrors(auth.authJwt),
     catchErrors(link.setCustomDomain)
   );
   server.delete(
-    '/api/url/customdomain',
-    auth.authJwt,
+    "/api/url/customdomain",
+    catchErrors(auth.authJwt),
     catchErrors(link.deleteCustomDomain)
   );
   server.get(
-    '/api/url/stats',
-    auth.authApikey,
-    auth.authJwt,
+    "/api/url/stats",
+    catchErrors(auth.authApikey),
+    catchErrors(auth.authJwt),
     catchErrors(link.getLinkStats)
   );
-  server.post('/api/url/requesturl', catchErrors(link.goToLink));
-  server.post('/api/url/report', catchErrors(link.reportLink));
+  server.post("/api/url/requesturl", catchErrors(link.goToLink));
+  server.post("/api/url/report", catchErrors(link.reportLink));
   server.post(
-    '/api/url/admin/ban',
-    auth.authApikey,
-    auth.authJwt,
-    auth.authAdmin,
+    "/api/url/admin/ban",
+    catchErrors(auth.authApikey),
+    catchErrors(auth.authJwt),
+    catchErrors(auth.authAdmin),
     catchErrors(link.ban)
   );
   server.get(
-    '/:id',
+    "/:id",
     catchErrors(link.goToLink),
     (req: Request, res: Response) => {
       switch (req.pageType) {
-        case 'password':
-          return app.render(req, res, '/url-password', {
-            protectedLink: req.protectedLink,
+        case "password":
+          return app.render(req, res, "/url-password", {
+            protectedLink: req.protectedLink
           });
-        case 'info':
+        case "info":
         default:
-          return app.render(req, res, '/url-info', {
-            linkTarget: req.linkTarget,
+          return app.render(req, res, "/url-info", {
+            linkTarget: req.linkTarget
           });
       }
     }
   );
 
-  server.get('*', (req, res) => handle(req, res));
+  server.get("*", (req, res) => handle(req, res));
 
   server.listen(port, err => {
     if (err) throw err;

+ 32 - 26
server/utils/index.ts

@@ -1,9 +1,9 @@
-import ms from 'ms';
+import ms from "ms";
 import {
   differenceInDays,
   differenceInHours,
-  differenceInMonths,
-} from 'date-fns';
+  differenceInMonths
+} from "date-fns";
 
 export const addProtocol = (url: string): string => {
   const hasProtocol = /^\w+:\/\//.test(url);
@@ -12,17 +12,25 @@ export const addProtocol = (url: string): string => {
 
 export const generateShortLink = (id: string, domain?: string): string => {
   const protocol =
-    process.env.CUSTOM_DOMAIN_USE_HTTPS === 'true' || !domain
-      ? 'https://'
-      : 'http://';
+    process.env.CUSTOM_DOMAIN_USE_HTTPS === "true" || !domain
+      ? "https://"
+      : "http://";
   return `${protocol}${domain || process.env.DEFAULT_DOMAIN}/${id}`;
 };
 
 export const isAdmin = (email: string): boolean =>
-  process.env.ADMIN_EMAILS.split(',')
+  process.env.ADMIN_EMAILS.split(",")
     .map(e => e.trim())
     .includes(email);
 
+export const getRedisKey = {
+  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 =>
   Number(process.env.DEFAULT_MAX_STATS_PER_LINK) || 100000000;
@@ -31,50 +39,48 @@ export const getStatsCacheTime = (total?: number): number => {
   let durationInMs;
   switch (true) {
     case total <= 5000:
-      durationInMs = ms('5 minutes');
+      durationInMs = ms("5 minutes");
       break;
     case total > 5000 && total < 20000:
-      durationInMs = ms('10 minutes');
+      durationInMs = ms("10 minutes");
       break;
     case total < 40000:
-      durationInMs = ms('15 minutes');
+      durationInMs = ms("15 minutes");
       break;
     case total > 40000:
-      durationInMs = ms('30 minutes');
+      durationInMs = ms("30 minutes");
       break;
     default:
-      durationInMs = ms('5 minutes');
+      durationInMs = ms("5 minutes");
   }
   return durationInMs / 1000;
 };
 
-export const statsObjectToArray = (
-  obj: Record<string, Record<string, number>>
-) => {
+export const statsObjectToArray = (obj: Stats) => {
   const objToArr = key =>
     Array.from(Object.keys(obj[key]))
       .map(name => ({
         name,
-        value: obj[key][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'),
+    browser: objToArr("browser"),
+    os: objToArr("os"),
+    country: objToArr("country"),
+    referrer: objToArr("referrer")
   };
 };
 
 export const getDifferenceFunction = (
-  type: 'lastDay' | 'lastWeek' | 'lastMonth' | 'allTime'
+  type: "lastDay" | "lastWeek" | "lastMonth" | "allTime"
 ): Function => {
-  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.');
+  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) => {

+ 1 - 1
tsconfig.server.json

@@ -12,5 +12,5 @@
     "experimentalDecorators": true,		
 		"strict": false
   },
-  "include": ["server"]
+	"include": ["global.d.ts", "server"]
 }

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików