Browse Source

Merge pull request #657 from thedevs-network/chore/update-deps

Update dependencies and delete v1 API
Pouria Ezzati 3 years ago
parent
commit
041aed5ad6
98 changed files with 5177 additions and 7794 deletions
  1. 0 10
      .babelrc
  2. 0 15
      .docker.env
  3. 8 26
      .eslintrc
  4. 0 15
      .example.env
  5. 4 0
      .husky/pre-commit
  6. 0 44
      MIGRATION.md
  7. 0 6
      README.md
  8. 19 4
      client/components/ALink.tsx
  9. 2 2
      client/components/Animation.ts
  10. 7 8
      client/components/AppWrapper.tsx
  11. 1 1
      client/components/Button.tsx
  12. 1 1
      client/components/Divider.tsx
  13. 2 2
      client/components/Extensions.tsx
  14. 2 3
      client/components/Features.tsx
  15. 3 2
      client/components/FeaturesItem.tsx
  16. 4 4
      client/components/Footer.tsx
  17. 57 45
      client/components/Header.tsx
  18. 1 1
      client/components/Icon/Icon.tsx
  19. 10 7
      client/components/Input.tsx
  20. 8 8
      client/components/Layout.tsx
  21. 27 25
      client/components/LinksTable.tsx
  22. 2 2
      client/components/Modal.tsx
  23. 4 6
      client/components/NeedToLogin.tsx
  24. 1 1
      client/components/PageLoading.tsx
  25. 1 1
      client/components/ReCaptcha.tsx
  26. 4 4
      client/components/Settings/SettingsApi.tsx
  27. 2 2
      client/components/Settings/SettingsChangeEmail.tsx
  28. 10 7
      client/components/Settings/SettingsDomain.tsx
  29. 3 3
      client/components/Settings/SettingsPassword.tsx
  30. 18 19
      client/components/Shortener.tsx
  31. 1 1
      client/components/Table.ts
  32. 8 8
      client/components/Text.tsx
  33. 0 52
      client/components/__tests__/footer.test.tsx
  34. 0 59
      client/components/__tests__/shortener.test.tsx
  35. 0 5
      client/consts/consts.ts
  36. 0 40
      client/helpers/analytics.ts
  37. 4 1
      client/next-env.d.ts
  38. 0 15
      client/pages/_app.tsx
  39. 6 5
      client/pages/_document.tsx
  40. 3 4
      client/pages/banned.tsx
  41. 16 18
      client/pages/login.tsx
  42. 3 3
      client/pages/logout.tsx
  43. 2 2
      client/pages/protected/[id].tsx
  44. 2 2
      client/pages/report.tsx
  45. 7 7
      client/pages/reset-password.tsx
  46. 11 17
      client/pages/stats.tsx
  47. 4 4
      client/pages/verify-email.tsx
  48. 15 20
      client/pages/verify.tsx
  49. 1 1
      client/store/auth.ts
  50. 1 1
      client/store/links.ts
  51. 16 4
      client/tsconfig.json
  52. 1 1
      client/utils.ts
  53. 6 1
      global.d.ts
  54. 0 13
      jest.config.js
  55. 1 3
      next.config.js
  56. 4655 4824
      package-lock.json
  57. 99 120
      package.json
  58. 0 404
      server/__v1/controllers/linkController.ts
  59. 0 223
      server/__v1/controllers/validateBodyController.ts
  60. 0 114
      server/__v1/db/domain.ts
  61. 0 51
      server/__v1/db/host.ts
  62. 0 47
      server/__v1/db/ip.ts
  63. 0 519
      server/__v1/db/link.ts
  64. 0 207
      server/__v1/db/user.ts
  65. 0 64
      server/__v1/index.ts
  66. 4 8
      server/env.ts
  67. 1 1
      server/handlers/auth.ts
  68. 28 12
      server/handlers/helpers.ts
  69. 4 28
      server/handlers/links.ts
  70. 1 1
      server/handlers/validators.ts
  71. 0 68
      server/migration/01_host.ts
  72. 0 86
      server/migration/02_users.ts
  73. 0 89
      server/migration/03_domains.ts
  74. 0 196
      server/migration/04_links.ts
  75. 0 62
      server/migration/neo4j_delete_duplicated.ts
  76. 2 2
      server/migrations/20200211220920_constraints.ts
  77. 2 2
      server/migrations/20200510140704_domains.ts
  78. 1 1
      server/migrations/20200718124944_description.ts
  79. 1 1
      server/migrations/20200730203154_expire_in.ts
  80. 1 1
      server/migrations/20200810195255_change_email.ts
  81. 1 1
      server/models/domain.ts
  82. 1 1
      server/models/host.ts
  83. 1 1
      server/models/ip.ts
  84. 1 1
      server/models/link.ts
  85. 1 1
      server/models/user.ts
  86. 1 1
      server/models/visit.ts
  87. 3 3
      server/queries/domain.ts
  88. 3 3
      server/queries/host.ts
  89. 3 1
      server/queries/index.ts
  90. 3 3
      server/queries/link.ts
  91. 7 11
      server/queries/user.ts
  92. 5 7
      server/queries/visit.ts
  93. 3 1
      server/queues/index.ts
  94. 3 3
      server/queues/visit.ts
  95. 10 22
      server/redis.ts
  96. 2 18
      server/server.ts
  97. 6 6
      server/utils/index.ts
  98. 15 18
      tsconfig.json

+ 0 - 10
.babelrc

@@ -1,10 +0,0 @@
-{
-  "presets": ["next/babel"],
-  "plugins": [
-    [
-      "styled-components",
-      { "ssr": true, "displayName": true, "preprocess": false }
-    ],
-    "inline-react-svg"
-  ]
-}

+ 0 - 15
.docker.env

@@ -18,12 +18,6 @@ DB_USER=
 DB_PASSWORD=
 DB_SSL=false
 
-# ONLY NEEDED FOR MIGRATION !!1!
-# Neo4j database credential details
-NEO4J_DB_URI=bolt://localhost
-NEO4J_DB_USERNAME=neo4j
-NEO4J_DB_PASSWORD=pass
-
 # Redis host and port
 REDIS_HOST=redis
 REDIS_PORT=6379
@@ -65,15 +59,6 @@ RECAPTCHA_SECRET_KEY=
 # Get it from https://developers.google.com/safe-browsing/v4/get-started
 GOOGLE_SAFE_BROWSING_KEY=
 
-# Google Analytics tracking ID for universal analytics.
-# Example: UA-XXXX-XX
-GOOGLE_ANALYTICS=
-GOOGLE_ANALYTICS_UNIVERSAL=
-
-# Google Analytics tracking ID for universal analytics
-# This one is used for links
-# GOOGLE_ANALYTICS_UNIVERSAL=
-
 # Your email host details to use to send verification emails.
 # More info on http://nodemailer.com/
 # Mail from example "Kutt <support@kutt.it>". Leave empty to use MAIL_USER

+ 8 - 26
.eslintrc

@@ -1,40 +1,22 @@
 {
   "extends": [
-    "eslint:recommended",
-    "plugin:@typescript-eslint/eslint-recommended",
-    "plugin:prettier/recommended"
+    "next/core-web-vitals",
+    "plugin:@typescript-eslint/recommended",
+    "prettier"
   ],
-  "parser": "@typescript-eslint/parser",
   "parserOptions": {
+    "ecmaVersion": "latest",
+    "sourceType": "module",
     "project": ["./tsconfig.json", "./client/tsconfig.json"]
   },
-  "plugins": ["@typescript-eslint"],
+  "plugins": ["@typescript-eslint", "prettier"],
   "rules": {
-    "eqeqeq": ["warn", "always", { "null": "ignore" }],
-    "no-useless-return": "warn",
-    "no-var": "warn",
-    "no-console": "warn",
-    "no-unused-vars": "off",
-    "max-len": ["warn", { "comments": 80 }],
-    "no-param-reassign": 0,
-    "require-atomic-updates": 0,
-    "@typescript-eslint/interface-name-prefix": "off",
-    "@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"
+    "@typescript-eslint/no-explicit-any": ["off"]
   },
   "env": {
     "es6": true,
     "browser": true,
-    "node": true,
-    "mocha": true
-  },
-  "globals": {
-    "assert": true
+    "node": true
   },
   "settings": {
     "react": {

+ 0 - 15
.example.env

@@ -18,12 +18,6 @@ DB_USER=
 DB_PASSWORD=
 DB_SSL=false
 
-# ONLY NEEDED FOR MIGRATION !!1!
-# Neo4j database credential details
-NEO4J_DB_URI=bolt://localhost
-NEO4J_DB_USERNAME=neo4j
-NEO4J_DB_PASSWORD=pass
-
 # Redis host and port
 REDIS_HOST=127.0.0.1
 REDIS_PORT=6379
@@ -68,15 +62,6 @@ RECAPTCHA_SECRET_KEY=
 # Get it from https://developers.google.com/safe-browsing/v4/get-started
 GOOGLE_SAFE_BROWSING_KEY=
 
-# Google Analytics tracking ID for universal analytics.
-# Example: UA-XXXX-XX
-GOOGLE_ANALYTICS=
-GOOGLE_ANALYTICS_UNIVERSAL=
-
-# Google Analytics tracking ID for universal analytics
-# This one is used for links
-# GOOGLE_ANALYTICS_UNIVERSAL=
-
 # Your email host details to use to send verification emails.
 # More info on http://nodemailer.com/
 # Mail from example "Kutt <support@kutt.it>". Leave empty to use MAIL_USER

+ 4 - 0
.husky/pre-commit

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

+ 0 - 44
MIGRATION.md

@@ -1,44 +0,0 @@
-# Migrate database from Neo4j to Postgres
-
-As explained in issue #197, Kutt is ditching Neo4j in favor of Postgres in version 2. But what happens to old data? Well, I have created migration scripts that you can use to transfer data from your Neo4j database to your new Postgres database.
-
-### 🚧 IMPORTANT: v2 is still in beta (but somehow more stable than v1)
-
-## General recommendations
-
-- Importing Neo4j data into local Neo4j database and migrate from there would speed things up.
-- Use local Postgres database (where app lives), because using a remote database server will be way slower. If you're doing this locally, you can import data from local database to the remote one after migration has finished. I used this command to move data:
-
-## 1. Set up a Postgres database
-
-Set up a Postgres database, either on your own server or using a SaaS service.
-
-## 2. Pull and run Kutt's new version
-
-Right now version 2 is in beta. Therefore, pull from `develop` branch and create and fill the `.env` file based on `.example.env`.
-
-**NOTE**: Run the app at least once and let it create and initialize tables in the database. You just need to do `npm run dev` and wait for it to create tables. Then check your database to make sure tables have been created. (If your production database is separate, you need to initialize it too).
-
-## 3. Migrate data using scripts
-
-First, do `npm run build` to build the files. Now if you check `production-server/migration` folder you will fine 4 files. You can now run these scripts one by one.
-
-**NOTE:** that the order of running the scripts is important.
-
-**NOTE:** Step 4 is going to take a good chunk of time.
-
-**NOTE:** If step 4 fails at any stage, you should delete links and visits data from the database and try again.
-
-```
-// 1. Migrate data: Hosts
-node production-server/migration/01_hosts.js
-
-// 2. Migrate data: Users
-node production-server/migration/02_users.js
-
-// 3. Migrate data: Domains
-node production-server/migration/03_domains.js
-
-// 4. Migrate data: Links
-node production-server/migration/04_links.js
-```

+ 0 - 6
README.md

@@ -13,12 +13,6 @@ _Contributions and bug reports are welcome._
 [![GitHub license](https://img.shields.io/github/license/thedevs-network/kutt.svg)](https://github.com/thedevs-network/kutt/blob/develop/LICENSE)
 [![Twitter](https://img.shields.io/twitter/url/https/github.com/thedevs-network/kutt/.svg?style=social)](https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgithub.com%2Fthedevs-network%2Fkutt%2F)
 
-## Migration from v1
-
-The new version of Kutt is here. In version 2, we used TypeScript and we moved from Neo4j to PostgreSQL database in favor of performance and we're working on adding new features.
-
-If you're coming from v1, refer to [MIGRATION.md](MIGRATION.md) to migrate data from Neo4j to PostgreSQL.
-
 ## Table of Contents
 
 - [Key Features](#key-features)

+ 19 - 4
client/components/ALink.tsx

@@ -1,6 +1,8 @@
-import { Box, BoxProps } from "reflexbox/styled-components";
+import { FC } from "react";
+import { Box, BoxProps } from "rebass/styled-components";
 import styled, { css } from "styled-components";
 import { ifProp } from "styled-tools";
+import Link from "next/link";
 
 interface Props extends BoxProps {
   href?: string;
@@ -8,10 +10,9 @@ interface Props extends BoxProps {
   target?: string;
   rel?: string;
   forButton?: boolean;
+  isNextLink?: boolean;
 }
-const ALink = styled(Box).attrs({
-  as: "a"
-})<Props>`
+const StyledBox = styled(Box)<Props>`
   cursor: pointer;
   color: #2196f3;
   border-bottom: 1px dotted transparent;
@@ -28,6 +29,20 @@ const ALink = styled(Box).attrs({
   )}
 `;
 
+export const ALink: FC<Props> = (props) => {
+  if (props.isNextLink) {
+    const { href, target, title, rel, ...rest } = props;
+    return (
+      <Link href={href} target={target} title={title} rel={rel} passHref>
+        <StyledBox as="a" {...rest} />
+      </Link>
+    );
+  }
+  return <StyledBox as="a" {...props} />;
+};
+
+ALink.displayName = "ALink";
+
 ALink.defaultProps = {
   pb: "1px",
   forButton: false

+ 2 - 2
client/components/Animation.ts

@@ -1,5 +1,5 @@
 import { fadeInVertical } from "../helpers/animations";
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import styled from "styled-components";
 import { prop } from "styled-tools";
 import { FC } from "react";
@@ -10,7 +10,7 @@ interface Props extends React.ComponentProps<typeof Flex> {
 }
 
 const Animation: FC<Props> = styled(Flex)<Props>`
-  animation: ${props => fadeInVertical(props.offset)}
+  animation: ${(props) => fadeInVertical(props.offset)}
     ${prop("duration", "0.3s")} ease-out;
 `;
 

+ 7 - 8
client/components/AppWrapper.tsx

@@ -1,7 +1,6 @@
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import React, { useEffect } from "react";
 import styled from "styled-components";
-import Router from "next/router";
 
 import { useStoreState, useStoreActions } from "../store";
 import PageLoading from "./PageLoading";
@@ -22,11 +21,11 @@ const Wrapper = styled(Flex)`
 `;
 
 const AppWrapper = ({ children }: { children: any }) => {
-  const isAuthenticated = useStoreState(s => s.auth.isAuthenticated);
-  const logout = useStoreActions(s => s.auth.logout);
-  const fetched = useStoreState(s => s.settings.fetched);
-  const loading = useStoreState(s => s.loading.loading);
-  const getSettings = useStoreActions(s => s.settings.getSettings);
+  const isAuthenticated = useStoreState((s) => s.auth.isAuthenticated);
+  const logout = useStoreActions((s) => s.auth.logout);
+  const fetched = useStoreState((s) => s.settings.fetched);
+  const loading = useStoreState((s) => s.loading.loading);
+  const getSettings = useStoreActions((s) => s.settings.getSettings);
 
   const isVerifyEmailPage =
     typeof window !== "undefined" &&
@@ -36,7 +35,7 @@ const AppWrapper = ({ children }: { children: any }) => {
     if (isAuthenticated && !fetched && !isVerifyEmailPage) {
       getSettings().catch(() => logout());
     }
-  }, [isVerifyEmailPage]);
+  }, [isAuthenticated, fetched, isVerifyEmailPage, getSettings, logout]);
 
   return (
     <Wrapper

+ 1 - 1
client/components/Button.tsx

@@ -1,6 +1,6 @@
 import styled, { css } from "styled-components";
 import { switchProp, prop, ifProp } from "styled-tools";
-import { Flex, BoxProps } from "reflexbox/styled-components";
+import { Flex, BoxProps } from "rebass/styled-components";
 
 interface Props extends BoxProps {
   color?: "purple" | "gray" | "blue" | "red";

+ 1 - 1
client/components/Divider.tsx

@@ -1,4 +1,4 @@
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import styled from "styled-components";
 
 import { Colors } from "../consts";

+ 2 - 2
client/components/Extensions.tsx

@@ -1,6 +1,6 @@
 import React from "react";
 import styled from "styled-components";
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import SVG from "react-inlinesvg"; // TODO: another solution
 import { Colors } from "../consts";
 import { ColCenterH } from "./Layout";
@@ -62,7 +62,7 @@ const Icon = styled(SVG)`
   width: 18px;
   height: 18px;
   margin-right: 16px;
-  fill: ${props => props.color || "#333"};
+  fill: ${(props) => props.color || "#333"};
 
   @media only screen and (max-width: 768px) {
     width: 13px;

+ 2 - 3
client/components/Features.tsx

@@ -1,11 +1,10 @@
 import React from "react";
-import styled from "styled-components";
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 
 import FeaturesItem from "./FeaturesItem";
 import { ColCenterH } from "./Layout";
 import { Colors } from "../consts";
-import Text, { H3 } from "./Text";
+import { H3 } from "./Text";
 
 const Features = () => (
   <ColCenterH

+ 3 - 2
client/components/FeaturesItem.tsx

@@ -1,6 +1,6 @@
-import React, { FC } from "react";
+import React, { FC, ReactNode } from "react";
 import styled from "styled-components";
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 
 import { fadeIn } from "../helpers/animations";
 import Icon from "./Icon";
@@ -9,6 +9,7 @@ import { Icons } from "./Icon/Icon";
 interface Props {
   title: string;
   icon: Icons;
+  children?: ReactNode;
 }
 
 const Block = styled(Flex).attrs({

+ 4 - 4
client/components/Footer.tsx

@@ -11,7 +11,7 @@ import Text from "./Text";
 const { publicRuntimeConfig } = getConfig();
 
 const Footer: FC = () => {
-  const { isAuthenticated } = useStoreState(s => s.auth);
+  const { isAuthenticated } = useStoreState((s) => s.auth);
 
   useEffect(() => {
     showRecaptcha();
@@ -27,7 +27,7 @@ const Footer: FC = () => {
       {!isAuthenticated && <ReCaptcha />}
       <Text fontSize={[12, 13]} py={2}>
         Made with love by{" "}
-        <ALink href="//thedevs.network/" title="The Devs">
+        <ALink href="//thedevs.network/" title="The Devs" target="_blank">
           The Devs
         </ALink>
         .{" | "}
@@ -39,11 +39,11 @@ const Footer: FC = () => {
           GitHub
         </ALink>
         {" | "}
-        <ALink href="/terms" title="Terms of Service">
+        <ALink href="/terms" title="Terms of Service" isNextLink>
           Terms of Service
         </ALink>
         {" | "}
-        <ALink href="/report" title="Report abuse">
+        <ALink href="/report" title="Report abuse" isNextLink>
           Report Abuse
         </ALink>
         {publicRuntimeConfig.CONTACT_EMAIL && (

+ 57 - 45
client/components/Header.tsx

@@ -1,9 +1,9 @@
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import getConfig from "next/config";
 import React, { FC } from "react";
 import Router from "next/router";
 import useMedia from "use-media";
-import Link from "next/link";
+import Image from "next/image";
 
 import { DISALLOW_REGISTRATION } from "../consts";
 import { useStoreState } from "../store";
@@ -35,6 +35,7 @@ const LogoImage = styled.div`
     text-decoration: none;
     color: inherit;
     transition: border-color 0.2s ease-out;
+    padding: 0;
   }
 
   @media only screen and (max-width: 488px) {
@@ -43,47 +44,41 @@ const LogoImage = styled.div`
     }
   }
 
-  img {
-    width: 18px;
-    margin-right: 11px;
+  span {
+    margin-right: 10px !important;
   }
 `;
 
 const Header: FC = () => {
-  const { isAuthenticated } = useStoreState(s => s.auth);
+  const { isAuthenticated } = useStoreState((s) => s.auth);
   const isMobile = useMedia({ maxWidth: 640 });
 
   const login = !isAuthenticated && (
     <Li>
-      <Link href="/login">
-        <ALink
-          href="/login"
-          title={!DISALLOW_REGISTRATION ? "login / signup" : "login"}
-          forButton
-        >
-          <Button height={[32, 40]}>
-            {!DISALLOW_REGISTRATION ? "Log in / Sign up" : "Log in"}
-          </Button>
-        </ALink>
-      </Link>
+      <ALink
+        href="/login"
+        title={!DISALLOW_REGISTRATION ? "login / signup" : "login"}
+        forButton
+        isNextLink
+      >
+        <Button height={[32, 40]}>
+          {!DISALLOW_REGISTRATION ? "Log in / Sign up" : "Log in"}
+        </Button>
+      </ALink>
     </Li>
   );
   const logout = isAuthenticated && (
     <Li>
-      <Link href="/logout">
-        <ALink href="/logout" title="logout" fontSize={[14, 16]}>
-          Log out
-        </ALink>
-      </Link>
+      <ALink href="/logout" title="logout" fontSize={[14, 16]} isNextLink>
+        Log out
+      </ALink>
     </Li>
   );
   const settings = isAuthenticated && (
     <Li>
-      <Link href="/settings">
-        <ALink href="/settings" title="Settings" forButton>
-          <Button height={[32, 40]}>Settings</Button>
-        </ALink>
-      </Link>
+      <ALink href="/settings" title="Settings" forButton isNextLink>
+        <Button height={[32, 40]}>Settings</Button>
+      </ALink>
     </Li>
   );
 
@@ -102,27 +97,36 @@ const Header: FC = () => {
         alignItems={["flex-start", "stretch"]}
       >
         <LogoImage>
-          <a
+          <ALink
             href="/"
             title="Homepage"
-            onClick={e => {
+            onClick={(e) => {
               e.preventDefault();
               if (window.location.pathname !== "/") Router.push("/");
             }}
+            forButton
+            isNextLink
           >
-            <img src="/images/logo.svg" alt="" />
+            <Image
+              src="/images/logo.svg"
+              alt="kutt logo"
+              width={18}
+              height={24}
+            />
             {publicRuntimeConfig.SITE_NAME}
-          </a>
+          </ALink>
         </LogoImage>
+
         {!isMobile && (
           <Flex
             style={{ listStyle: "none" }}
             display={["none", "flex"]}
             alignItems="flex-end"
             as="ul"
-            mb="3px"
             m={0}
-            p={0}
+            px={0}
+            pt={0}
+            pb="2px"
           >
             <Li>
               <ALink
@@ -136,11 +140,14 @@ const Header: FC = () => {
               </ALink>
             </Li>
             <Li>
-              <Link href="/report">
-                <ALink href="/report" title="Report abuse" fontSize={[14, 16]}>
-                  Report
-                </ALink>
-              </Link>
+              <ALink
+                href="/report"
+                title="Report abuse"
+                fontSize={[14, 16]}
+                isNextLink
+              >
+                Report
+              </ALink>
             </Li>
           </Flex>
         )}
@@ -152,15 +159,20 @@ const Header: FC = () => {
         as="ul"
         style={{ listStyle: "none" }}
       >
-        <Li>
-          <Flex display={["flex", "none"]}>
-            <Link href="/report">
-              <ALink href="/report" title="Report" fontSize={[14, 16]}>
+        {isMobile && (
+          <Li>
+            <Flex>
+              <ALink
+                href="/report"
+                title="Report"
+                fontSize={[14, 16]}
+                isNextLink
+              >
                 Report
               </ALink>
-            </Link>
-          </Flex>
-        </Li>
+            </Flex>
+          </Li>
+        )}
         {logout}
         {settings}
         {login}

+ 1 - 1
client/components/Icon/Icon.tsx

@@ -1,4 +1,4 @@
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import styled, { css } from "styled-components";
 import { prop, ifProp } from "styled-tools";
 import React, { FC } from "react";

+ 10 - 7
client/components/Input.tsx

@@ -1,5 +1,5 @@
-import React from 'react';
-import { Flex, BoxProps } from "reflexbox/styled-components";
+import React from "react";
+import { Flex, BoxProps } from "rebass/styled-components";
 import styled, { css, keyframes } from "styled-components";
 import { withProp, prop, ifProp } from "styled-tools";
 import { FC } from "react";
@@ -41,7 +41,7 @@ export const TextInput = styled(Flex).attrs({
   }
 
   ::placeholder {
-    font-size: ${withProp("placeholderSize", s => s[0] || 14)}px;
+    font-size: ${withProp("placeholderSize", (s) => s[0] || 14)}px;
     letter-spacing: 0.05em;
     color: #888;
   }
@@ -50,7 +50,7 @@ export const TextInput = styled(Flex).attrs({
     ::placeholder {
       font-size: ${withProp(
         "placeholderSize",
-        s => s[3] || s[2] || s[1] || s[0] || 16
+        (s) => s[3] || s[2] || s[1] || s[0] || 16
       )}px;
     }
   }
@@ -61,14 +61,14 @@ export const TextInput = styled(Flex).attrs({
     ::placeholder {
       font-size: ${withProp(
         "placeholderSize",
-        s => s[2] || s[1] || s[0] || 15
+        (s) => s[2] || s[1] || s[0] || 15
       )}px;
     }
   }
 
   @media screen and (min-width: 40em) {
     ::placeholder {
-      font-size: ${withProp("placeholderSize", s => s[1] || s[0] || 15)}px;
+      font-size: ${withProp("placeholderSize", (s) => s[1] || s[0] || 15)}px;
     }
   }
 `;
@@ -211,8 +211,11 @@ const CheckboxBox = styled(Flex).attrs({
   )}
 `;
 
-interface CheckboxProps extends ChecknoxInputProps, BoxProps {
+interface CheckboxProps
+  extends ChecknoxInputProps,
+    Omit<BoxProps, "name" | "checked" | "onChange" | "value"> {
   label: string;
+  value?: boolean | string;
 }
 
 export const Checkbox: FC<CheckboxProps> = ({

+ 8 - 8
client/components/Layout.tsx

@@ -1,34 +1,34 @@
 import React from "react";
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import { FC } from "react";
 
 type Props = React.ComponentProps<typeof Flex>;
 
-export const Col: FC<Props> = props => (
+export const Col: FC<Props> = (props) => (
   <Flex flexDirection="column" {...props} />
 );
 
-export const RowCenterV: FC<Props> = props => (
+export const RowCenterV: FC<Props> = (props) => (
   <Flex alignItems="center" {...props} />
 );
 
-export const RowCenterH: FC<Props> = props => (
+export const RowCenterH: FC<Props> = (props) => (
   <Flex justifyContent="center" {...props} />
 );
 
-export const RowCenter: FC<Props> = props => (
+export const RowCenter: FC<Props> = (props) => (
   <Flex alignItems="center" justifyContent="center" {...props} />
 );
 
-export const ColCenterV: FC<Props> = props => (
+export const ColCenterV: FC<Props> = (props) => (
   <Flex flexDirection="column" justifyContent="center" {...props} />
 );
 
-export const ColCenterH: FC<Props> = props => (
+export const ColCenterH: FC<Props> = (props) => (
   <Flex flexDirection="column" alignItems="center" {...props} />
 );
 
-export const ColCenter: FC<Props> = props => (
+export const ColCenter: FC<Props> = (props) => (
   <Flex
     flexDirection="column"
     alignItems="center"

+ 27 - 25
client/components/LinksTable.tsx

@@ -2,12 +2,11 @@ import formatDistanceToNow from "date-fns/formatDistanceToNow";
 import { CopyToClipboard } from "react-copy-to-clipboard";
 import React, { FC, useState, useEffect } from "react";
 import { useFormState } from "react-use-form-state";
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import styled, { css } from "styled-components";
 import { ifProp } from "styled-tools";
 import getConfig from "next/config";
 import QRCode from "qrcode.react";
-import Link from "next/link";
 import differenceInMilliseconds from "date-fns/differenceInMilliseconds";
 import ms from "ms";
 
@@ -120,9 +119,9 @@ interface EditForm {
 }
 
 const Row: FC<RowProps> = ({ index, link, setDeleteModal }) => {
-  const isAdmin = useStoreState(s => s.auth.isAdmin);
-  const ban = useStoreActions(s => s.links.ban);
-  const edit = useStoreActions(s => s.links.edit);
+  const isAdmin = useStoreState((s) => s.auth.isAdmin);
+  const ban = useStoreActions((s) => s.links.ban);
+  const edit = useStoreActions((s) => s.links.edit);
   const [banFormState, { checkbox }] = useFormState<BanForm>();
   const [editFormState, { text, label, password }] = useFormState<EditForm>(
     {
@@ -182,7 +181,7 @@ const Row: FC<RowProps> = ({ index, link, setDeleteModal }) => {
   };
 
   const toggleEdit = () => {
-    setShowEdit(s => !s);
+    setShowEdit((s) => !s);
     if (showEdit) editFormState.reset();
     setEditMessage("");
   };
@@ -280,16 +279,19 @@ const Row: FC<RowProps> = ({ index, link, setDeleteModal }) => {
             </>
           )}
           {link.visit_count > 0 && (
-            <Link href={`/stats?id=${link.id}`}>
-              <ALink title="View stats" forButton>
-                <Action
-                  name="pieChart"
-                  stroke={Colors.PieIcon}
-                  strokeWidth="2.5"
-                  backgroundColor={Colors.PieIconBg}
-                />
-              </ALink>
-            </Link>
+            <ALink
+              href={`/stats?id=${link.id}`}
+              title="View stats"
+              forButton
+              isNextLink
+            >
+              <Action
+                name="pieChart"
+                stroke={Colors.PieIcon}
+                strokeWidth="2.5"
+                backgroundColor={Colors.PieIconBg}
+              />
+            </ALink>
           )}
           <Action
             name="qrcode"
@@ -503,7 +505,7 @@ const Row: FC<RowProps> = ({ index, link, setDeleteModal }) => {
           </H2>
           <Text mb={24} textAlign="center">
             Are you sure do you want to ban the link{" "}
-            <Span bold>"{removeProtocol(link.link)}"</Span>?
+            <Span bold>&quot;{removeProtocol(link.link)}&quot;</Span>?
           </Text>
           <RowCenter>
             <Checkbox {...checkbox("user")} label="User" mb={12} />
@@ -546,9 +548,9 @@ interface Form {
 }
 
 const LinksTable: FC = () => {
-  const isAdmin = useStoreState(s => s.auth.isAdmin);
-  const links = useStoreState(s => s.links);
-  const { get, remove } = useStoreActions(s => s.links);
+  const isAdmin = useStoreState((s) => s.auth.isAdmin);
+  const links = useStoreState((s) => s.links);
+  const { get, remove } = useStoreActions((s) => s.links);
   const [tableMessage, setTableMessage] = useState("No links to show.");
   const [deleteModal, setDeleteModal] = useState(-1);
   const [deleteLoading, setDeleteLoading] = useState(false);
@@ -562,12 +564,12 @@ const LinksTable: FC = () => {
   const linkToDelete = links.items[deleteModal];
 
   useEffect(() => {
-    get(options).catch(err =>
+    get(options).catch((err) =>
       setTableMessage(err?.response?.data?.error || "An error occurred.")
     );
-  }, [options.limit, options.skip, options.all]);
+  }, [options, get]);
 
-  const onSubmit = e => {
+  const onSubmit = (e) => {
     e.preventDefault();
     get(options);
   };
@@ -596,7 +598,7 @@ const LinksTable: FC = () => {
       flexShrink={1}
     >
       <Flex as="ul" m={0} p={0} style={{ listStyle: "none" }}>
-        {["10", "25", "50"].map(c => (
+        {["10", "25", "50"].map((c) => (
           <Flex key={c} ml={[10, 12]}>
             <NavButton
               disabled={options.limit === c}
@@ -722,7 +724,7 @@ const LinksTable: FC = () => {
             </H2>
             <Text textAlign="center">
               Are you sure do you want to delete the link{" "}
-              <Span bold>"{removeProtocol(linkToDelete.link)}"</Span>?
+              <Span bold>&quot;{removeProtocol(linkToDelete.link)}&quot;</Span>?
             </Text>
             <Flex justifyContent="center" mt={44}>
               {deleteLoading ? (

+ 2 - 2
client/components/Modal.tsx

@@ -1,4 +1,4 @@
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import styled from "styled-components";
 import React, { FC } from "react";
 import ReactDOM from "react-dom";
@@ -27,7 +27,7 @@ const Wrapper = styled.div`
 const Modal: FC<Props> = ({ children, id, show, closeHandler, ...rest }) => {
   if (!show) return null;
 
-  const onClickOutside = e => {
+  const onClickOutside = (e) => {
     if (e.target.id === id) closeHandler();
   };
 

+ 4 - 6
client/components/NeedToLogin.tsx

@@ -1,7 +1,7 @@
 import React from "react";
 import Link from "next/link";
 import styled from "styled-components";
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 
 import { Button } from "./Button";
 import { fadeIn } from "../helpers/animations";
@@ -65,13 +65,11 @@ const NeedToLogin = () => (
       <Title>
         Manage links, set custom <b>domains</b> and view <b>stats</b>.
       </Title>
-      <Link href="/login">
-        <a href="/login" title="login / signup">
-          <Button>Login / Signup</Button>
-        </a>
+      <Link href="/login" title="login / signup">
+        <Button>Login / Signup</Button>
       </Link>
     </Col>
-    <Image src="/images/callout.png" />
+    <Image src="/images/callout.png" alt="callout image" />
   </Wrapper>
 );
 

+ 1 - 1
client/components/PageLoading.tsx

@@ -1,4 +1,4 @@
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import React from "react";
 
 import { Colors } from "../consts";

+ 1 - 1
client/components/ReCaptcha.tsx

@@ -1,4 +1,4 @@
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import getConfig from "next/config";
 import React from "react";
 

+ 4 - 4
client/components/Settings/SettingsApi.tsx

@@ -1,5 +1,5 @@
 import { CopyToClipboard } from "react-copy-to-clipboard";
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import React, { FC, useState } from "react";
 import styled from "styled-components";
 
@@ -32,13 +32,13 @@ const SettingsApi: FC = () => {
   const [copied, setCopied] = useCopy();
   const [message, setMessage] = useMessage(1500);
   const [loading, setLoading] = useState(false);
-  const apikey = useStoreState(s => s.settings.apikey);
-  const generateApiKey = useStoreActions(s => s.settings.generateApiKey);
+  const apikey = useStoreState((s) => s.settings.apikey);
+  const generateApiKey = useStoreActions((s) => s.settings.generateApiKey);
 
   const onSubmit = async () => {
     if (loading) return;
     setLoading(true);
-    await generateApiKey().catch(err => setMessage(errorMessage(err)));
+    await generateApiKey().catch((err) => setMessage(errorMessage(err)));
     setLoading(false);
   };
 

+ 2 - 2
client/components/Settings/SettingsChangeEmail.tsx

@@ -1,6 +1,6 @@
 import { useFormState } from "react-use-form-state";
 import React, { FC, useState } from "react";
-import { Flex } from "reflexbox";
+import { Flex } from "rebass";
 import axios from "axios";
 
 import { getAxiosConfig } from "../../utils";
@@ -22,7 +22,7 @@ const SettingsChangeEmail: FC = () => {
     withIds: true
   });
 
-  const onSubmit = async e => {
+  const onSubmit = async (e) => {
     e.preventDefault();
     if (loading) return;
     setLoading(true);

+ 10 - 7
client/components/Settings/SettingsDomain.tsx

@@ -1,5 +1,5 @@
 import { useFormState } from "react-use-form-state";
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import React, { FC, useState } from "react";
 import styled from "styled-components";
 import getConfig from "next/config";
@@ -27,10 +27,10 @@ const Td = styled(Flex).attrs({ as: "td", py: 12, px: 3 })`
 `;
 
 const SettingsDomain: FC = () => {
-  const { saveDomain, deleteDomain } = useStoreActions(s => s.settings);
+  const { saveDomain, deleteDomain } = useStoreActions((s) => s.settings);
   const [domainToDelete, setDomainToDelete] = useState<Domain>(null);
   const [deleteLoading, setDeleteLoading] = useState(false);
-  const domains = useStoreState(s => s.settings.domains);
+  const domains = useStoreState((s) => s.settings.domains);
   const [message, setMessage] = useMessage(2000);
   const [loading, setLoading] = useState(false);
   const [modal, setModal] = useState(false);
@@ -39,7 +39,7 @@ const SettingsDomain: FC = () => {
     homepage: string;
   }>(null, { withIds: true });
 
-  const onSubmit = async e => {
+  const onSubmit = async (e) => {
     e.preventDefault();
     setLoading(true);
 
@@ -59,7 +59,7 @@ const SettingsDomain: FC = () => {
 
   const onDelete = async () => {
     setDeleteLoading(true);
-    await deleteDomain(domainToDelete.id).catch(err =>
+    await deleteDomain(domainToDelete.id).catch((err) =>
       setMessage(errorMessage(err, "Couldn't delete the domain."))
     );
     setMessage("Domain has been deleted successfully.", "green");
@@ -91,7 +91,7 @@ const SettingsDomain: FC = () => {
             </tr>
           </thead>
           <tbody>
-            {domains.map(d => (
+            {domains.map((d) => (
               <tr key={d.address}>
                 <Td width={2 / 5}>{d.address}</Td>
                 <Td width={2 / 5}>
@@ -174,7 +174,10 @@ const SettingsDomain: FC = () => {
         </H2>
         <Text textAlign="center">
           Are you sure do you want to delete the domain{" "}
-          <Span bold>"{domainToDelete && domainToDelete.address}"</Span>?
+          <Span bold>
+            &quot;{domainToDelete && domainToDelete.address}&quot;
+          </Span>
+          ?
         </Text>
         <Flex justifyContent="center" mt={44}>
           {deleteLoading ? (

+ 3 - 3
client/components/Settings/SettingsPassword.tsx

@@ -1,5 +1,5 @@
 import { useFormState } from "react-use-form-state";
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import React, { FC, useState } from "react";
 import axios from "axios";
 
@@ -20,7 +20,7 @@ const SettingsPassword: FC = () => {
     { withIds: true }
   );
 
-  const onSubmit = async e => {
+  const onSubmit = async (e) => {
     e.preventDefault();
     if (loading) return;
     if (!formState.validity.password) {
@@ -61,7 +61,7 @@ const SettingsPassword: FC = () => {
         <TextInput
           {...password({
             name: "password",
-            validate: value => {
+            validate: (value) => {
               const val = value.trim();
               if (!val || val.length < 8) {
                 return "Password must be at least 8 chars.";

+ 18 - 19
client/components/Shortener.tsx

@@ -1,6 +1,6 @@
 import { CopyToClipboard } from "react-copy-to-clipboard";
 import { useFormState } from "react-use-form-state";
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import React, { useState } from "react";
 import styled from "styled-components";
 import getConfig from "next/config";
@@ -62,27 +62,26 @@ interface Form {
 const defaultDomain = publicRuntimeConfig.DEFAULT_DOMAIN;
 
 const Shortener = () => {
-  const { isAuthenticated } = useStoreState(s => s.auth);
-  const domains = useStoreState(s => s.settings.domains);
-  const submit = useStoreActions(s => s.links.submit);
+  const { isAuthenticated } = useStoreState((s) => s.auth);
+  const domains = useStoreState((s) => s.settings.domains);
+  const submit = useStoreActions((s) => s.links.submit);
   const [link, setLink] = useState<Link | null>(null);
   const [message, setMessage] = useMessage(3000);
   const [loading, setLoading] = useState(false);
   const [copied, setCopied] = useCopy();
-  const [formState, { raw, password, text, select, label }] = useFormState<
-    Form
-  >(
-    { showAdvanced: false },
-    {
-      withIds: true,
-      onChange(e, stateValues, nextStateValues) {
-        if (stateValues.showAdvanced && !nextStateValues.showAdvanced) {
-          formState.clear();
-          formState.setField("target", stateValues.target);
+  const [formState, { raw, password, text, select, label }] =
+    useFormState<Form>(
+      { showAdvanced: false },
+      {
+        withIds: true,
+        onChange(e, stateValues, nextStateValues) {
+          if (stateValues.showAdvanced && !nextStateValues.showAdvanced) {
+            formState.clear();
+            formState.setField("target", stateValues.target);
+          }
         }
       }
-    }
-  );
+    );
 
   const submitLink = async (reCaptchaToken?: string) => {
     try {
@@ -97,7 +96,7 @@ const Shortener = () => {
     setLoading(false);
   };
 
-  const onSubmit = async e => {
+  const onSubmit = async (e) => {
     e.preventDefault();
     if (loading) return;
     setCopied(false);
@@ -232,7 +231,7 @@ const Shortener = () => {
       <Checkbox
         {...raw({
           name: "showAdvanced",
-          onChange: e => {
+          onChange: () => {
             if (!isAuthenticated) {
               setMessage(
                 "You need to log in or sign up to use advanced options."
@@ -270,7 +269,7 @@ const Shortener = () => {
                 width={[1, 210, 240]}
                 options={[
                   { key: defaultDomain, value: "" },
-                  ...domains.map(d => ({
+                  ...domains.map((d) => ({
                     key: d.address,
                     value: d.address
                   }))

+ 1 - 1
client/components/Table.ts

@@ -1,4 +1,4 @@
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import styled, { css } from "styled-components";
 import { ifProp, prop } from "styled-tools";
 

+ 8 - 8
client/components/Text.tsx

@@ -1,6 +1,6 @@
 import React from "react";
 import { switchProp, ifNotProp, ifProp } from "styled-tools";
-import { Box, BoxProps } from "reflexbox/styled-components";
+import { Box, BoxProps } from "rebass/styled-components";
 import styled, { css } from "styled-components";
 
 import { FC, CSSProperties } from "react";
@@ -58,10 +58,10 @@ Text.defaultProps = {
 
 export default Text;
 
-export const H1: FC<Props> = props => <Text as="h1" {...props} />;
-export const H2: FC<Props> = props => <Text as="h2" {...props} />;
-export const H3: FC<Props> = props => <Text as="h3" {...props} />;
-export const H4: FC<Props> = props => <Text as="h4" {...props} />;
-export const H5: FC<Props> = props => <Text as="h5" {...props} />;
-export const H6: FC<Props> = props => <Text as="h6" {...props} />;
-export const Span: FC<Props> = props => <Text as="span" {...props} />;
+export const H1: FC<Props> = (props) => <Text as="h1" {...props} />;
+export const H2: FC<Props> = (props) => <Text as="h2" {...props} />;
+export const H3: FC<Props> = (props) => <Text as="h3" {...props} />;
+export const H4: FC<Props> = (props) => <Text as="h4" {...props} />;
+export const H5: FC<Props> = (props) => <Text as="h5" {...props} />;
+export const H6: FC<Props> = (props) => <Text as="h6" {...props} />;
+export const Span: FC<Props> = (props) => <Text as="span" {...props} />;

+ 0 - 52
client/components/__tests__/footer.test.tsx

@@ -1,52 +0,0 @@
-import React from "react";
-import { render } from "@testing-library/react";
-import { StoreProvider } from "easy-peasy";
-import { initializeStore } from "../../store";
-import Footer from "../Footer";
-import getConfig from "next/config";
-
-describe("<Footer /> component test", () => {
-  let app;
-
-  beforeEach(() => {
-    const store = initializeStore();
-    app = (
-      <StoreProvider store={store}>
-        <Footer />
-      </StoreProvider>
-    );
-  });
-
-  it("should contain a github link", () => {
-    const screen = render(app);
-    const githubLink = screen.getByRole("link", { name: "GitHub" });
-    expect(githubLink).toHaveAttribute("href", "https://github.com/thedevs-network/kutt");
-  });
-
-  it("should contain a TOS link", () => {
-    const config = getConfig();
-    const screen = render(app);
-    const tosLink = screen.getByRole("link", { name: "Terms of Service" });
-
-    expect(tosLink).toHaveAttribute("href", "/terms");
-  });
-
-  it("should show contact email if defined", () => {
-    const config = getConfig();
-    config.publicRuntimeConfig.CONTACT_EMAIL = 'foobar';
-    const screen = render(app);
-    const emailLink = screen.getByRole("link", { name: "Contact us" });
-
-    expect(emailLink).toHaveAttribute("href", "mailto:foobar");
-  });
-
-  it("should NOT show contact email if none is defined", () => {
-    const config = getConfig();
-    delete(config.publicRuntimeConfig.CONTACT_EMAIL);
-    const screen = render(app);
-    const emailLink= screen.queryByRole("link", { name: "Contact us" });
-
-    expect(emailLink).toBeNull();
-  });
-})
-

+ 0 - 59
client/components/__tests__/shortener.test.tsx

@@ -1,59 +0,0 @@
-import React from "react";
-import { render } from "@testing-library/react";
-import { StoreProvider, createStore, thunk } from "easy-peasy";
-import userEvent from "@testing-library/user-event"
-import { store } from "../../store";
-import Shortener from "../Shortener";
-
-describe("<Shortener /> component test", () => {
-  let app;
-
-  beforeEach(() => {
-    store.links = {
-      ...store.links,
-      submit: thunk(async (actions, payload) => {
-        return {
-          id: "0",
-          address: "localhost:3000/foobar",
-          banned: false,
-          created_at: "now",
-          link: "localhost:3000/foobar",
-          target: "",
-          updated_at: "now",
-          visit_count: 0
-        };
-      })
-    };
-    const testStore = createStore(store);
-    app = (
-      <StoreProvider store={testStore}>
-        <Shortener />
-      </StoreProvider>
-    );
-  });
-
-  it("Should show the short URL", async () => {
-    const screen = render(app);
-    const urlInput = screen.getByRole("textbox", { name: "target" });
-    userEvent.type(urlInput, "https://easy-peasy.now.sh/docs/api/thunk.html");
-    const submitButton = screen.getByRole("button", { name: "submit" });
-    userEvent.click(submitButton);
-    const msg = await screen.findByText(/localhost:3000\/foobar/i);
-    expect(msg).toBeInTheDocument();
-  });
-
-  it("Should empty target input", async () => {
-    const screen = render(app);
-    let urlInput: HTMLInputElement = screen.getByRole("textbox", {
-      name: "target"
-    }) as HTMLInputElement;
-    userEvent.type(urlInput, "https://easy-peasy.now.sh/docs/api/thunk.html");
-    const submitButton = screen.getByRole("button", { name: "submit" });
-    userEvent.click(submitButton);
-    await screen.findByText(/localhost:3000\/foobar/i);
-    urlInput = screen.getByRole("textbox", {
-      name: "target"
-    }) as HTMLInputElement;
-    expect(urlInput.value).toEqual("");
-  });
-});

+ 0 - 5
client/consts/consts.ts

@@ -8,11 +8,6 @@ export const DISALLOW_ANONYMOUS_LINKS =
 export const DISALLOW_REGISTRATION =
   publicRuntimeConfig.DISALLOW_REGISTRATION === "true";
 
-export enum API {
-  BAN_LINK = "/api/url/admin/ban",
-  STATS = "/api/url/stats"
-}
-
 export enum APIv2 {
   AuthLogin = "/api/v2/auth/login",
   AuthSignup = "/api/v2/auth/signup",

+ 0 - 40
client/helpers/analytics.ts

@@ -1,40 +0,0 @@
-import getConfig from "next/config";
-import ReactGA from "react-ga";
-import * as Sentry from '@sentry/react';
-import { Integrations } from '@sentry/apm';
-
-const { publicRuntimeConfig } = getConfig();
-
-export const initGA = () => {
-  ReactGA.initialize(publicRuntimeConfig.GOOGLE_ANALYTICS);
-};
-
-export const logPageView = () => {
-  ReactGA.set({ page: window.location.pathname });
-  ReactGA.pageview(window.location.pathname);
-};
-
-export const initSentry = () => {
-  if (publicRuntimeConfig.SENTRY_PUBLIC_DSN) {
-    Sentry.init({
-      dsn: publicRuntimeConfig.SENTRY_PUBLIC_DSN,
-      environment: process.env.NODE_ENV,
-      integrations: [
-        new Integrations.Tracing(),
-      ],
-      tracesSampleRate: 1.0,
-    });
-  };
-};
-
-export const logEvent = (category = "", action = "") => {
-  if (category && action) {
-    ReactGA.event({ category, action });
-  }
-};
-
-export const logException = (description = "", fatal = false) => {
-  if (description) {
-    ReactGA.exception({ description, fatal });
-  }
-};

+ 4 - 1
client/next-env.d.ts

@@ -1,2 +1,5 @@
 /// <reference types="next" />
-/// <reference types="next/types/global" />
+/// <reference types="next/image-types/global" />
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.

+ 0 - 15
client/pages/_app.tsx

@@ -7,17 +7,11 @@ import cookie from "js-cookie";
 import Head from "next/head";
 import React from "react";
 
-import { initGA, logPageView , initSentry } from "../helpers/analytics";
 import { initializeStore } from "../store";
 import { TokenPayload } from "../types";
 
-const isProd = process.env.NODE_ENV === "production";
 const { publicRuntimeConfig } = getConfig();
 
-if (isProd) {
-  initSentry();
-};
-
 // TODO: types
 class MyApp extends App<any> {
   static async getInitialProps({ Component, ctx }: AppContext) {
@@ -59,18 +53,9 @@ class MyApp extends App<any> {
       });
     }
 
-    if (isProd) {
-      initGA();
-      logPageView();
-    }
-
     Router.events.on("routeChangeStart", () => loading.show());
     Router.events.on("routeChangeComplete", () => {
       loading.hide();
-
-      if (isProd) {
-        logPageView();
-      }
     });
     Router.events.on("routeChangeError", () => loading.hide());
   }

+ 6 - 5
client/pages/_document.tsx

@@ -12,13 +12,14 @@ interface Props {
 }
 
 class AppDocument extends Document<Props> {
-  static getInitialProps({ renderPage }) {
+  static async getInitialProps(ctx) {
+    const initialProps = await Document.getInitialProps(ctx);
     const sheet = new ServerStyleSheet();
-    const page = renderPage(App => props =>
-      sheet.collectStyles(<App {...props} />)
+    const page = ctx.renderPage(
+      (App) => (props) => sheet.collectStyles(<App {...props} />)
     );
     const styleTags = sheet.getStyleElement();
-    return { ...page, styleTags };
+    return { ...initialProps, ...page, styleTags };
   }
 
   render() {
@@ -35,7 +36,7 @@ class AppDocument extends Document<Props> {
             content={`${publicRuntimeConfig.SITE_NAME} is a free and open source URL shortener with custom domains and stats.`}
           />
           <link
-            href="https://fonts.googleapis.com/css?family=Nunito:300,400,700"
+            href="https://fonts.googleapis.com/css?family=Nunito:300,400,700&display=optional"
             rel="stylesheet"
           />
           <link rel="icon" sizes="196x196" href="/images/favicon-196x196.png" />

+ 3 - 4
client/pages/banned.tsx

@@ -1,5 +1,4 @@
 import getConfig from "next/config";
-import Link from "next/link";
 import React from "react";
 
 import AppWrapper from "../components/AppWrapper";
@@ -24,9 +23,9 @@ const BannedPage = () => {
         <H4 textAlign="center" normal>
           If you noticed a malware/scam link shortened by{" "}
           {publicRuntimeConfig.SITE_NAME},{" "}
-          <Link href="/report">
-            <ALink title="Send report">send us a report</ALink>
-          </Link>
+          <ALink href="/report" title="Send report" isNextLink>
+            send us a report
+          </ALink>
           .
         </H4>
       </Col>

+ 16 - 18
client/pages/login.tsx

@@ -1,10 +1,9 @@
 import { useFormState } from "react-use-form-state";
 import React, { useEffect, useState } from "react";
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import emailValidator from "email-validator";
 import styled from "styled-components";
 import Router from "next/router";
-import Link from "next/link";
 import axios from "axios";
 
 import { useStoreState, useStoreActions } from "../store";
@@ -32,8 +31,8 @@ const Email = styled.span`
 `;
 
 const LoginPage = () => {
-  const { isAuthenticated } = useStoreState(s => s.auth);
-  const login = useStoreActions(s => s.auth.login);
+  const { isAuthenticated } = useStoreState((s) => s.auth);
+  const login = useStoreActions((s) => s.auth.login);
   const [error, setError] = useState("");
   const [verifying, setVerifying] = useState(false);
   const [loading, setLoading] = useState({ login: false, signup: false });
@@ -47,7 +46,7 @@ const LoginPage = () => {
   }, [isAuthenticated]);
 
   function onSubmit(type: "login" | "signup") {
-    return async e => {
+    return async (e) => {
       e.preventDefault();
       const { email, password } = formState.values;
 
@@ -68,7 +67,7 @@ const LoginPage = () => {
       setError("");
 
       if (type === "login") {
-        setLoading(s => ({ ...s, login: true }));
+        setLoading((s) => ({ ...s, login: true }));
         try {
           await login(formState.values);
           Router.push("/");
@@ -78,7 +77,7 @@ const LoginPage = () => {
       }
 
       if (type === "signup" && !DISALLOW_REGISTRATION) {
-        setLoading(s => ({ ...s, signup: true }));
+        setLoading((s) => ({ ...s, signup: true }));
         try {
           await axios.post(APIv2.AuthSignup, { email, password });
           setVerifying(true);
@@ -163,17 +162,16 @@ const LoginPage = () => {
                 </Button>
               )}
             </Flex>
-            <Link href="/reset-password">
-              <ALink
-                href="/reset-password"
-                title="Forget password"
-                fontSize={14}
-                alignSelf="flex-start"
-                my={16}
-              >
-                Forgot your password?
-              </ALink>
-            </Link>
+            <ALink
+              href="/reset-password"
+              title="Forget password"
+              fontSize={14}
+              alignSelf="flex-start"
+              my={16}
+              isNextLink
+            >
+              Forgot your password?
+            </ALink>
             <Text color="red" mt={1} normal>
               {error}
             </Text>

+ 3 - 3
client/pages/logout.tsx

@@ -4,14 +4,14 @@ import Router from "next/router";
 import { useStoreActions } from "../store";
 
 const LogoutPage: FC = () => {
-  const logout = useStoreActions(s => s.auth.logout);
-  const reset = useStoreActions(s => s.reset);
+  const logout = useStoreActions((s) => s.auth.logout);
+  const reset = useStoreActions((s) => s.reset);
 
   useEffect(() => {
     logout();
     reset();
     Router.push("/");
-  }, []);
+  }, [logout, reset]);
 
   return <div />;
 };

+ 2 - 2
client/pages/protected/[id].tsx

@@ -1,5 +1,5 @@
 import { useFormState } from "react-use-form-state";
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import React, { useState } from "react";
 import { NextPage } from "next";
 import { useRouter } from "next/router";
@@ -23,7 +23,7 @@ const ProtectedPage: NextPage<Props> = () => {
   const [formState, { password }] = useFormState<{ password: string }>();
   const [error, setError] = useState<string>();
 
-  const onSubmit = async e => {
+  const onSubmit = async (e) => {
     e.preventDefault();
     const { password } = formState.values;
 

+ 2 - 2
client/pages/report.tsx

@@ -1,5 +1,5 @@
 import { useFormState } from "react-use-form-state";
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import React, { useState } from "react";
 import axios from "axios";
 
@@ -21,7 +21,7 @@ const ReportPage = () => {
   const [loading, setLoading] = useState(false);
   const [message, setMessage] = useMessage(5000);
 
-  const onSubmit = async e => {
+  const onSubmit = async (e) => {
     e.preventDefault();
     setLoading(true);
     setMessage();

+ 7 - 7
client/pages/reset-password.tsx

@@ -1,6 +1,6 @@
 import { useFormState } from "react-use-form-state";
 import React, { useEffect, useState } from "react";
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import Router from "next/router";
 import decode from "jwt-decode";
 import { NextPage } from "next";
@@ -16,15 +16,15 @@ import { Col } from "../components/Layout";
 import { TokenPayload } from "../types";
 import { useMessage } from "../hooks";
 import Icon from "../components/Icon";
-import { API, APIv2 } from "../consts";
+import { APIv2 } from "../consts";
 
 interface Props {
   token?: string;
 }
 
 const ResetPassword: NextPage<Props> = ({ token }) => {
-  const auth = useStoreState(s => s.auth);
-  const addAuth = useStoreActions(s => s.auth.add);
+  const auth = useStoreState((s) => s.auth);
+  const addAuth = useStoreActions((s) => s.auth.add);
   const [loading, setLoading] = useState(false);
   const [message, setMessage] = useMessage();
   const [formState, { email, label }] = useFormState<{ email: string }>(null, {
@@ -42,9 +42,9 @@ const ResetPassword: NextPage<Props> = ({ token }) => {
       addAuth(decoded);
       Router.push("/settings");
     }
-  }, []);
+  }, [auth, token, addAuth]);
 
-  const onSubmit = async e => {
+  const onSubmit = async (e) => {
     e.preventDefault();
     if (!formState.validity.email) return;
 
@@ -103,7 +103,7 @@ const ResetPassword: NextPage<Props> = ({ token }) => {
   );
 };
 
-ResetPassword.getInitialProps = async ctx => {
+ResetPassword.getInitialProps = async (ctx) => {
   return { token: ctx.req && (ctx.req as any).token };
 };
 

+ 11 - 17
client/pages/stats.tsx

@@ -1,8 +1,7 @@
-import { Box, Flex } from "reflexbox/styled-components";
+import { Box, Flex } from "rebass/styled-components";
 import React, { useState, useEffect } from "react";
 import formatDate from "date-fns/format";
 import { NextPage } from "next";
-import Link from "next/link";
 import axios from "axios";
 
 import Text, { H1, H2, H4, Span } from "../components/Text";
@@ -23,7 +22,7 @@ interface Props {
 }
 
 const StatsPage: NextPage<Props> = ({ id }) => {
-  const { isAuthenticated } = useStoreState(s => s.auth);
+  const { isAuthenticated } = useStoreState((s) => s.auth);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState(false);
   const [data, setData] = useState<Record<string, any> | undefined>();
@@ -44,7 +43,7 @@ const StatsPage: NextPage<Props> = ({ id }) => {
         setLoading(false);
         setError(true);
       });
-  }, []);
+  }, [id, isAuthenticated]);
 
   let errorMessage;
 
@@ -61,7 +60,7 @@ const StatsPage: NextPage<Props> = ({ id }) => {
     errorMessage = (
       <Flex mt={3}>
         <Icon name="x" size={32} mr={3} stroke={Colors.TrashIcon} />
-        <H2>Couldn't get stats.</H2>
+        <H2>Couldn&apos;t get stats.</H2>
       </Flex>
     );
   }
@@ -88,10 +87,7 @@ const StatsPage: NextPage<Props> = ({ id }) => {
               </H1>
               <Text fontSize={[13, 14]} textAlign="right">
                 {data.target.length > 80
-                  ? `${data.target
-                      .split("")
-                      .slice(0, 80)
-                      .join("")}...`
+                  ? `${data.target.split("").slice(0, 80).join("")}...`
                   : data.target}
               </Text>
             </Flex>
@@ -187,14 +183,12 @@ const StatsPage: NextPage<Props> = ({ id }) => {
               </Col>
             </Col>
             <Box alignSelf="center" my={64}>
-              <Link href="/">
-                <ALink href="/" title="Back to homepage" forButton>
-                  <Button>
-                    <Icon name="arrowLeft" stroke="white" mr={2} />
-                    Back to homepage
-                  </Button>
-                </ALink>
-              </Link>
+              <ALink href="/" title="Back to homepage" forButton isNextLink>
+                <Button>
+                  <Icon name="arrowLeft" stroke="white" mr={2} />
+                  Back to homepage
+                </Button>
+              </ALink>
             </Box>
           </Col>
         ))}

+ 4 - 4
client/pages/verify-email.tsx

@@ -1,5 +1,5 @@
 import React, { useEffect } from "react";
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import decode from "jwt-decode";
 import { NextPage } from "next";
 import cookie from "js-cookie";
@@ -17,7 +17,7 @@ interface Props {
 }
 
 const VerifyEmail: NextPage<Props> = ({ token }) => {
-  const addAuth = useStoreActions(s => s.auth.add);
+  const addAuth = useStoreActions((s) => s.auth.add);
 
   useEffect(() => {
     if (token) {
@@ -25,7 +25,7 @@ const VerifyEmail: NextPage<Props> = ({ token }) => {
       const decoded: TokenPayload = decode(token);
       addAuth(decoded);
     }
-  }, []);
+  }, [addAuth, token]);
 
   return (
     <AppWrapper>
@@ -48,7 +48,7 @@ const VerifyEmail: NextPage<Props> = ({ token }) => {
   );
 };
 
-VerifyEmail.getInitialProps = async ctx => {
+VerifyEmail.getInitialProps = async (ctx) => {
   return { token: (ctx?.req as any)?.token };
 };
 

+ 15 - 20
client/pages/verify.tsx

@@ -1,9 +1,8 @@
-import { Flex } from "reflexbox/styled-components";
+import { Flex } from "rebass/styled-components";
 import React, { useEffect } from "react";
 import styled from "styled-components";
 import decode from "jwt-decode";
 import cookie from "js-cookie";
-import Link from "next/link";
 
 import AppWrapper from "../components/AppWrapper";
 import { Button } from "../components/Button";
@@ -35,7 +34,7 @@ const Message = styled.p`
 `;
 
 const Verify: NextPage<Props> = ({ token }) => {
-  const addAuth = useStoreActions(s => s.auth.add);
+  const addAuth = useStoreActions((s) => s.auth.add);
 
   useEffect(() => {
     if (token) {
@@ -43,7 +42,7 @@ const Verify: NextPage<Props> = ({ token }) => {
       const payload: TokenPayload = decode(token);
       addAuth(payload);
     }
-  }, []);
+  }, [token, addAuth]);
 
   return (
     <AppWrapper>
@@ -53,14 +52,12 @@ const Verify: NextPage<Props> = ({ token }) => {
             <Icon name="check" size={32} mr={3} stroke={Colors.CheckIcon} />
             <Message>Your account has been verified successfully!</Message>
           </MessageWrapper>
-          <Link href="/">
-            <ALink href="/" forButton>
-              <Button>
-                <Icon name="arrowLeft" stroke="white" mr={2} />
-                Back to homepage
-              </Button>
-            </ALink>
-          </Link>
+          <ALink href="/" forButton isNextLink>
+            <Button>
+              <Icon name="arrowLeft" stroke="white" mr={2} />
+              Back to homepage
+            </Button>
+          </ALink>
         </Col>
       ) : (
         <Col alignItems="center">
@@ -68,14 +65,12 @@ const Verify: NextPage<Props> = ({ token }) => {
             <Icon name="x" size={32} mr={3} stroke={Colors.TrashIcon} />
             <Message>Invalid verification.</Message>
           </MessageWrapper>
-          <Link href="/login">
-            <ALink href="/login" forButton>
-              <Button color="purple">
-                <Icon name="arrowLeft" stroke="white" mr={2} />
-                Back to signup
-              </Button>
-            </ALink>
-          </Link>
+          <ALink href="/login" forButton isNextLink>
+            <Button color="purple">
+              <Icon name="arrowLeft" stroke="white" mr={2} />
+              Back to signup
+            </Button>
+          </ALink>
         </Col>
       )}
     </AppWrapper>

+ 1 - 1
client/store/auth.ts

@@ -4,7 +4,7 @@ import cookie from "js-cookie";
 import axios from "axios";
 
 import { TokenPayload } from "../types";
-import { API, APIv2 } from "../consts";
+import { APIv2 } from "../consts";
 import { getAxiosConfig } from "../utils";
 
 export interface Auth {

+ 1 - 1
client/store/links.ts

@@ -3,7 +3,7 @@ import axios from "axios";
 import query from "query-string";
 
 import { getAxiosConfig } from "../utils";
-import { API, APIv2 } from "../consts";
+import { APIv2 } from "../consts";
 
 export interface Link {
   id: string;

+ 16 - 4
client/tsconfig.json

@@ -1,7 +1,11 @@
 {
   "compilerOptions": {
     "target": "es5",
-    "lib": ["dom", "dom.iterable", "esnext"],
+    "lib": [
+      "dom",
+      "dom.iterable",
+      "esnext"
+    ],
     "allowJs": true,
     "skipLibCheck": true,
     "strict": false,
@@ -12,8 +16,16 @@
     "moduleResolution": "node",
     "resolveJsonModule": true,
     "isolatedModules": true,
-    "jsx": "preserve"
+    "jsx": "preserve",
+    "incremental": true
   },
-  "exclude": ["node_modules"],
-  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "./module.d.ts"]
+  "exclude": [
+    "node_modules"
+  ],
+  "include": [
+    "next-env.d.ts",
+    "**/*.ts",
+    "**/*.tsx",
+    "./module.d.ts"
+  ]
 }

+ 1 - 1
client/utils.ts

@@ -18,6 +18,6 @@ export const getAxiosConfig = (
 });
 
 export const errorMessage = (err: AxiosError, defaultMessage?: string) => {
-  const data = err?.response?.data;
+  const data = err?.response?.data as Record<string, any>;
   return data?.message || data?.error || defaultMessage || "";
 };

+ 6 - 1
global.d.ts

@@ -1,4 +1,4 @@
-type Raw = import("knex").Raw;
+type Raw = import("knex").Knex.Raw;
 
 type Match<T> = {
   [K in keyof T]?: T[K] | [">" | ">=" | "<=" | "<", T[K]];
@@ -151,5 +151,10 @@ declare namespace Express {
     protectedLink?: string;
     token?: string;
     user: UserJoined;
+    context?: {
+      limit: number;
+      skip: number;
+      all: boolean;
+    };
   }
 }

+ 0 - 13
jest.config.js

@@ -1,13 +0,0 @@
-module.exports = {
-  setupFilesAfterEnv: ['<rootDir>/jest-setup.ts'],
-  "preset": "ts-jest",
-  "transform": {
-    "^.+\\.js$": "babel-jest"
-  },
-  "testEnvironment": "jsdom",
-  "globals": {
-    "ts-jest": {
-      "tsconfig": "<rootDir>/tsconfig.test.json"
-    }
-  }
-};

+ 1 - 3
next.config.js

@@ -6,10 +6,8 @@ module.exports = {
     SITE_NAME: localEnv && localEnv.SITE_NAME,
     DEFAULT_DOMAIN: localEnv && localEnv.DEFAULT_DOMAIN,
     RECAPTCHA_SITE_KEY: localEnv && localEnv.RECAPTCHA_SITE_KEY,
-    GOOGLE_ANALYTICS: localEnv && localEnv.GOOGLE_ANALYTICS,
     REPORT_EMAIL: localEnv && localEnv.REPORT_EMAIL,
     DISALLOW_ANONYMOUS_LINKS: localEnv && localEnv.DISALLOW_ANONYMOUS_LINKS,
-    DISALLOW_REGISTRATION: localEnv && localEnv.DISALLOW_REGISTRATION,
-    SENTRY_PUBLIC_DSN: localEnv && localEnv.SENTRY_PUBLIC_DSN,
+    DISALLOW_REGISTRATION: localEnv && localEnv.DISALLOW_REGISTRATION
   }
 };

File diff suppressed because it is too large
+ 4655 - 4824
package-lock.json


+ 99 - 120
package.json

@@ -4,7 +4,7 @@
   "description": "Modern URL shortener.",
   "main": "./production-server/server.js",
   "scripts": {
-    "test": "jest",
+    "test": "jest --passWithNoTests",
     "docker:build": "docker build -t kutt .",
     "docker:run": "docker run -p 3000:3000 --env-file .env -d kutt:latest",
     "dev": "npm run migrate && cross-env NODE_ENV=development nodemon server/server.ts",
@@ -14,12 +14,8 @@
     "migrate:make": "knex migrate:make --env production",
     "lint": "eslint server/ --ext .js,.ts --fix",
     "lint:nofix": "eslint server/ --ext .js,.ts",
-    "docs:build": "cd docs/api && tsc generate.ts --resolveJsonModule && node generate && cd ../.."
-  },
-  "husky": {
-    "hooks": {
-      "pre-commit": "npm run lint:nofix"
-    }
+    "docs:build": "cd docs/api && tsc generate.ts --resolveJsonModule && node generate && cd ../..",
+    "prepare": "husky install"
   },
   "repository": {
     "type": "git",
@@ -35,65 +31,59 @@
   },
   "homepage": "https://github.com/TheDevs-Network/kutt#readme",
   "dependencies": {
-    "@sentry/apm": "^5.21.1",
-    "@sentry/node": "^5.21.1",
-    "@sentry/react": "^5.21.1",
-    "app-root-path": "^3.0.0",
-    "axios": "^0.21.1",
-    "babel-plugin-inline-react-svg": "^1.1.0",
+    "app-root-path": "^3.1.0",
+    "axios": "^1.1.3",
     "bcryptjs": "^2.4.3",
-    "bull": "^3.12.1",
-    "cookie-parser": "^1.4.4",
+    "bull": "^4.10.1",
+    "cookie-parser": "^1.4.6",
     "cors": "^2.8.5",
     "cross-env": "^7.0.3",
-    "date-fns": "^2.9.0",
-    "dotenv": "^8.2.0",
-    "easy-peasy": "^5.0.3",
-    "email-validator": "^1.2.3",
-    "envalid": "^6.0.0",
-    "express": "^4.17.1",
-    "express-async-handler": "^1.1.4",
-    "express-validator": "^6.3.1",
-    "geoip-lite": "^1.4.0",
-    "helmet": "^3.21.2",
-    "isbot": "^2.5.4",
-    "js-cookie": "^2.2.1",
-    "jsonwebtoken": "^8.4.0",
-    "jwt-decode": "^2.2.0",
-    "knex": "^0.21.1",
-    "morgan": "^1.9.1",
-    "ms": "^2.1.2",
-    "nanoid": "^1.3.4",
-    "neo4j-driver": "^1.7.6",
-    "next": "^9.4.4",
-    "node-cron": "^2.0.3",
-    "nodemailer": "^6.4.2",
-    "p-queue": "^6.2.1",
-    "passport": "^0.4.1",
+    "d3-color": "^3.1.0",
+    "date-fns": "^2.29.3",
+    "dotenv": "^16.0.3",
+    "easy-peasy": "^5.1.0",
+    "email-validator": "^2.0.4",
+    "envalid": "^7.3.1",
+    "express": "^4.18.2",
+    "express-async-handler": "1.1.4",
+    "express-validator": "^6.14.2",
+    "geoip-lite": "^1.4.6",
+    "helmet": "^6.0.0",
+    "ioredis": "^5.2.4",
+    "isbot": "^3.6.3",
+    "js-cookie": "^3.0.1",
+    "jsonwebtoken": "^8.5.1",
+    "jwt-decode": "^3.1.2",
+    "knex": "^2.3.0",
+    "morgan": "^1.10.0",
+    "ms": "^2.1.3",
+    "nanoid": "^2.1.11",
+    "next": "^12.3.3",
+    "node-cron": "^3.0.2",
+    "nodemailer": "^6.8.0",
+    "p-queue": "^7.3.0",
+    "passport": "^0.6.0",
     "passport-jwt": "^4.0.0",
     "passport-local": "^1.0.0",
     "passport-localapikey-update": "^0.6.0",
-    "pg": "^8.2.1",
-    "pg-query-stream": "^2.1.2",
-    "prop-types": "^15.7.2",
-    "qrcode.react": "^0.8.0",
-    "query-string": "^6.10.1",
-    "react": "^16.12.0",
-    "react-copy-to-clipboard": "^5.0.2",
-    "react-dom": "^16.12.0",
-    "react-ga": "^2.7.0",
-    "react-inlinesvg": "^1.2.0",
-    "react-tippy": "^1.3.1",
-    "react-tooltip": "^3.11.2",
-    "react-use-form-state": "^0.12.1",
-    "recharts": "^1.8.5",
-    "redis": "^3.1.1",
-    "reflexbox": "^4.0.6",
+    "pg": "^8.8.0",
+    "pg-query-stream": "^4.2.4",
+    "qrcode.react": "^3.1.0",
+    "query-string": "^7.1.1",
+    "re2": "^1.17.8",
+    "react": "^17.0.2",
+    "react-copy-to-clipboard": "^5.1.0",
+    "react-dom": "^17.0.2",
+    "react-inlinesvg": "^3.0.1",
+    "react-tooltip": "^4.5.0",
+    "react-use-form-state": "^0.13.2",
+    "rebass": "^4.0.7",
+    "recharts": "^2.1.16",
+    "redis": "^4.5.0",
     "signale": "^1.4.0",
-    "styled-components": "^5.0.0",
-    "styled-tools": "^1.7.1",
-    "universal-analytics": "^0.4.20",
-    "url-regex": "^4.1.1",
+    "styled-components": "^5.3.6",
+    "styled-tools": "^1.7.2",
+    "url-regex-safe": "^3.0.0",
     "use-media": "^1.4.0",
     "useragent": "^2.2.1",
     "uuid": "^3.4.0",
@@ -101,73 +91,62 @@
     "winston-daily-rotate-file": "^4.7.1"
   },
   "devDependencies": {
-    "@babel/cli": "^7.8.3",
-    "@babel/core": "^7.12.17",
-    "@babel/node": "^7.8.3",
-    "@babel/preset-env": "^7.12.17",
-    "@babel/register": "^7.8.3",
-    "@testing-library/jest-dom": "^5.11.9",
-    "@testing-library/react": "^11.2.5",
-    "@testing-library/user-event": "^12.8.3",
     "@types/bcryptjs": "^2.4.2",
-    "@types/body-parser": "^1.17.1",
-    "@types/bull": "^3.12.0",
-    "@types/chai": "^4.2.15",
-    "@types/cookie-parser": "^1.4.2",
-    "@types/cors": "^2.8.6",
-    "@types/date-fns": "^2.6.0",
-    "@types/dotenv": "^4.0.3",
-    "@types/express": "^4.17.2",
-    "@types/helmet": "0.0.38",
+    "@types/cookie-parser": "^1.4.3",
+    "@types/cors": "^2.8.12",
+    "@types/express": "^4.17.14",
     "@types/jest": "^26.0.20",
     "@types/jsonwebtoken": "^7.2.8",
-    "@types/jwt-decode": "^2.2.1",
-    "@types/mongodb": "^3.3.14",
     "@types/morgan": "^1.7.37",
     "@types/ms": "^0.7.31",
-    "@types/next": "^9.0.0",
+    "@types/nanoid": "^3.0.0",
+    "@types/node": "^18.11.9",
     "@types/node-cron": "^2.0.2",
-    "@types/nodemailer": "^6.4.0",
-    "@types/pg": "^7.14.1",
-    "@types/pg-query-stream": "^1.0.3",
-    "@types/qrcode.react": "^1.0.0",
-    "@types/react": "^16.9.17",
-    "@types/react-dom": "^16.9.4",
-    "@types/react-tooltip": "^3.11.0",
-    "@types/redis": "^2.8.14",
-    "@types/reflexbox": "^4.0.0",
-    "@types/sinon": "^9.0.10",
+    "@types/nodemailer": "^6.4.6",
+    "@types/pg": "^8.6.5",
+    "@types/qrcode.react": "^1.0.2",
+    "@types/react": "^17.0.52",
+    "@types/react-dom": "^17.0.18",
+    "@types/rebass": "^4.0.10",
+    "@types/signale": "^1.4.4",
     "@types/styled-components": "^5.1.7",
-    "@typescript-eslint/eslint-plugin": "^4.15.2",
-    "@typescript-eslint/parser": "^4.15.2",
-    "babel": "^6.23.0",
-    "babel-cli": "^6.26.0",
-    "babel-core": "^6.26.3",
-    "babel-eslint": "^8.2.6",
-    "babel-jest": "^26.6.3",
-    "babel-plugin-styled-components": "^1.10.6",
-    "babel-preset-env": "^1.7.0",
-    "chai": "^4.3.0",
-    "copyfiles": "^2.2.0",
-    "deep-freeze": "^0.0.1",
-    "eslint": "^5.16.0",
-    "eslint-config-airbnb": "^16.1.0",
-    "eslint-config-prettier": "^6.9.0",
-    "eslint-plugin-import": "^2.20.0",
-    "eslint-plugin-jsx-a11y": "^6.2.3",
-    "eslint-plugin-prettier": "^3.1.2",
-    "eslint-plugin-react": "^7.18.0",
-    "husky": "^0.15.0-rc.13",
-    "jest": "^26.6.3",
-    "mocha": "^5.2.0",
-    "nock": "^9.3.3",
-    "nodemon": "^1.19.4",
-    "prettier": "^1.19.1",
-    "redoc": "^2.0.0-rc.20",
-    "rimraf": "^3.0.0",
-    "sinon": "^6.0.0",
-    "ts-jest": "^26.5.1",
-    "ts-node": "^9.1.1",
-    "typescript": "^4.2.2"
+    "@typescript-eslint/eslint-plugin": "^5.42.1",
+    "@typescript-eslint/parser": "^5.42.1",
+    "copyfiles": "^2.4.1",
+    "eslint": "^8.27.0",
+    "eslint-config-next": "^13.0.3",
+    "eslint-config-prettier": "^8.5.0",
+    "eslint-plugin-prettier": "^4.2.1",
+    "husky": "^8.0.2",
+    "jest": "^29.3.1",
+    "nodemon": "^2.0.20",
+    "prettier": "^2.7.1",
+    "redoc": "^2.0.0",
+    "rimraf": "^3.0.2",
+    "ts-node": "^10.9.1",
+    "typescript": "^4.8.4"
+  },
+  "overrides": {
+    "react-use-form-state": {
+      "react": "*",
+      "react-dom": "*"
+    },
+    "redoc": {
+      "react": "*",
+      "react-dom": "*"
+    },
+    "use-media": {
+      "react": "*",
+      "react-dom": "*"
+    },
+    "react-transition-group": {
+      "react": "*",
+      "react-dom": "*"
+    },
+    "recharts": {
+      "react": "*",
+      "react-dom": "*",
+      "d3-color": "*"
+    }
   }
 }

+ 0 - 404
server/__v1/controllers/linkController.ts

@@ -1,404 +0,0 @@
-import bcrypt from "bcryptjs";
-import dns from "dns";
-import { Handler } from "express";
-import isbot from "isbot";
-import generate from "nanoid/generate";
-import ua from "universal-analytics";
-import URL from "url";
-import urlRegex from "url-regex";
-import { promisify } from "util";
-import { deleteDomain, getDomain, setDomain } from "../db/domain";
-import { addIP } from "../db/ip";
-import env from "../../env";
-import {
-  banLink,
-  createShortLink,
-  deleteLink,
-  findLink,
-  getLinks,
-  getStats,
-  getUserLinksCount
-} from "../db/link";
-import transporter from "../../mail/mail";
-import * as redis from "../../redis";
-import {
-  addProtocol,
-  generateShortLink,
-  getStatsCacheTime,
-  removeWww
-} from "../../utils";
-import {
-  checkBannedDomain,
-  checkBannedHost,
-  cooldownCheck,
-  malwareCheck,
-  preservedUrls,
-  urlCountsCheck
-} from "./validateBodyController";
-import queue from "../../queues";
-
-const dnsLookup = promisify(dns.lookup);
-
-const generateId = async () => {
-  const address = generate(
-    "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789",
-    env.LINK_LENGTH
-  );
-  const link = await findLink({ address });
-  if (!link) return address;
-  return generateId();
-};
-
-export const shortener: Handler = async (req, res) => {
-  try {
-    const target = addProtocol(req.body.target);
-    const targetDomain = removeWww(URL.parse(target).hostname);
-
-    const queries = await Promise.all([
-      env.GOOGLE_SAFE_BROWSING_KEY && cooldownCheck(req.user),
-      env.GOOGLE_SAFE_BROWSING_KEY && malwareCheck(req.user, req.body.target),
-      req.user && urlCountsCheck(req.user),
-      req.user &&
-        req.body.reuse &&
-        findLink({
-          target,
-          user_id: req.user.id
-        }),
-      req.user &&
-        req.body.customurl &&
-        findLink({
-          address: req.body.customurl,
-          domain_id: req.user.domain_id || null
-        }),
-      (!req.user || !req.body.customurl) && generateId(),
-      checkBannedDomain(targetDomain),
-      checkBannedHost(targetDomain)
-    ]);
-
-    // if "reuse" is true, try to return
-    // the existent URL without creating one
-    if (queries[3]) {
-      const { domain_id: d, user_id: u, ...link } = queries[3];
-      const shortLink = generateShortLink(link.address, req.user.domain);
-      const data = {
-        ...link,
-        id: link.address,
-        password: !!link.password,
-        reuse: true,
-        shortLink,
-        shortUrl: shortLink
-      };
-      return res.json(data);
-    }
-
-    // Check if custom link already exists
-    if (queries[4]) {
-      throw new Error("Custom URL is already in use.");
-    }
-
-    // Create new link
-    const address = (req.user && req.body.customurl) || queries[5];
-    const link = await createShortLink(
-      {
-        ...req.body,
-        address,
-        target
-      },
-      req.user
-    );
-    if (!req.user && env.NON_USER_COOLDOWN) {
-      addIP(req.realIP);
-    }
-
-    return res.json({ ...link, id: link.address });
-  } catch (error) {
-    return res.status(400).json({ error: error.message });
-  }
-};
-
-export const goToLink: Handler = async (req, res, next) => {
-  const host = removeWww(req.headers.host);
-  const requestedId = req.params.id || req.body.id;
-  const address = requestedId.replace("+", "");
-  const customDomain = host !== env.DEFAULT_DOMAIN && host;
-  const isBot = isbot(req.headers["user-agent"]);
-
-  let domain;
-  if (customDomain) {
-    domain = await getDomain({ address: customDomain });
-  }
-
-  const link = await findLink({ address, domain_id: domain && domain.id });
-
-  if (!link) {
-    if (host !== env.DEFAULT_DOMAIN) {
-      if (!domain || !domain.homepage) return next();
-      return res.redirect(302, domain.homepage);
-    }
-    return next();
-  }
-
-  if (link.banned) {
-    return res.redirect("/banned");
-  }
-
-  const doesRequestInfo = /.*\+$/gi.test(requestedId);
-  if (doesRequestInfo && !link.password) {
-    req.linkTarget = link.target;
-    req.pageType = "info";
-    return next();
-  }
-
-  if (link.password && !req.body.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" });
-    }
-    if (link.user_id && !isBot) {
-      queue.visit.add({
-        headers: req.headers,
-        realIP: req.realIP,
-        referrer: req.get("Referrer"),
-        link,
-        customDomain
-      });
-    }
-    return res.status(200).json({ target: link.target });
-  }
-
-  if (link.user_id && !isBot) {
-    queue.visit.add({
-      headers: req.headers,
-      realIP: req.realIP,
-      referrer: req.get("Referrer"),
-      link,
-      customDomain
-    });
-  }
-
-  if (env.GOOGLE_ANALYTICS_UNIVERSAL && !isBot) {
-    const visitor = ua(env.GOOGLE_ANALYTICS_UNIVERSAL);
-    visitor
-      .pageview({
-        dp: `/${address}`,
-        ua: req.headers["user-agent"],
-        uip: req.realIP,
-        aip: 1
-      })
-      .send();
-  }
-
-  return res.redirect(link.target);
-};
-
-export const getUserLinks: Handler = async (req, res) => {
-  const [countAll, list] = await Promise.all([
-    getUserLinksCount({ user_id: req.user.id }),
-    getLinks(req.user.id, req.query)
-  ]);
-  return res.json({ list, countAll: parseInt(countAll) });
-};
-
-export const setCustomDomain: Handler = async (req, res) => {
-  const parsed = URL.parse(req.body.customDomain);
-  const customDomain = removeWww(parsed.hostname || parsed.href);
-  if (!customDomain)
-    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." });
-  }
-  if (customDomain === env.DEFAULT_DOMAIN) {
-    return res.status(400).json({ error: "You can't use default domain." });
-  }
-  const isValidHomepage =
-    !req.body.homepage ||
-    urlRegex({ exact: true, strict: false }).test(req.body.homepage);
-  if (!isValidHomepage)
-    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({ address: customDomain });
-  if (
-    matchedDomain &&
-    matchedDomain.user_id &&
-    matchedDomain.user_id !== req.user.id
-  ) {
-    return res.status(400).json({
-      error: "Domain is already taken. Contact us for multiple users."
-    });
-  }
-  const userCustomDomain = await setDomain(
-    {
-      address: customDomain,
-      homepage
-    },
-    req.user,
-    matchedDomain
-  );
-  if (userCustomDomain) {
-    return res.status(201).json({
-      customDomain: userCustomDomain.address,
-      homepage: userCustomDomain.homepage
-    });
-  }
-  return res.status(400).json({ error: "Couldn't set custom domain." });
-};
-
-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(400).json({ error: "Couldn't delete custom domain." });
-};
-
-export const customDomainRedirection: Handler = async (req, res, next) => {
-  const { path } = req;
-  const host = removeWww(req.headers.host);
-  if (
-    host !== env.DEFAULT_DOMAIN &&
-    (path === "/" ||
-      preservedUrls
-        .filter(l => l !== "url-password")
-        .some(item => item === path.replace("/", "")))
-  ) {
-    const domain = await getDomain({ address: host });
-    return res.redirect(
-      302,
-      (domain && domain.homepage) || `https://${env.DEFAULT_DOMAIN + path}`
-    );
-  }
-  return next();
-};
-
-export const deleteUserLink: Handler = async (req, res) => {
-  const { id, domain } = req.body;
-
-  if (!id) {
-    return res.status(400).json({ error: "No id has been provided." });
-  }
-
-  const response = await deleteLink({
-    address: id,
-    domain: !domain || domain === env.DEFAULT_DOMAIN ? null : domain,
-    user_id: req.user.id
-  });
-
-  if (response) {
-    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 hostname = removeWww(URL.parse(req.query.domain).hostname);
-  const hasCustomDomain = req.query.domain && hostname !== 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({
-    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." });
-  }
-
-  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) => {
-  if (!req.body.link) {
-    return res.status(400).json({ error: "No URL has been provided." });
-  }
-
-  const hostname = removeWww(URL.parse(req.body.link).hostname);
-  if (hostname !== env.DEFAULT_DOMAIN) {
-    return res.status(400).json({
-      error: `You can only report a ${env.DEFAULT_DOMAIN} link`
-    });
-  }
-
-  const mail = await transporter.sendMail({
-    from: env.MAIL_FROM || env.MAIL_USER,
-    to: env.REPORT_MAIL,
-    subject: "[REPORT]",
-    text: req.body.link,
-    html: req.body.link
-  });
-  if (mail.accepted.length) {
-    return res
-      .status(200)
-      .json({ message: "Thanks for the report, we'll take actions shortly." });
-  }
-  return res
-    .status(400)
-    .json({ error: "Couldn't submit the report. Try again later." });
-};
-
-export const ban: Handler = async (req, res) => {
-  if (!req.body.id)
-    return res.status(400).json({ error: "No id has been provided." });
-
-  const link = await findLink({ address: req.body.id, domain_id: null });
-
-  if (!link) return res.status(400).json({ error: "Link does not exist." });
-
-  if (link.banned) {
-    return res.status(200).json({ message: "Link was banned already." });
-  }
-
-  const domain = removeWww(URL.parse(link.target).hostname);
-
-  let host;
-  if (req.body.host) {
-    try {
-      const dnsRes = await dnsLookup(domain);
-      host = dnsRes && dnsRes.address;
-    } catch (error) {
-      host = null;
-    }
-  }
-
-  await banLink({
-    adminId: req.user.id,
-    domain,
-    host,
-    address: req.body.id,
-    banUser: !!req.body.user
-  });
-
-  return res.status(200).json({ message: "Link has been banned successfully" });
-};

+ 0 - 223
server/__v1/controllers/validateBodyController.ts

@@ -1,223 +0,0 @@
-import { differenceInMinutes, subHours, subDays, isAfter } from "date-fns";
-import { validationResult } from "express-validator";
-import { body } from "express-validator";
-import { RequestHandler } from "express";
-import { promisify } from "util";
-import urlRegex from "url-regex";
-import axios from "axios";
-import dns from "dns";
-import URL from "url";
-
-import { addProtocol, CustomError, removeWww } from "../../utils";
-import { addCooldown, banUser } from "../db/user";
-import { getUserLinksCount } from "../db/link";
-import { getDomain } from "../db/domain";
-import { getHost } from "../db/host";
-import { getIP } from "../db/ip";
-import env from "../../env";
-
-const dnsLookup = promisify(dns.lookup);
-
-export const validationCriterias = [
-  body("email")
-    .exists()
-    .withMessage("Email must be provided.")
-    .isEmail()
-    .withMessage("Email is not valid.")
-    .trim(),
-  body("password", "Password must be at least 8 chars long.")
-    .exists()
-    .withMessage("Password must be provided.")
-    .isLength({ min: 8 })
-];
-
-export const validateBody = (req, res, next) => {
-  const errors = validationResult(req);
-  if (!errors.isEmpty()) {
-    const errorsObj = errors.mapped();
-    const emailError = errorsObj.email && errorsObj.email.msg;
-    const passwordError = errorsObj.password && errorsObj.password.msg;
-    return res.status(400).json({ error: emailError || passwordError });
-  }
-  return next();
-};
-
-export const preservedUrls = [
-  "login",
-  "logout",
-  "signup",
-  "reset-password",
-  "resetpassword",
-  "url-password",
-  "url-info",
-  "settings",
-  "stats",
-  "verify",
-  "api",
-  "404",
-  "static",
-  "images",
-  "banned",
-  "terms",
-  "privacy",
-  "protected",
-  "report",
-  "pricing"
-];
-
-export const validateUrl: RequestHandler = async (req, res, next) => {
-  // Validate URL existence
-  if (!req.body.target)
-    return res.status(400).json({ error: "No target has been provided." });
-
-  // validate URL length
-  if (req.body.target.length > 2040) {
-    return res.status(400).json({ error: "Maximum URL length is 2040." });
-  }
-
-  // Validate URL
-  const isValidUrl = urlRegex({ exact: true, strict: false }).test(
-    req.body.target
-  );
-  if (!isValidUrl && !/^\w+:\/\//.test(req.body.target))
-    return res.status(400).json({ error: "URL is not valid." });
-
-  // If target is the URL shortener itself
-  const host = removeWww(URL.parse(addProtocol(req.body.target)).host);
-  if (host === env.DEFAULT_DOMAIN) {
-    return res
-      .status(400)
-      .json({ error: `${env.DEFAULT_DOMAIN} URLs are not allowed.` });
-  }
-
-  // Validate password length
-  if (req.body.password && req.body.password.length > 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." });
-    }
-
-    // Prevent from using preserved URLs
-    if (preservedUrls.some(url => url === req.body.customurl)) {
-      return res
-        .status(400)
-        .json({ error: "You can't use this custom URL name." });
-    }
-
-    // Validate custom URL length
-    if (req.body.customurl.length > 64) {
-      return res
-        .status(400)
-        .json({ error: "Maximum custom URL length is 64." });
-    }
-  }
-
-  return next();
-};
-
-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 banned.");
-    }
-    const hasCooldownNow = user.cooldowns.some(cooldown =>
-      isAfter(subHours(new Date(), 12), new Date(cooldown))
-    );
-    if (hasCooldownNow) {
-      throw new Error("Cooldown because of a malware URL. Wait 12h");
-    }
-  }
-};
-
-export const ipCooldownCheck: RequestHandler = async (req, res, next) => {
-  const cooldownConfig = env.NON_USER_COOLDOWN;
-  if (req.user || !cooldownConfig) return next();
-  const ip = await getIP(req.realIP);
-  if (ip) {
-    const timeToWait =
-      cooldownConfig - differenceInMinutes(new Date(), new Date(ip.created_at));
-    return res.status(400).json({
-      error:
-        `Non-logged in users are limited. Wait ${timeToWait} ` +
-        "minutes or log in."
-    });
-  }
-  next();
-};
-
-export const malwareCheck = async (user: User, target: string) => {
-  const isMalware = await axios.post(
-    `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${env.GOOGLE_SAFE_BROWSING_KEY}`,
-    {
-      client: {
-        clientId: env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
-        clientVersion: "1.0.0"
-      },
-      threatInfo: {
-        threatTypes: [
-          "THREAT_TYPE_UNSPECIFIED",
-          "MALWARE",
-          "SOCIAL_ENGINEERING",
-          "UNWANTED_SOFTWARE",
-          "POTENTIALLY_HARMFUL_APPLICATION"
-        ],
-        platformTypes: ["ANY_PLATFORM", "PLATFORM_TYPE_UNSPECIFIED"],
-        threatEntryTypes: [
-          "EXECUTABLE",
-          "URL",
-          "THREAT_ENTRY_TYPE_UNSPECIFIED"
-        ],
-        threatEntries: [{ url: target }]
-      }
-    }
-  );
-  if (isMalware.data && isMalware.data.matches) {
-    if (user) {
-      await addCooldown(user.id);
-    }
-    throw new CustomError(
-      user ? "Malware detected! Cooldown for 12h." : "Malware detected!"
-    );
-  }
-};
-
-export const urlCountsCheck = async (user: User) => {
-  const count = await getUserLinksCount({
-    user_id: user.id,
-    date: subDays(new Date(), 1)
-  });
-  if (count > env.USER_LIMIT_PER_DAY) {
-    throw new CustomError(
-      `You have reached your daily limit (${env.USER_LIMIT_PER_DAY}). Please wait 24h.`
-    );
-  }
-};
-
-export const checkBannedDomain = async (domain: string) => {
-  const bannedDomain = await getDomain({ address: domain, banned: true });
-  if (bannedDomain) {
-    throw new CustomError("URL is containing malware/scam.");
-  }
-};
-
-export const checkBannedHost = async (domain: string) => {
-  let isHostBanned;
-  try {
-    const dnsRes = await dnsLookup(domain);
-    isHostBanned = await getHost({
-      address: dnsRes && dnsRes.address,
-      banned: true
-    });
-  } catch (error) {
-    isHostBanned = null;
-  }
-  if (isHostBanned) {
-    throw new CustomError("URL is containing malware/scam.");
-  }
-};

+ 0 - 114
server/__v1/db/domain.ts

@@ -1,114 +0,0 @@
-import knex from "../../knex";
-import * as redis from "../../redis";
-import { getRedisKey } from "../../utils";
-
-export const getDomain = async (data: Partial<Domain>): Promise<Domain> => {
-  const getData = {
-    ...data,
-    ...(data.address && { address: data.address.toLowerCase() }),
-    ...(data.homepage && { homepage: data.homepage.toLowerCase() })
-  };
-
-  const redisKey = getRedisKey.domain(getData.address);
-  const cachedDomain = await redis.get(redisKey);
-
-  if (cachedDomain) return JSON.parse(cachedDomain);
-
-  const domain = await knex<Domain>("domains")
-    .where(getData)
-    .first();
-
-  if (domain) {
-    redis.set(redisKey, JSON.stringify(domain), "EX", 60 * 60 * 6);
-  }
-
-  return 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(getRedisKey.user(user.email));
-  redis.del(getRedisKey.user(user.apikey));
-  redis.del(getRedisKey.domain(updateDate.address));
-
-  return domain;
-};
-
-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(getRedisKey.domain(domain.address));
-  }
-
-  redis.del(getRedisKey.user(user.email));
-  redis.del(getRedisKey.user(user.apikey));
-
-  return domain;
-};
-
-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(getRedisKey.domain(domain.address));
-  }
-
-  return domain;
-};

+ 0 - 51
server/__v1/db/host.ts

@@ -1,51 +0,0 @@
-import knex from "../../knex";
-import * as redis from "../../redis";
-import { getRedisKey } from "../../utils";
-
-export const getHost = async (data: Partial<Host>) => {
-  const getData = {
-    ...data,
-    ...(data.address && { address: data.address.toLowerCase() })
-  };
-
-  const redisKey = getRedisKey.host(getData.address);
-  const cachedHost = await redis.get(redisKey);
-
-  if (cachedHost) return JSON.parse(cachedHost);
-
-  const host = await knex<Host>("hosts")
-    .where(getData)
-    .first();
-
-  if (host) {
-    redis.set(redisKey, JSON.stringify(host), "EX", 60 * 60 * 6);
-  }
-
-  return host;
-};
-
-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 (currentHost) {
-    redis.del(getRedisKey.host(currentHost.address));
-  }
-
-  return currentHost;
-};

+ 0 - 47
server/__v1/db/ip.ts

@@ -1,47 +0,0 @@
-import { subMinutes } from "date-fns";
-
-import knex from "../../knex";
-import env from "../../env";
-
-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 });
-  }
-
-  return ip;
-};
-export const getIP = async (ip: string) => {
-  const cooldownConfig = env.NON_USER_COOLDOWN;
-  const matchedIp = await knex<IP>("ips")
-    .where({ ip: ip.toLowerCase() })
-    .andWhere(
-      "created_at",
-      ">",
-      subMinutes(new Date(), cooldownConfig).toISOString()
-    )
-    .first();
-
-  return matchedIp;
-};
-export const clearIPs = async () =>
-  knex<IP>("ips")
-    .where(
-      "created_at",
-      "<",
-      subMinutes(new Date(), env.NON_USER_COOLDOWN).toISOString()
-    )
-    .delete();

+ 0 - 519
server/__v1/db/link.ts

@@ -1,519 +0,0 @@
-import bcrypt from "bcryptjs";
-import { isAfter, subDays, set } from "date-fns";
-import knex from "../../knex";
-import * as redis from "../../redis";
-import {
-  generateShortLink,
-  getRedisKey,
-  getUTCDate,
-  getDifferenceFunction,
-  statsObjectToArray
-} from "../../utils";
-import { banDomain } from "./domain";
-import { banHost } from "./host";
-import { banUser } from "./user";
-
-interface CreateLink extends Link {
-  reuse?: boolean;
-  domainName?: string;
-}
-
-export const createShortLink = async (data: CreateLink, user: UserJoined) => {
-  const { id: user_id = null, domain, domain_id = null } =
-    user || ({} as UserJoined);
-  let password;
-
-  if (data.password) {
-    const salt = await bcrypt.genSalt(12);
-    password = await bcrypt.hash(data.password, salt);
-  }
-
-  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.address, domain),
-    shortUrl: generateShortLink(data.address, domain)
-  };
-};
-
-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: number;
-  os: string;
-  referrer: string;
-}
-
-export const createVisit = async (params: ICreateVisit) => {
-  const data = {
-    ...params,
-    country: params.country.toLowerCase(),
-    referrer: params.referrer.toLowerCase()
-  };
-
-  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) {
-    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
-    });
-  }
-
-  return visit;
-};
-
-interface IFindLink {
-  address?: string;
-  domain_id?: number | null;
-  user_id?: number | null;
-  target?: string;
-}
-
-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 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);
-  }
-
-  return link;
-};
-
-export const getUserLinksCount = async (params: {
-  user_id: number;
-  date?: Date;
-}) => {
-  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 {
-  count?: string;
-  page?: string;
-  search?: string;
-}
-
-export const getLinks = async (
-  user_id: number,
-  options: IGetLinksOptions = {}
-) => {
-  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",
-      "links.uuid",
-      "domains.address as domain"
-    )
-    .offset(offset)
-    .limit(limit)
-    .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.address, link.domain),
-    shortUrl: generateShortLink(link.address, link.domain)
-  }));
-
-  return links;
-};
-
-interface IDeleteLink {
-  address: string;
-  user_id: number;
-  domain?: string;
-}
-
-export const deleteLink = async (data: IDeleteLink) => {
-  const link: LinkJoinedDomain = await knex<LinkJoinedDomain>("links")
-    .select("links.id", "domains.address as domain")
-    .where("links.address", data.address)
-    .where("links.user_id", data.user_id)
-    .where({
-      ...(!data.domain && { domain_id: null })
-    })
-    .leftJoin("domains", "links.domain_id", "domains.id")
-    .first();
-
-  if (!link) return;
-
-  if (link.domain !== data.domain) {
-    return;
-  }
-
-  await knex<Visit>("visits")
-    .where("link_id", link.id)
-    .delete();
-
-  const deletedLink = await knex<Link>("links")
-    .where("id", link.id)
-    .delete();
-
-  redis.del(getRedisKey.link(link.address, link.domain_id, link.user_id));
-
-  return !!deletedLink;
-};
-
-/*
- ** Collecting stats
- */
-
-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 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"]
-];
-
-interface IGetStatsResponse {
-  allTime: StatsResult;
-  id: string;
-  lastDay: StatsResult;
-  lastMonth: StatsResult;
-  lastWeek: StatsResult;
-  shortLink: string;
-  shortUrl: string;
-  target: string;
-  total: number;
-  updatedAt: string;
-}
-
-export const getStats = async (link: Link, domain: Domain) => {
-  const stats = {
-    lastDay: {
-      stats: getInitStats(),
-      views: new Array(24).fill(0)
-    },
-    lastWeek: {
-      stats: getInitStats(),
-      views: new Array(7).fill(0)
-    },
-    lastMonth: {
-      stats: getInitStats(),
-      views: new Array(30).fill(0)
-    },
-    allTime: {
-      stats: getInitStats(),
-      views: new Array(18).fill(0)
-    }
-  };
-
-  const visitsStream: any = knex<Visit>("visits")
-    .where("link_id", link.id)
-    .stream();
-  const nowUTC = getUTCDate();
-  const now = new Date();
-
-  for await (const visit of visitsStream as Visit[]) {
-    STATS_PERIODS.forEach(([days, type]) => {
-      const isIncluded = isAfter(
-        new Date(visit.created_at),
-        subDays(nowUTC, days)
-      );
-      if (isIncluded) {
-        const diffFunction = getDifferenceFunction(type);
-        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.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.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.entries(visit.countries).reduce(
-              (obj, [country, count]) => ({
-                ...obj,
-                [country]: (period.country[country] || 0) + count
-              }),
-              {}
-            )
-          },
-          referrer: {
-            ...period.referrer,
-            ...Object.entries(visit.referrers).reduce(
-              (obj, [referrer, count]) => ({
-                ...obj,
-                [referrer]: (period.referrer[referrer] || 0) + count
-              }),
-              {}
-            )
-          }
-        };
-        stats[type].views[index] = view + visit.total;
-      }
-    });
-
-    const allTime = stats.allTime.stats;
-    const diffFunction = getDifferenceFunction("allTime");
-    const diff = diffFunction(
-      set(new Date(), { date: 1 }),
-      set(new Date(visit.created_at), { date: 1 })
-    );
-    const index = stats.allTime.views.length - diff - 1;
-    const view = stats.allTime.views[index];
-    stats.allTime.stats = {
-      browser: {
-        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.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.entries(visit.countries).reduce(
-          (obj, [country, count]) => ({
-            ...obj,
-            [country]: (allTime.country[country] || 0) + count
-          }),
-          {}
-        )
-      },
-      referrer: {
-        ...allTime.referrer,
-        ...Object.entries(visit.referrers).reduce(
-          (obj, [referrer, count]) => ({
-            ...obj,
-            [referrer]: (allTime.referrer[referrer] || 0) + count
-          }),
-          {}
-        )
-      }
-    };
-    stats.allTime.views[index] = view + visit.total;
-  }
-
-  const response: IGetStatsResponse = {
-    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.lastMonth.stats),
-      views: stats.lastMonth.views
-    },
-    lastWeek: {
-      stats: statsObjectToArray(stats.lastWeek.stats),
-      views: stats.lastWeek.views
-    },
-    shortLink: generateShortLink(link.address, domain.address),
-    shortUrl: generateShortLink(link.address, domain.address),
-    target: link.target,
-    total: link.visit_count,
-    updatedAt: new Date().toISOString()
-  };
-  return response;
-};
-
-interface IBanLink {
-  adminId?: number;
-  banUser?: boolean;
-  domain?: string;
-  host?: string;
-  address: string;
-}
-
-export const banLink = async (data: IBanLink) => {
-  const tasks = [];
-  const banned_by_id = data.adminId;
-
-  // Ban link
-  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 user, ban user and all of their links.
-  if (data.banUser && link.user_id) {
-    tasks.push(banUser(link.user_id, banned_by_id));
-    tasks.push(
-      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, banned_by_id));
-
-  // Ban domain
-  if (data.domain) tasks.push(banDomain(data.domain, banned_by_id));
-
-  redis.del(getRedisKey.link(link.address, link.domain_id, link.user_id));
-  redis.del(getRedisKey.link(link.address, link.domain_id));
-  redis.del(getRedisKey.link(link.address));
-
-  return Promise.all(tasks);
-};

+ 0 - 207
server/__v1/db/user.ts

@@ -1,207 +0,0 @@
-import bcrypt from "bcryptjs";
-import nanoid from "nanoid";
-import uuid from "uuid/v4";
-import { addMinutes } from "date-fns";
-
-import knex from "../../knex";
-import * as redis from "../../redis";
-import { getRedisKey } from "../../utils";
-
-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 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", "ILIKE", emailOrKey)
-    .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 (
-  emailToCreate: string,
-  password: string,
-  user?: User
-) => {
-  const email = emailToCreate.toLowerCase();
-  const salt = await bcrypt.genSalt(12);
-  const hashedPassword = await bcrypt.hash(password, salt);
-
-  const data = {
-    email,
-    password: hashedPassword,
-    verification_token: uuid(),
-    verification_expires: addMinutes(new Date(), 60).toISOString()
-  };
-
-  if (user) {
-    await knex<User>("users")
-      .where({ email })
-      .update({ ...data, updated_at: new Date().toISOString() });
-  } else {
-    await knex<User>("users").insert(data);
-  }
-
-  redis.del(getRedisKey.user(email));
-
-  return {
-    ...user,
-    ...data
-  };
-};
-
-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: number, newPassword: string) => {
-  const salt = await bcrypt.genSalt(12);
-  const password = await bcrypt.hash(newPassword, salt);
-
-  const [user]: User[] = await knex<User>("users")
-    .where({ id })
-    .update({ password, updated_at: new Date().toISOString() }, "*");
-
-  if (user) {
-    redis.del(getRedisKey.user(user.email));
-    redis.del(getRedisKey.user(user.apikey));
-  }
-
-  return user;
-};
-
-export const generateApiKey = async (id: number) => {
-  const apikey = nanoid(40);
-
-  const [user]: User[] = await knex<User>("users")
-    .where({ id })
-    .update({ apikey, updated_at: new Date().toISOString() }, "*");
-
-  if (user) {
-    redis.del(getRedisKey.user(user.email));
-    redis.del(getRedisKey.user(user.apikey));
-  }
-
-  return 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 (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: 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: 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;
-};

+ 0 - 64
server/__v1/index.ts

@@ -1,64 +0,0 @@
-import asyncHandler from "express-async-handler";
-import { Router } from "express";
-import cors from "cors";
-
-import {
-  validateUrl,
-  ipCooldownCheck
-} from "./controllers/validateBodyController";
-import * as auth from "../handlers/auth";
-import * as link from "./controllers/linkController";
-import env from "../env";
-
-const router = Router();
-
-/* URL shortener */
-router.post(
-  "/url/submit",
-  cors(),
-  asyncHandler(auth.apikey),
-  asyncHandler(env.DISALLOW_ANONYMOUS_LINKS ? auth.jwt : auth.jwtLoose),
-  asyncHandler(auth.recaptcha),
-  asyncHandler(validateUrl),
-  asyncHandler(ipCooldownCheck),
-  asyncHandler(link.shortener)
-);
-router.post(
-  "/url/deleteurl",
-  asyncHandler(auth.apikey),
-  asyncHandler(auth.jwt),
-  asyncHandler(link.deleteUserLink)
-);
-router.get(
-  "/url/geturls",
-  asyncHandler(auth.apikey),
-  asyncHandler(auth.jwt),
-  asyncHandler(link.getUserLinks)
-);
-router.post(
-  "/url/customdomain",
-  asyncHandler(auth.jwt),
-  asyncHandler(link.setCustomDomain)
-);
-router.delete(
-  "/url/customdomain",
-  asyncHandler(auth.jwt),
-  asyncHandler(link.deleteCustomDomain)
-);
-router.get(
-  "/url/stats",
-  asyncHandler(auth.apikey),
-  asyncHandler(auth.jwt),
-  asyncHandler(link.getLinkStats)
-);
-router.post("/url/requesturl", asyncHandler(link.goToLink));
-router.post("/url/report", asyncHandler(link.reportLink));
-router.post(
-  "/url/admin/ban",
-  asyncHandler(auth.apikey),
-  asyncHandler(auth.jwt),
-  asyncHandler(auth.admin),
-  asyncHandler(link.ban)
-);
-
-export default router;

+ 4 - 8
server/env.ts

@@ -1,5 +1,8 @@
+import * as dotenv from "dotenv";
 import { cleanEnv, num, str, bool } from "envalid";
 
+dotenv.config();
+
 const env = cleanEnv(process.env, {
   PORT: num({ default: 3000 }),
   SITE_NAME: str({ example: "Kutt" }),
@@ -13,9 +16,6 @@ const env = cleanEnv(process.env, {
   DB_SSL: bool({ default: false }),
   DB_POOL_MIN: num({ default: 2 }),
   DB_POOL_MAX: num({ default: 10 }),
-  NEO4J_DB_URI: str({ default: "" }),
-  NEO4J_DB_USERNAME: str({ default: "" }),
-  NEO4J_DB_PASSWORD: str({ default: "" }),
   REDIS_HOST: str({ default: "127.0.0.1" }),
   REDIS_PORT: num({ default: 6379 }),
   REDIS_PASSWORD: str({ default: "" }),
@@ -31,8 +31,6 @@ const env = cleanEnv(process.env, {
   RECAPTCHA_SITE_KEY: str({ default: "" }),
   RECAPTCHA_SECRET_KEY: str({ default: "" }),
   GOOGLE_SAFE_BROWSING_KEY: str({ default: "" }),
-  GOOGLE_ANALYTICS: str({ default: "" }),
-  GOOGLE_ANALYTICS_UNIVERSAL: str({ default: "" }),
   MAIL_HOST: str(),
   MAIL_PORT: num(),
   MAIL_SECURE: bool({ default: false }),
@@ -40,9 +38,7 @@ const env = cleanEnv(process.env, {
   MAIL_FROM: str({ default: "", example: "Kutt <support@kutt.it>" }),
   MAIL_PASSWORD: str(),
   REPORT_EMAIL: str({ default: "" }),
-  CONTACT_EMAIL: str({ default: "" }),
-  SENTRY_PRIVATE_DSN: str({ default: "" }),
-  SENTRY_PUBLIC_DSN: str({ default: "" })
+  CONTACT_EMAIL: str({ default: "" })
 });
 
 export default env;

+ 1 - 1
server/handlers/auth.ts

@@ -3,7 +3,7 @@ import { Handler } from "express";
 import passport from "passport";
 import bcrypt from "bcryptjs";
 import nanoid from "nanoid";
-import uuid from "uuid/v4";
+import { v4 as uuid } from "uuid";
 import axios from "axios";
 
 import { CustomError } from "../utils";

+ 28 - 12
server/handlers/helpers.ts

@@ -1,6 +1,5 @@
 import { Handler, ErrorRequestHandler } from "express";
 import { validationResult } from "express-validator";
-import * as Sentry from "@sentry/node";
 import signale from "signale";
 
 import { CustomError } from "../utils";
@@ -13,7 +12,8 @@ export const ip: Handler = (req, res, next) => {
   return next();
 };
 
-export const error: ErrorRequestHandler = (error, req, res, next) => {
+// eslint-disable-next-line
+export const error: ErrorRequestHandler = (error, _req, res, _next) => {
   logger.error(error);
 
   if (env.isDev) {
@@ -24,10 +24,6 @@ export const error: ErrorRequestHandler = (error, req, res, next) => {
     return res.status(error.statusCode || 500).json({ error: error.message });
   }
 
-  if (env.SENTRY_PRIVATE_DSN) {
-    Sentry.captureException(error);
-  }
-
   return res.status(500).json({ error: "An error occurred." });
 };
 
@@ -41,17 +37,37 @@ export const verify = (req, res, next) => {
 };
 
 export const query: Handler = (req, res, next) => {
-  const { limit, skip, all } = req.query;
   const { admin } = req.user || {};
 
-  req.query.limit = parseInt(limit) || 10;
-  req.query.skip = parseInt(skip) || 0;
+  if (
+    typeof req.query.limit !== "undefined" &&
+    typeof req.query.limit !== "string"
+  ) {
+    return res.status(400).json({ error: "limit query is not valid." });
+  }
+
+  if (
+    typeof req.query.skip !== "undefined" &&
+    typeof req.query.skip !== "string"
+  ) {
+    return res.status(400).json({ error: "skip query is not valid." });
+  }
 
-  if (req.query.limit > 50) {
-    req.query.limit = 50;
+  if (
+    typeof req.query.search !== "undefined" &&
+    typeof req.query.search !== "string"
+  ) {
+    return res.status(400).json({ error: "search query is not valid." });
   }
 
-  req.query.all = admin ? all === "true" : false;
+  const limit = parseInt(req.query.limit) || 10;
+  const skip = parseInt(req.query.skip) || 0;
+
+  req.context = {
+    limit: limit > 50 ? 50 : limit,
+    skip,
+    all: admin ? req.query.all === "true" : false
+  };
 
   next();
 };

+ 4 - 28
server/handlers/links.ts

@@ -1,4 +1,3 @@
-import ua from "universal-analytics";
 import { Handler } from "express";
 import { promisify } from "util";
 import bcrypt from "bcryptjs";
@@ -19,7 +18,8 @@ import env from "../env";
 const dnsLookup = promisify(dns.lookup);
 
 export const get: Handler = async (req, res) => {
-  const { limit, skip, search, all } = req.query;
+  const { limit, skip, all } = req.context;
+  const search = req.query.search as string;
   const userId = req.user.id;
 
   const match = {
@@ -310,19 +310,7 @@ export const redirect = (app: ReturnType<typeof next>): Handler => async (
     });
   }
 
-  // 8. Create Google Analytics visit
-  if (env.GOOGLE_ANALYTICS_UNIVERSAL && !isBot) {
-    ua(env.GOOGLE_ANALYTICS_UNIVERSAL)
-      .pageview({
-        dp: `/${address}`,
-        ua: req.headers["user-agent"],
-        uip: req.realIP,
-        aip: 1
-      })
-      .send();
-  }
-
-  // 10. Redirect to target
+  // 8. Redirect to target
   return res.redirect(link.target);
 };
 
@@ -353,19 +341,7 @@ export const redirectProtected: Handler = async (req, res) => {
     });
   }
 
-  // 5. Create Google Analytics visit
-  if (env.GOOGLE_ANALYTICS_UNIVERSAL) {
-    ua(env.GOOGLE_ANALYTICS_UNIVERSAL)
-      .pageview({
-        dp: `/${link.address}`,
-        ua: req.headers["user-agent"],
-        uip: req.realIP,
-        aip: 1
-      })
-      .send();
-  }
-
-  // 6. Send target
+  // 5. Send target
   return res.status(200).send({ target: link.target });
 };
 

+ 1 - 1
server/handlers/validators.ts

@@ -1,6 +1,6 @@
 import { body, param } from "express-validator";
 import { isAfter, subDays, subHours, addMilliseconds } from "date-fns";
-import urlRegex from "url-regex";
+import urlRegex from "url-regex-safe";
 import { promisify } from "util";
 import bcrypt from "bcryptjs";
 import axios from "axios";

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

@@ -1,68 +0,0 @@
-import env from "../env";
-
-import { v1 as NEO4J } from "neo4j-driver";
-import knex from "knex";
-import PQueue from "p-queue";
-
-const queue = new PQueue({ concurrency: 10 });
-
-// 1. Connect to Neo4j database
-const neo4j = NEO4J.driver(
-  env.NEO4J_DB_URI,
-  NEO4J.auth.basic(env.NEO4J_DB_USERNAME, env.NEO4J_DB_PASSWORD)
-);
-// 2. Connect to Postgres database
-const postgres = knex({
-  client: "postgres",
-  connection: {
-    host: env.DB_HOST,
-    database: env.DB_NAME,
-    user: env.DB_USER,
-    password: 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;
-    }
-  });
-})();

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

@@ -1,86 +0,0 @@
-import env from "../env";
-
-import { v1 as NEO4J } from "neo4j-driver";
-import PQueue from "p-queue";
-import knex from "knex";
-
-const queue = new PQueue({ concurrency: 10 });
-
-// 1. Connect to Neo4j database
-const neo4j = NEO4J.driver(
-  env.NEO4J_DB_URI,
-  NEO4J.auth.basic(env.NEO4J_DB_USERNAME, env.NEO4J_DB_PASSWORD)
-);
-// 2. Connect to Postgres database
-const postgres = knex({
-  client: "postgres",
-  connection: {
-    host: env.DB_HOST,
-    database: env.DB_NAME,
-    user: env.DB_USER,
-    password: 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;
-      }
-    });
-})();

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

@@ -1,89 +0,0 @@
-import env from "../env";
-
-import { v1 as NEO4J } from "neo4j-driver";
-import PQueue from "p-queue";
-import knex from "knex";
-
-const queue = new PQueue({ concurrency: 1 });
-
-// 1. Connect to Neo4j database
-const neo4j = NEO4J.driver(
-  env.NEO4J_DB_URI,
-  NEO4J.auth.basic(env.NEO4J_DB_USERNAME, env.NEO4J_DB_PASSWORD)
-);
-// 2. Connect to Postgres database
-const postgres = knex({
-  client: "postgres",
-  connection: {
-    host: env.DB_HOST,
-    database: env.DB_NAME,
-    user: env.DB_USER,
-    password: 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) {
-        console.log(error);
-        session.close();
-        throw error;
-      }
-    });
-})();

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

@@ -1,196 +0,0 @@
-import env from "../env";
-
-import { v1 as NEO4J } from "neo4j-driver";
-import { startOfHour } from "date-fns";
-import PQueue from "p-queue";
-import knex from "knex";
-
-let count = 0;
-const queue = new PQueue({ concurrency: 5 });
-
-queue.on("active", () => (count % 1000 === 0 ? console.log(count++) : count++));
-
-// 1. Connect to Neo4j database
-const neo4j = NEO4J.driver(
-  env.NEO4J_DB_URI,
-  NEO4J.auth.basic(env.NEO4J_DB_USERNAME, env.NEO4J_DB_PASSWORD)
-);
-
-// 2. Connect to Postgres database
-const postgres = knex({
-  client: "postgres",
-  connection: {
-    host: env.DB_HOST,
-    database: env.DB_NAME,
-    user: env.DB_USER,
-    password: env.DB_PASSWORD
-  }
-});
-
-(async function() {
-  const startTime = Date.now();
-
-  // 3. [NEO4J] Get all links
-  const session = neo4j.session();
-  const { records } = await session.run(
-    "MATCH (l:URL) WITH COUNT(l) as count RETURN count"
-  );
-  const total = records[0].get("count").toNumber();
-  const limit = 20000;
-
-  function main(index = 0) {
-    queue.add(
-      () =>
-        new Promise((resolve, reject) => {
-          session
-            .run(
-              "MATCH (l:URL) WITH l SKIP $skip LIMIT $limit " +
-                "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 " +
-                "RETURN l, u.email as email, d.name as domain, stats",
-              { limit: limit, skip: index * limit }
-            )
-            .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]) => {
-                    if (b && o && country && referrer && date) {
-                      const dateHour = startOfHour(
-                        new Date(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].referrers[
-                                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 res = await postgres<Link>("links").insert(data, "id");
-                  const link_id = res[0];
-
-                  // 7. [Postgres] Create visits
-                  const newVisits = Object.entries(visits).map(
-                    ([date, details]) => ({
-                      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
-                    })
-                  );
-
-                  await postgres<Visit>("visits").insert(newVisits);
-                });
-              },
-              onCompleted() {
-                session.close();
-                if ((index + 1) * limit < total) {
-                  queue.add(() => main(index + 1));
-                } else {
-                  queue.add(() => {
-                    const endTime = Date.now();
-                    console.log(
-                      `✅ Done! It took ${(endTime - startTime) /
-                        1000} seconds.`
-                    );
-                  });
-                }
-                resolve(null);
-              },
-              onError(error) {
-                session.close();
-                if ((index + 1) * limit < total) {
-                  queue.add(() => main(index + 1));
-                }
-                reject(error);
-              }
-            });
-        })
-    );
-  }
-  main();
-})();

+ 0 - 62
server/migration/neo4j_delete_duplicated.ts

@@ -1,62 +0,0 @@
-import env from "../env";
-
-import { v1 as NEO4J } from "neo4j-driver";
-import PQueue from "p-queue";
-
-let count = 0;
-const queue = new PQueue({ concurrency: 1 });
-queue.on("active", () => console.log(count++));
-
-// 1. Connect to Neo4j database
-const neo4j = NEO4J.driver(
-  env.NEO4J_DB_URI,
-  NEO4J.auth.basic(env.NEO4J_DB_USERNAME, env.NEO4J_DB_PASSWORD)
-);
-
-(async function() {
-  const startTime = Date.now();
-
-  const nodes = [
-    ["VISITED_IN", "DATE"]
-    // ['BROWSED_BY', 'BROWSER'],
-    // ['OS', 'OS'],
-    // ['LOCATED_IN', 'COUNTRY'],
-    // ['REFERRED_BY', 'REFERRER'],
-  ];
-
-  // 3. [NEO4J] Get all hosts
-  const session = neo4j.session();
-  const { records } = await session.run(
-    "MATCH (v:VISIT) WITH COUNT(v) as count RETURN count;"
-  );
-  const total = records[0].get("count").toNumber();
-  const limit = 100000;
-
-  function main(index = 0) {
-    nodes.forEach(([r, n]) => {
-      queue.add(() => {
-        return session.run(`
-          MATCH (a:VISIT)-[r:${r}]->(b:${n})
-          WITH a, r, b SKIP ${index * limit} LIMIT ${limit}
-          WITH a, b, TYPE(r) AS t, COLLECT(r) AS rr
-          WHERE SIZE(rr) > 1
-          WITH rr
-          FOREACH (r IN TAIL(rr) | DELETE r);
-        `);
-      });
-    });
-
-    if ((index + 1) * limit < total) {
-      main(index + 1);
-    } else {
-      queue.add(() => {
-        const endTime = Date.now();
-        console.log(
-          `✅ Done! It took ${(endTime - startTime) / 1000} seconds.`
-        );
-      });
-    }
-  }
-
-  main();
-})();

+ 2 - 2
server/migrations/20200211220920_constraints.ts

@@ -1,4 +1,4 @@
-import * as Knex from "knex";
+import { Knex } from "knex";
 import * as models from "../models";
 
 export async function up(knex: Knex): Promise<any> {
@@ -37,6 +37,6 @@ export async function up(knex: Knex): Promise<any> {
   ]);
 }
 
-export async function down(knex: Knex): Promise<any> {
+export async function down(): Promise<any> {
   // do nothing
 }

+ 2 - 2
server/migrations/20200510140704_domains.ts

@@ -1,4 +1,4 @@
-import * as Knex from "knex";
+import { Knex } from "knex";
 import * as models from "../models";
 
 export async function up(knex: Knex): Promise<any> {
@@ -21,6 +21,6 @@ export async function up(knex: Knex): Promise<any> {
   ]);
 }
 
-export async function down(knex: Knex): Promise<any> {
+export async function down(): Promise<any> {
   // do nothing
 }

+ 1 - 1
server/migrations/20200718124944_description.ts

@@ -1,4 +1,4 @@
-import * as Knex from "knex";
+import { Knex } from "knex";
 
 export async function up(knex: Knex): Promise<any> {
   const hasDescription = await knex.schema.hasColumn("links", "description");

+ 1 - 1
server/migrations/20200730203154_expire_in.ts

@@ -1,4 +1,4 @@
-import * as Knex from "knex";
+import { Knex } from "knex";
 
 export async function up(knex: Knex): Promise<any> {
   const hasExpireIn = await knex.schema.hasColumn("links", "expire_in");

+ 1 - 1
server/migrations/20200810195255_change_email.ts

@@ -1,4 +1,4 @@
-import * as Knex from "knex";
+import { Knex } from "knex";
 
 export async function up(knex: Knex): Promise<any> {
   const hasChangeEmail = await knex.schema.hasColumn(

+ 1 - 1
server/models/domain.ts

@@ -1,4 +1,4 @@
-import * as Knex from "knex";
+import { Knex } from "knex";
 
 export async function createDomainTable(knex: Knex) {
   const hasTable = await knex.schema.hasTable("domains");

+ 1 - 1
server/models/host.ts

@@ -1,4 +1,4 @@
-import * as Knex from "knex";
+import { Knex } from "knex";
 
 export async function createHostTable(knex: Knex) {
   const hasTable = await knex.schema.hasTable("hosts");

+ 1 - 1
server/models/ip.ts

@@ -1,4 +1,4 @@
-import * as Knex from "knex";
+import { Knex } from "knex";
 
 export async function createIPTable(knex: Knex) {
   const hasTable = await knex.schema.hasTable("ips");

+ 1 - 1
server/models/link.ts

@@ -1,4 +1,4 @@
-import * as Knex from "knex";
+import { Knex } from "knex";
 
 export async function createLinkTable(knex: Knex) {
   const hasTable = await knex.schema.hasTable("links");

+ 1 - 1
server/models/user.ts

@@ -1,4 +1,4 @@
-import * as Knex from "knex";
+import { Knex } from "knex";
 
 export async function createUserTable(knex: Knex) {
   const hasTable = await knex.schema.hasTable("users");

+ 1 - 1
server/models/visit.ts

@@ -1,4 +1,4 @@
-import * as Knex from "knex";
+import { Knex } from "knex";
 
 export async function createVisitTable(knex: Knex) {
   const hasTable = await knex.schema.hasTable("visits");

+ 3 - 3
server/queries/domain.ts

@@ -1,9 +1,9 @@
-import * as redis from "../redis";
+import redisClient, * as redis from "../redis";
 import knex from "../knex";
 
 export const find = async (match: Partial<Domain>): Promise<Domain> => {
   if (match.address) {
-    const cachedDomain = await redis.get(redis.key.domain(match.address));
+    const cachedDomain = await redisClient.get(redis.key.domain(match.address));
     if (cachedDomain) return JSON.parse(cachedDomain);
   }
 
@@ -12,7 +12,7 @@ export const find = async (match: Partial<Domain>): Promise<Domain> => {
     .first();
 
   if (domain) {
-    redis.set(
+    redisClient.set(
       redis.key.domain(domain.address),
       JSON.stringify(domain),
       "EX",

+ 3 - 3
server/queries/host.ts

@@ -1,4 +1,4 @@
-import * as redis from "../redis";
+import redisClient, * as redis from "../redis";
 import knex from "../knex";
 
 interface Add extends Partial<Host> {
@@ -7,7 +7,7 @@ interface Add extends Partial<Host> {
 
 export const find = async (match: Partial<Host>): Promise<Host> => {
   if (match.address) {
-    const cachedHost = await redis.get(redis.key.host(match.address));
+    const cachedHost = await redisClient.get(redis.key.host(match.address));
     if (cachedHost) return JSON.parse(cachedHost);
   }
 
@@ -16,7 +16,7 @@ export const find = async (match: Partial<Host>): Promise<Host> => {
     .first();
 
   if (host) {
-    redis.set(
+    redisClient.set(
       redis.key.host(host.address),
       JSON.stringify(host),
       "EX",

+ 3 - 1
server/queries/index.ts

@@ -5,7 +5,7 @@ import * as user from "./user";
 import * as host from "./host";
 import * as ip from "./ip";
 
-export default {
+const queries = {
   domain,
   host,
   ip,
@@ -13,3 +13,5 @@ export default {
   user,
   visit
 };
+
+export default queries;

+ 3 - 3
server/queries/link.ts

@@ -1,7 +1,7 @@
 import bcrypt from "bcryptjs";
 
 import { CustomError } from "../utils";
-import * as redis from "../redis";
+import redisClient, * as redis from "../redis";
 import knex from "../knex";
 
 const selectable = [
@@ -96,7 +96,7 @@ export const get = async (match: Partial<Link>, params: GetParams) => {
 export const find = async (match: Partial<Link>): Promise<Link> => {
   if (match.address && match.domain_id) {
     const key = redis.key.link(match.address, match.domain_id);
-    const cachedLink = await redis.get(key);
+    const cachedLink = await redisClient.get(key);
     if (cachedLink) return JSON.parse(cachedLink);
   }
 
@@ -108,7 +108,7 @@ export const find = async (match: Partial<Link>): Promise<Link> => {
 
   if (link) {
     const key = redis.key.link(link.address, link.domain_id);
-    redis.set(key, JSON.stringify(link), "EX", 60 * 60 * 2);
+    redisClient.set(key, JSON.stringify(link), "EX", 60 * 60 * 2);
   }
 
   return link;

+ 7 - 11
server/queries/user.ts

@@ -1,27 +1,25 @@
-import uuid from "uuid/v4";
+import { v4 as uuid } from "uuid";
 import { addMinutes } from "date-fns";
 
-import * as redis from "../redis";
+import redisCLient, * as redis from "../redis";
 import knex from "../knex";
 
 export const find = async (match: Partial<User>) => {
   if (match.email || match.apikey) {
     const key = redis.key.user(match.email || match.apikey);
-    const cachedUser = await redis.get(key);
+    const cachedUser = await redisCLient.get(key);
     if (cachedUser) return JSON.parse(cachedUser) as User;
   }
 
-  const user = await knex<User>("users")
-    .where(match)
-    .first();
+  const user = await knex<User>("users").where(match).first();
 
   if (user) {
     const emailKey = redis.key.user(user.email);
-    redis.set(emailKey, JSON.stringify(user), "EX", 60 * 60 * 1);
+    redisCLient.set(emailKey, JSON.stringify(user), "EX", 60 * 60 * 1);
 
     if (user.apikey) {
       const apikeyKey = redis.key.user(user.apikey);
-      redis.set(apikeyKey, JSON.stringify(user), "EX", 60 * 60 * 1);
+      redisCLient.set(apikeyKey, JSON.stringify(user), "EX", 60 * 60 * 1);
     }
   }
 
@@ -75,9 +73,7 @@ export const update = async (match: Match<User>, update: Partial<User>) => {
 };
 
 export const remove = async (user: User) => {
-  const deletedUser = await knex<User>("users")
-    .where("id", user.id)
-    .delete();
+  const deletedUser = await knex<User>("users").where("id", user.id).delete();
 
   redis.remove.user(user);
 

+ 5 - 7
server/queries/visit.ts

@@ -1,7 +1,7 @@
 import { isAfter, subDays, set } from "date-fns";
 
 import * as utils from "../utils";
-import * as redis from "../redis";
+import redisClient, * as redis from "../redis";
 import knex from "../knex";
 
 interface Add {
@@ -81,7 +81,7 @@ interface IGetStatsResponse {
 export const find = async (match: Partial<Visit>, total: number) => {
   if (match.link_id) {
     const key = redis.key.stats(match.link_id);
-    const cached = await redis.get(key);
+    const cached = await redisClient.get(key);
     if (cached) return JSON.parse(cached);
   }
 
@@ -104,9 +104,7 @@ export const find = async (match: Partial<Visit>, total: number) => {
     }
   };
 
-  const visitsStream: any = knex<Visit>("visits")
-    .where(match)
-    .stream();
+  const visitsStream: any = knex<Visit>("visits").where(match).stream();
   const nowUTC = utils.getUTCDate();
   const now = new Date();
 
@@ -118,7 +116,7 @@ export const find = async (match: Partial<Visit>, total: number) => {
       );
       if (isIncluded) {
         const diffFunction = utils.getDifferenceFunction(type);
-        const diff = diffFunction(now, visit.created_at);
+        const diff = diffFunction(now, new Date(visit.created_at));
         const index = stats[type].views.length - diff - 1;
         const view = stats[type].views[index];
         const period = stats[type].stats;
@@ -238,7 +236,7 @@ export const find = async (match: Partial<Visit>, total: number) => {
   if (match.link_id) {
     const cacheTime = utils.getStatsCacheTime(total);
     const key = redis.key.stats(match.link_id);
-    redis.set(key, JSON.stringify(response), "EX", cacheTime);
+    redisClient.set(key, JSON.stringify(response), "EX", cacheTime);
   }
 
   return response;

+ 3 - 1
server/queues/index.ts

@@ -1,5 +1,7 @@
 import { visit } from "./queues";
 
-export default {
+const queues = {
   visit
 };
+
+export default queues;

+ 3 - 3
server/queues/visit.ts

@@ -7,12 +7,12 @@ import { getStatsLimit, removeWww } from "../utils";
 
 const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"];
 const osList = ["Windows", "Mac OS", "Linux", "Android", "iOS"];
-const filterInBrowser = agent => item =>
+const filterInBrowser = (agent) => (item) =>
   agent.family.toLowerCase().includes(item.toLocaleLowerCase());
-const filterInOs = agent => item =>
+const filterInOs = (agent) => (item) =>
   agent.os.family.toLowerCase().includes(item.toLocaleLowerCase());
 
-export default function({ data }) {
+export default function visit({ data }) {
   const tasks = [];
 
   tasks.push(query.link.incrementVisit({ id: data.link.id }));

+ 10 - 22
server/redis.ts

@@ -1,29 +1,15 @@
-import { promisify } from "util";
-import redis from "redis";
+import Redis from "ioredis";
 
 import env from "./env";
 
-const client = redis.createClient({
+const client = new Redis({
   host: env.REDIS_HOST,
   port: env.REDIS_PORT,
   db: env.REDIS_DB,
   ...(env.REDIS_PASSWORD && { password: env.REDIS_PASSWORD })
 });
 
-export const get: (key: string) => Promise<any> = promisify(client.get).bind(
-  client
-);
-
-export const set: (
-  key: string,
-  value: string,
-  ex?: string,
-  exValue?: number
-) => Promise<any> = promisify(client.set).bind(client);
-
-export const del: (key: string) => Promise<any> = promisify(client.del).bind(
-  client
-);
+export default client;
 
 export const key = {
   link: (address: string, domain_id?: number, user_id?: number) =>
@@ -37,19 +23,21 @@ export const key = {
 export const remove = {
   domain: (domain?: Domain) => {
     if (!domain) return;
-    del(key.domain(domain.address));
+    return client.del(key.domain(domain.address));
   },
   host: (host?: Host) => {
     if (!host) return;
-    del(key.host(host.address));
+    return client.del(key.host(host.address));
   },
   link: (link?: Link) => {
     if (!link) return;
-    del(key.link(link.address, link.domain_id));
+    return client.del(key.link(link.address, link.domain_id));
   },
   user: (user?: User) => {
     if (!user) return;
-    del(key.user(user.email));
-    del(key.user(user.apikey));
+    return Promise.all([
+      client.del(key.user(user.email)),
+      client.del(key.user(user.apikey))
+    ]);
   }
 };

+ 2 - 18
server/server.ts

@@ -7,12 +7,10 @@ import express from "express";
 import helmet from "helmet";
 import morgan from "morgan";
 import nextApp from "next";
-import * as Sentry from "@sentry/node";
 
 import * as helpers from "./handlers/helpers";
 import * as links from "./handlers/links";
 import * as auth from "./handlers/auth";
-import __v1Routes from "./__v1";
 import routes from "./routes";
 import { stream } from "./config/winston";
 
@@ -30,21 +28,9 @@ app.prepare().then(async () => {
 
   if (env.isDev) {
     server.use(morgan("combined", { stream }));
-  } else if (env.SENTRY_PRIVATE_DSN) {
-    Sentry.init({
-      dsn: env.SENTRY_PRIVATE_DSN,
-      environment: process.env.NODE_ENV
-    });
-
-    server.use(
-      Sentry.Handlers.requestHandler({
-        ip: true,
-        user: ["id", "email"]
-      })
-    );
   }
 
-  server.use(helmet());
+  server.use(helmet({ contentSecurityPolicy: false }));
   server.use(cookieParser());
   server.use(express.json());
   server.use(express.urlencoded({ extended: true }));
@@ -55,7 +41,6 @@ app.prepare().then(async () => {
   server.use(asyncHandler(links.redirectCustomDomain));
 
   server.use("/api/v2", routes);
-  server.use("/api", __v1Routes);
 
   server.get(
     "/reset-password/:resetPasswordToken?",
@@ -83,8 +68,7 @@ app.prepare().then(async () => {
   // Handler everything else by Next.js
   server.get("*", (req, res) => handle(req, res));
 
-  server.listen(port, err => {
-    if (err) throw err;
+  server.listen(port, () => {
     console.log(`> Ready on http://localhost:${port}`);
   });
 });

+ 6 - 6
server/utils/index.ts

@@ -1,5 +1,5 @@
 import ms from "ms";
-import generate from "nanoid/generate";
+import nanoid from "nanoid/generate";
 import JWT from "jsonwebtoken";
 import {
   differenceInDays,
@@ -24,7 +24,7 @@ export class CustomError extends Error {
 
 export const isAdmin = (email: string): boolean =>
   env.ADMIN_EMAILS.split(",")
-    .map(e => e.trim())
+    .map((e) => e.trim())
     .includes(email);
 
 export const signToken = (user: UserJoined) =>
@@ -41,7 +41,7 @@ export const signToken = (user: UserJoined) =>
   );
 
 export const generateId = async (domain_id: number = null) => {
-  const address = generate(
+  const address = nanoid(
     "abcdefghkmnpqrstuvwxyzABCDEFGHKLMNPQRSTUVWXYZ23456789",
     env.LINK_LENGTH
   );
@@ -79,9 +79,9 @@ export const getStatsCacheTime = (total?: number): number => {
 };
 
 export const statsObjectToArray = (obj: Stats) => {
-  const objToArr = key =>
+  const objToArr = (key) =>
     Array.from(Object.keys(obj[key]))
-      .map(name => ({
+      .map((name) => ({
         name,
         value: obj[key][name]
       }))
@@ -97,7 +97,7 @@ export const statsObjectToArray = (obj: Stats) => {
 
 export const getDifferenceFunction = (
   type: "lastDay" | "lastWeek" | "lastMonth" | "allTime"
-): Function => {
+) => {
   if (type === "lastDay") return differenceInHours;
   if (type === "lastWeek") return differenceInDays;
   if (type === "lastMonth") return differenceInDays;

+ 15 - 18
tsconfig.json

@@ -1,19 +1,16 @@
 {
-	"compilerOptions": {
-		"target": "es2018",
-		"module": "commonjs",
-		"sourceMap": true,
-		"outDir": "production-server",
-		"noUnusedLocals": true,
-		"resolveJsonModule": true,
-		"esModuleInterop": true,
-		"noEmit": false,
-		"emitDecoratorMetadata": true,
-		"experimentalDecorators": true,
-		"strict": false
-	},
-	"include": [
-		"global.d.ts",
-		"server"
-	]
-}
+  "compilerOptions": {
+    "target": "es2018",
+    "module": "commonjs",
+    "sourceMap": true,
+    "outDir": "production-server",
+    "noUnusedLocals": true,
+    "resolveJsonModule": true,
+    "esModuleInterop": true,
+    "noEmit": false,
+    "emitDecoratorMetadata": true,
+    "experimentalDecorators": true,
+    "strict": false
+  },
+  "include": ["global.d.ts", "server"]
+}

Some files were not shown because too many files changed in this diff