poeti8 6 år sedan
förälder
incheckning
25903bf3cd
72 ändrade filer med 3599 tillägg och 1703 borttagningar
  1. 1 1
      .eslintrc
  2. 4 2
      client/components/Icon/Icon.tsx
  3. 22 0
      client/components/Icon/Stop.tsx
  4. 228 123
      client/components/LinksTable.tsx
  5. 0 83
      client/components/Settings/SettingsBan.tsx
  6. 16 14
      client/components/Settings/SettingsDomain.tsx
  7. 2 2
      client/components/Settings/SettingsPassword.tsx
  8. 3 3
      client/components/Shortener.tsx
  9. 13 14
      client/components/Text.tsx
  10. 10 12
      client/consts/consts.ts
  11. 2 2
      client/pages/login.tsx
  12. 19 15
      client/pages/protected/[id].tsx
  13. 2 2
      client/pages/report.tsx
  14. 2 2
      client/pages/reset-password.tsx
  15. 5 13
      client/pages/settings.tsx
  16. 3 5
      client/pages/stats.tsx
  17. 5 14
      client/pages/url-info.tsx
  18. 3 3
      client/store/auth.ts
  19. 28 4
      client/store/links.ts
  20. 35 27
      client/store/settings.ts
  21. 35 0
      global.d.ts
  22. 1013 357
      package-lock.json
  23. 57 55
      package.json
  24. 24 26
      server/__v1/controllers/linkController.ts
  25. 16 14
      server/__v1/controllers/validateBodyController.ts
  26. 3 3
      server/__v1/db/domain.ts
  27. 3 3
      server/__v1/db/host.ts
  28. 4 6
      server/__v1/db/ip.ts
  29. 3 3
      server/__v1/db/link.ts
  30. 3 3
      server/__v1/db/user.ts
  31. 63 0
      server/__v1/index.ts
  32. 0 62
      server/configToEnv.ts
  33. 0 274
      server/controllers/authController.ts
  34. 4 3
      server/cron.ts
  35. 41 0
      server/env.ts
  36. 127 11
      server/handlers/auth.ts
  37. 31 0
      server/handlers/domains.ts
  38. 40 1
      server/handlers/helpers.ts
  39. 335 96
      server/handlers/links.ts
  40. 0 21
      server/handlers/sanitizers.ts
  41. 11 0
      server/handlers/types.d.ts
  42. 14 0
      server/handlers/users.ts
  43. 305 18
      server/handlers/validators.ts
  44. 8 6
      server/knex.ts
  45. 1 0
      server/mail/index.ts
  46. 61 5
      server/mail/mail.ts
  47. 8 7
      server/migration/01_host.ts
  48. 9 8
      server/migration/02_users.ts
  49. 9 8
      server/migration/03_domains.ts
  50. 10 9
      server/migration/04_links.ts
  51. 4 3
      server/migration/neo4j_delete_duplicated.ts
  52. 12 0
      server/models/domain.ts
  53. 8 7
      server/passport.ts
  54. 71 21
      server/queries/domain.ts
  55. 62 0
      server/queries/host.ts
  56. 15 0
      server/queries/index.ts
  57. 35 0
      server/queries/ip.ts
  58. 117 96
      server/queries/link.ts
  59. 75 0
      server/queries/user.ts
  60. 245 0
      server/queries/visit.ts
  61. 5 1
      server/queues/index.ts
  62. 9 7
      server/queues/queues.ts
  63. 3 4
      server/queues/visit.ts
  64. 34 3
      server/redis.ts
  65. 43 0
      server/routes/auth.ts
  66. 29 0
      server/routes/domains.ts
  67. 1 11
      server/routes/index.ts
  68. 46 7
      server/routes/links.ts
  69. 17 0
      server/routes/routes.ts
  70. 16 0
      server/routes/users.ts
  71. 27 168
      server/server.ts
  72. 84 35
      server/utils/index.ts

+ 1 - 1
.eslintrc

@@ -15,7 +15,7 @@
     "no-var": "warn",
     "no-console": "warn",
     "max-len": ["warn", { "comments": 80 }],
-    "no-param-reassign": ["warn", { "props": false }],
+    "no-param-reassign": 0,
     "require-atomic-updates": 0,
     "@typescript-eslint/interface-name-prefix": "off",
     "@typescript-eslint/no-unused-vars": "off", // "warn" for production

+ 4 - 2
client/components/Icon/Icon.tsx

@@ -18,6 +18,7 @@ import Trash from "./Trash";
 import Check from "./Check";
 import Login from "./Login";
 import Heart from "./Heart";
+import Stop from "./Stop";
 import Plus from "./Plus";
 import Lock from "./Lock";
 import Edit from "./Edit";
@@ -33,10 +34,9 @@ const icons = {
   chevronLeft: ChevronLeft,
   chevronRight: ChevronRight,
   clipboard: Clipboard,
-  shuffle: Shuffle,
   copy: Copy,
-  heart: Heart,
   edit: Edit,
+  heart: Heart,
   key: Key,
   lock: Lock,
   login: Login,
@@ -45,8 +45,10 @@ const icons = {
   qrcode: QRCode,
   refresh: Refresh,
   send: Send,
+  shuffle: Shuffle,
   signup: Signup,
   spinner: Spinner,
+  stop: Stop,
   trash: Trash,
   x: X,
   zap: Zap

+ 22 - 0
client/components/Icon/Stop.tsx

@@ -0,0 +1,22 @@
+import React from "react";
+
+function Stop() {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="48"
+      height="48"
+      fill="none"
+      stroke="#5c666b"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      strokeWidth="2"
+      viewBox="0 0 24 24"
+    >
+      <circle cx="12" cy="12" r="10"></circle>
+      <path d="M4.93 4.93L19.07 19.07"></path>
+    </svg>
+  );
+}
+
+export default React.memo(Stop);

+ 228 - 123
client/components/LinksTable.tsx

@@ -4,16 +4,18 @@ import React, { FC, useState, useEffect } from "react";
 import { useFormState } from "react-use-form-state";
 import { Flex } from "reflexbox/styled-components";
 import styled, { css } from "styled-components";
+import { ifProp } from "styled-tools";
 import QRCode from "qrcode.react";
 import Link from "next/link";
 
-import { useStoreActions, useStoreState } from "../store";
 import { removeProtocol, withComma, errorMessage } from "../utils";
+import { useStoreActions, useStoreState } from "../store";
+import { Link as LinkType } from "../store/links";
 import { Checkbox, TextInput } from "./Input";
 import { NavButton, Button } from "./Button";
+import Text, { H2, H4, Span } from "./Text";
 import { Col, RowCenter } from "./Layout";
-import Text, { H2, Span } from "./Text";
-import { ifProp } from "styled-tools";
+import { useMessage } from "../hooks";
 import Animation from "./Animation";
 import { Colors } from "../consts";
 import Tooltip from "./Tooltip";
@@ -21,7 +23,6 @@ import Table from "./Table";
 import ALink from "./ALink";
 import Modal from "./Modal";
 import Icon from "./Icon";
-import { useMessage } from "../hooks";
 
 const Tr = styled(Flex).attrs({ as: "tr", px: [12, 12, 2] })``;
 const Th = styled(Flex)``;
@@ -87,6 +88,218 @@ const viewsFlex = {
 };
 const actionsFlex = { flexGrow: [1, 1, 2.5], flexShrink: [1, 1, 2.5] };
 
+interface RowProps {
+  index: number;
+  link: LinkType;
+  setDeleteModal: (number) => void;
+}
+
+interface BanForm {
+  host: boolean;
+  user: boolean;
+  userLinks: boolean;
+  domain: boolean;
+}
+
+const Row: FC<RowProps> = ({ index, link, setDeleteModal }) => {
+  const isAdmin = useStoreState(s => s.auth.isAdmin);
+  const ban = useStoreActions(s => s.links.ban);
+  const [formState, { checkbox }] = useFormState<BanForm>();
+  const [copied, setCopied] = useState(false);
+  const [qrModal, setQRModal] = useState(false);
+  const [banModal, setBanModal] = useState(false);
+  const [banLoading, setBanLoading] = useState(false);
+  const [banMessage, setBanMessage] = useMessage();
+
+  const onCopy = () => {
+    setCopied(true);
+    setTimeout(() => {
+      setCopied(false);
+    }, 1500);
+  };
+
+  const onBan = async () => {
+    setBanLoading(true);
+    try {
+      const res = await ban({ id: link.id, ...formState.values });
+      setBanMessage(res.message, "green");
+      setTimeout(() => {
+        setBanModal(false);
+      }, 2000);
+    } catch (err) {
+      setBanMessage(errorMessage(err));
+    }
+    setBanLoading(false);
+  };
+
+  return (
+    <>
+      <Tr key={index}>
+        <Td {...ogLinkFlex} withFade>
+          <ALink href={link.target}>{link.target}</ALink>
+        </Td>
+        <Td {...createdFlex}>{`${formatDistanceToNow(
+          new Date(link.created_at)
+        )} ago`}</Td>
+        <Td {...shortLinkFlex} withFade>
+          {copied ? (
+            <Animation
+              minWidth={32}
+              offset="10px"
+              duration="0.2s"
+              alignItems="center"
+            >
+              <Icon
+                size={[23, 24]}
+                py={0}
+                px={0}
+                mr={2}
+                p="3px"
+                name="check"
+                strokeWidth="3"
+                stroke={Colors.CheckIcon}
+              />
+            </Animation>
+          ) : (
+            <Animation minWidth={32} offset="-10px" duration="0.2s">
+              <CopyToClipboard text={link.link} onCopy={onCopy}>
+                <Action
+                  name="copy"
+                  strokeWidth="2.5"
+                  stroke={Colors.CopyIcon}
+                  backgroundColor={Colors.CopyIconBg}
+                />
+              </CopyToClipboard>
+            </Animation>
+          )}
+          <ALink href={link.link}>{removeProtocol(link.link)}</ALink>
+        </Td>
+        <Td {...viewsFlex}>{withComma(link.visit_count)}</Td>
+        <Td {...actionsFlex} justifyContent="flex-end">
+          {link.password && (
+            <>
+              <Tooltip id={`${index}-tooltip-password`}>
+                Password protected
+              </Tooltip>
+              <Action
+                as="span"
+                data-tip
+                data-for={`${index}-tooltip-password`}
+                name="key"
+                stroke={"#bbb"}
+                strokeWidth="2.5"
+                backgroundColor="none"
+              />
+            </>
+          )}
+          {link.banned && (
+            <>
+              <Tooltip id={`${index}-tooltip-banned`}>Banned</Tooltip>
+              <Action
+                as="span"
+                data-tip
+                data-for={`${index}-tooltip-banned`}
+                name="stop"
+                stroke="#bbb"
+                strokeWidth="2.5"
+                backgroundColor="none"
+              />
+            </>
+          )}
+          {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>
+          )}
+          <Action
+            name="qrcode"
+            stroke="none"
+            fill={Colors.QrCodeIcon}
+            backgroundColor={Colors.QrCodeIconBg}
+            onClick={() => setQRModal(true)}
+          />
+          {isAdmin && !link.banned && (
+            <Action
+              name="stop"
+              strokeWidth="2"
+              stroke={Colors.StopIcon}
+              backgroundColor={Colors.StopIconBg}
+              onClick={() => setBanModal(true)}
+            />
+          )}
+          <Action
+            mr={0}
+            name="trash"
+            strokeWidth="2"
+            stroke={Colors.TrashIcon}
+            backgroundColor={Colors.TrashIconBg}
+            onClick={() => setDeleteModal(index)}
+          />
+        </Td>
+      </Tr>
+      <Modal
+        id="table-qrcode-modal"
+        minWidth="max-content"
+        show={qrModal}
+        closeHandler={() => setQRModal(false)}
+      >
+        <RowCenter width={192}>
+          <QRCode size={192} value={link.link} />
+        </RowCenter>
+      </Modal>
+      <Modal
+        id="table-ban-modal"
+        show={banModal}
+        closeHandler={() => setBanModal(false)}
+      >
+        <>
+          <H2 mb={24} textAlign="center" bold>
+            Ban link?
+          </H2>
+          <Text mb={24} textAlign="center">
+            Are you sure do you want to ban the link{" "}
+            <Span bold>"{removeProtocol(link.link)}"</Span>?
+          </Text>
+          <RowCenter>
+            <Checkbox {...checkbox("user")} label="User" mb={12} />
+            <Checkbox {...checkbox("userLinks")} label="User links" mb={12} />
+            <Checkbox {...checkbox("host")} label="Host" mb={12} />
+            <Checkbox {...checkbox("domain")} label="Domain" mb={12} />
+          </RowCenter>
+          <Flex justifyContent="center" mt={4}>
+            {banLoading ? (
+              <>
+                <Icon name="spinner" size={20} stroke={Colors.Spinner} />
+              </>
+            ) : banMessage.text ? (
+              <Text fontSize={15} color={banMessage.color}>
+                {banMessage.text}
+              </Text>
+            ) : (
+              <>
+                <Button color="gray" mr={3} onClick={() => setBanModal(false)}>
+                  Cancel
+                </Button>
+                <Button color="red" ml={3} onClick={onBan}>
+                  <Icon name="stop" stroke="white" mr={2} />
+                  Ban
+                </Button>
+              </>
+            )}
+          </Flex>
+        </>
+      </Modal>
+    </>
+  );
+};
+
 interface Form {
   all: boolean;
   limit: string;
@@ -97,10 +310,8 @@ interface Form {
 const LinksTable: FC = () => {
   const isAdmin = useStoreState(s => s.auth.isAdmin);
   const links = useStoreState(s => s.links);
-  const { get, deleteOne } = useStoreActions(s => s.links);
+  const { get, remove } = useStoreActions(s => s.links);
   const [tableMessage, setTableMessage] = useState("No links to show.");
-  const [copied, setCopied] = useState([]);
-  const [qrModal, setQRModal] = useState(-1);
   const [deleteModal, setDeleteModal] = useState(-1);
   const [deleteLoading, setDeleteLoading] = useState(false);
   const [deleteMessage, setDeleteMessage] = useMessage();
@@ -113,7 +324,9 @@ const LinksTable: FC = () => {
   const linkToDelete = links.items[deleteModal];
 
   useEffect(() => {
-    get(options).catch(err => setTableMessage(err?.response?.data?.error));
+    get(options).catch(err =>
+      setTableMessage(err?.response?.data?.error || "An error occurred.")
+    );
   }, [options.limit, options.skip, options.all]);
 
   const onSubmit = e => {
@@ -121,20 +334,10 @@ const LinksTable: FC = () => {
     get(options);
   };
 
-  const onCopy = (index: number) => () => {
-    setCopied([index]);
-    setTimeout(() => {
-      setCopied(s => s.filter(i => i !== index));
-    }, 1500);
-  };
-
   const onDelete = async () => {
     setDeleteLoading(true);
     try {
-      await deleteOne({
-        id: linkToDelete.address,
-        domain: linkToDelete.domain
-      });
+      await remove(linkToDelete.id);
       await get(options);
       setDeleteModal(-1);
     } catch (err) {
@@ -254,98 +457,12 @@ const LinksTable: FC = () => {
             </Tr>
           ) : (
             <>
-              {links.items.map((l, index) => (
-                <Tr key={`link-${index}`}>
-                  <Td {...ogLinkFlex} withFade>
-                    <ALink href={l.target}>{l.target}</ALink>
-                  </Td>
-                  <Td {...createdFlex}>{`${formatDistanceToNow(
-                    new Date(l.created_at)
-                  )} ago`}</Td>
-                  <Td {...shortLinkFlex} withFade>
-                    {copied.includes(index) ? (
-                      <Animation
-                        minWidth={32}
-                        offset="10px"
-                        duration="0.2s"
-                        alignItems="center"
-                      >
-                        <Icon
-                          size={[23, 24]}
-                          py={0}
-                          px={0}
-                          mr={2}
-                          p="3px"
-                          name="check"
-                          strokeWidth="3"
-                          stroke={Colors.CheckIcon}
-                        />
-                      </Animation>
-                    ) : (
-                      <Animation minWidth={32} offset="-10px" duration="0.2s">
-                        <CopyToClipboard text={l.link} onCopy={onCopy(index)}>
-                          <Action
-                            name="copy"
-                            strokeWidth="2.5"
-                            stroke={Colors.CopyIcon}
-                            backgroundColor={Colors.CopyIconBg}
-                          />
-                        </CopyToClipboard>
-                      </Animation>
-                    )}
-                    <ALink href={l.link}>{removeProtocol(l.link)}</ALink>
-                  </Td>
-                  <Td {...viewsFlex}>{withComma(l.visit_count)}</Td>
-                  <Td {...actionsFlex} justifyContent="flex-end">
-                    {l.password && (
-                      <>
-                        <Tooltip id={`${index}-tooltip-password`}>
-                          Password protected
-                        </Tooltip>
-                        <Action
-                          as="span"
-                          data-tip
-                          data-for={`${index}-tooltip-password`}
-                          name="key"
-                          stroke="#bbb"
-                          strokeWidth="2.5"
-                          backgroundColor="none"
-                        />
-                      </>
-                    )}
-                    {l.visit_count > 0 && (
-                      <Link
-                        href={`/stats?id=${l.id}${
-                          l.domain ? `&domain=${l.domain}` : ""
-                        }`}
-                      >
-                        <ALink title="View stats" forButton>
-                          <Action
-                            name="pieChart"
-                            stroke={Colors.PieIcon}
-                            strokeWidth="2.5"
-                            backgroundColor={Colors.PieIconBg}
-                          />
-                        </ALink>
-                      </Link>
-                    )}
-                    <Action
-                      name="qrcode"
-                      stroke="none"
-                      fill={Colors.QrCodeIcon}
-                      backgroundColor={Colors.QrCodeIconBg}
-                      onClick={() => setQRModal(index)}
-                    />
-                    <Action
-                      mr={0}
-                      name="trash"
-                      strokeWidth="2"
-                      stroke={Colors.TrashIcon}
-                      backgroundColor={Colors.TrashIconBg}
-                      onClick={() => setDeleteModal(index)}
-                    />
-                  </Td>
-                </Tr>
+              {links.items.map((link, index) => (
+                <Row
+                  setDeleteModal={setDeleteModal}
+                  index={index}
+                  link={link}
+                />
               ))}
             </>
           )}
@@ -354,18 +471,6 @@ const LinksTable: FC = () => {
           <Tr justifyContent="flex-end">{Nav}</Tr>
         </tfoot>
       </Table>
-      <Modal
-        id="table-qrcode-modal"
-        minWidth="max-content"
-        show={qrModal > -1}
-        closeHandler={() => setQRModal(-1)}
-      >
-        {links.items[qrModal] && (
-          <RowCenter width={192}>
-            <QRCode size={192} value={links.items[qrModal].link} />
-          </RowCenter>
-        )}
-      </Modal>
       <Modal
         id="delete-custom-domain"
         show={deleteModal > -1}

+ 0 - 83
client/components/Settings/SettingsBan.tsx

@@ -1,83 +0,0 @@
-import { useFormState } from "react-use-form-state";
-import { Flex } from "reflexbox/styled-components";
-import React, { FC, useState } from "react";
-import axios from "axios";
-
-import { Checkbox, TextInput } from "../Input";
-import { getAxiosConfig } from "../../utils";
-import { useMessage } from "../../hooks";
-import { API } from "../../consts";
-import { Button } from "../Button";
-import Text, { H2 } from "../Text";
-import { Col } from "../Layout";
-import Icon from "../Icon";
-
-interface BanForm {
-  id: string;
-  user: boolean;
-  domain: boolean;
-  host: boolean;
-}
-
-const SettingsBan: FC = () => {
-  const [submitting, setSubmitting] = useState(false);
-  const [message, setMessage] = useMessage(3000);
-  const [formState, { checkbox, text }] = useFormState<BanForm>();
-
-  const onSubmit = async e => {
-    e.preventDefault();
-    setSubmitting(true);
-    setMessage();
-    try {
-      const { data } = await axios.post(
-        API.BAN_LINK,
-        formState.values,
-        getAxiosConfig()
-      );
-      setMessage(data.message, "green");
-      formState.clear();
-    } catch (err) {
-      setMessage(err?.response?.data?.error || "Couldn't ban the link.");
-    }
-    setSubmitting(false);
-  };
-
-  return (
-    <Col>
-      <H2 mb={4} bold>
-        Ban link
-      </H2>
-      <Col as="form" onSubmit={onSubmit} alignItems="flex-start">
-        <Flex mb={24} alignItems="center">
-          <TextInput
-            {...text("id")}
-            placeholder="Link ID (e.g. K7b2A)"
-            mr={3}
-            width={[1, 3 / 5]}
-            required
-          />
-          <Button height={[36, 40]} type="submit" disabled={submitting}>
-            <Icon
-              name={submitting ? "spinner" : "lock"}
-              stroke="white"
-              mr={2}
-            />
-            {submitting ? "Banning..." : "Ban"}
-          </Button>
-        </Flex>
-        <Checkbox
-          {...checkbox("user")}
-          label="Ban User (and all of their links)"
-          mb={12}
-        />
-        <Checkbox {...checkbox("domain")} label="Ban Domain" mb={12} />
-        <Checkbox {...checkbox("host")} label="Ban Host/IP" />
-        <Text color={message.color} mt={3}>
-          {message.text}
-        </Text>
-      </Col>
-    </Col>
-  );
-};
-
-export default SettingsBan;

+ 16 - 14
client/components/Settings/SettingsDomain.tsx

@@ -5,6 +5,7 @@ import styled from "styled-components";
 
 import { useStoreState, useStoreActions } from "../../store";
 import { Domain } from "../../store/settings";
+import { errorMessage } from "../../utils";
 import { useMessage } from "../../hooks";
 import Text, { H2, Span } from "../Text";
 import { Colors } from "../../consts";
@@ -14,7 +15,6 @@ import { Col } from "../Layout";
 import Table from "../Table";
 import Modal from "../Modal";
 import Icon from "../Icon";
-import { errorMessage } from "../../utils";
 
 const Th = styled(Flex).attrs({ as: "th", py: 3, px: 3 })`
   font-size: 15px;
@@ -24,15 +24,15 @@ const Td = styled(Flex).attrs({ as: "td", py: 12, px: 3 })`
 `;
 
 const SettingsDomain: FC = () => {
-  const [modal, setModal] = useState(false);
-  const [loading, setLoading] = useState(false);
-  const [deleteLoading, setDeleteLoading] = useState(false);
+  const { saveDomain, deleteDomain } = useStoreActions(s => s.settings);
   const [domainToDelete, setDomainToDelete] = useState<Domain>(null);
-  const [message, setMessage] = useMessage(2000);
+  const [deleteLoading, setDeleteLoading] = useState(false);
   const domains = useStoreState(s => s.settings.domains);
-  const { saveDomain, deleteDomain } = useStoreActions(s => s.settings);
+  const [message, setMessage] = useMessage(2000);
+  const [loading, setLoading] = useState(false);
+  const [modal, setModal] = useState(false);
   const [formState, { label, text }] = useFormState<{
-    customDomain: string;
+    address: string;
     homepage: string;
   }>(null, { withIds: true });
 
@@ -56,7 +56,7 @@ const SettingsDomain: FC = () => {
 
   const onDelete = async () => {
     setDeleteLoading(true);
-    await deleteDomain().catch(err =>
+    await deleteDomain(domainToDelete.id).catch(err =>
       setMessage(errorMessage(err, "Couldn't delete the domain."))
     );
     setMessage("Domain has been deleted successfully.", "green");
@@ -88,9 +88,11 @@ const SettingsDomain: FC = () => {
           </thead>
           <tbody>
             {domains.map(d => (
-              <tr key={d.customDomain}>
-                <Td width={2 / 5}>{d.customDomain}</Td>
-                <Td width={2 / 5}>{d.homepage || "default"}</Td>
+              <tr key={d.address}>
+                <Td width={2 / 5}>{d.address}</Td>
+                <Td width={2 / 5}>
+                  {d.homepage || process.env.DEFAULT_DOMAIN}
+                </Td>
                 <Td width={1 / 5} justifyContent="center">
                   <Icon
                     as="button"
@@ -123,7 +125,7 @@ const SettingsDomain: FC = () => {
           <Flex width={1} flexDirection={["column", "row"]}>
             <Col mr={[0, 2]} mb={[3, 0]} flex="0 0 auto">
               <Text
-                {...label("customDomain")}
+                {...label("address")}
                 as="label"
                 mb={[2, 3]}
                 fontSize={[15, 16]}
@@ -132,7 +134,7 @@ const SettingsDomain: FC = () => {
                 Domain
               </Text>
               <TextInput
-                {...text("customDomain")}
+                {...text("address")}
                 placeholder="example.com"
                 maxWidth="240px"
                 required
@@ -169,7 +171,7 @@ const SettingsDomain: FC = () => {
         </H2>
         <Text textAlign="center">
           Are you sure do you want to delete the domain{" "}
-          <Span bold>"{domainToDelete && domainToDelete.customDomain}"</Span>?
+          <Span bold>"{domainToDelete && domainToDelete.address}"</Span>?
         </Text>
         <Flex justifyContent="center" mt={44}>
           {deleteLoading ? (

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

@@ -6,7 +6,7 @@ import axios from "axios";
 import { getAxiosConfig } from "../../utils";
 import { useMessage } from "../../hooks";
 import { TextInput } from "../Input";
-import { API } from "../../consts";
+import { APIv2 } from "../../consts";
 import { Button } from "../Button";
 import Text, { H2 } from "../Text";
 import { Col } from "../Layout";
@@ -30,7 +30,7 @@ const SettingsPassword: FC = () => {
     setMessage();
     try {
       const res = await axios.post(
-        API.CHANGE_PASSWORD,
+        APIv2.AuthChangePassword,
         formState.values,
         getAxiosConfig()
       );

+ 3 - 3
client/components/Shortener.tsx

@@ -1,7 +1,7 @@
 import { CopyToClipboard } from "react-copy-to-clipboard";
 import { useFormState } from "react-use-form-state";
 import { Flex } from "reflexbox/styled-components";
-import React, { useState } from "react";
+import React, { FC, useState } from "react";
 import styled from "styled-components";
 
 import { useStoreActions, useStoreState } from "../store";
@@ -260,8 +260,8 @@ const Shortener = () => {
               options={[
                 { key: defaultDomain, value: "" },
                 ...domains.map(d => ({
-                  key: d.customDomain,
-                  value: d.customDomain
+                  key: d.address,
+                  value: d.address
                 }))
               ]}
             />

+ 13 - 14
client/components/Text.tsx

@@ -1,17 +1,19 @@
 import { switchProp, ifNotProp, ifProp } from "styled-tools";
-import { Box } from "reflexbox/styled-components";
+import { Box, BoxProps } from "reflexbox/styled-components";
 import styled, { css } from "styled-components";
 
+import { FC, CSSProperties } from "react";
 import { Colors } from "../consts";
-import { FC, ComponentProps } from "react";
 
-interface Props {
+interface Props extends Omit<BoxProps, "as"> {
+  as?: string;
   htmlFor?: string;
   light?: boolean;
   normal?: boolean;
   bold?: boolean;
+  style?: CSSProperties;
 }
-const Text = styled(Box)<Props>`
+const Text: FC<Props> = styled(Box)<Props>`
   font-weight: 400;
   ${ifNotProp(
     "fontSize",
@@ -50,18 +52,15 @@ const Text = styled(Box)<Props>`
 `;
 
 Text.defaultProps = {
-  as: "p",
   color: Colors.Text
 };
 
 export default Text;
 
-type TextProps = ComponentProps<typeof Text>;
-
-export const H1: FC<TextProps> = props => <Text as="h1" {...props} />;
-export const H2: FC<TextProps> = props => <Text as="h2" {...props} />;
-export const H3: FC<TextProps> = props => <Text as="h3" {...props} />;
-export const H4: FC<TextProps> = props => <Text as="h4" {...props} />;
-export const H5: FC<TextProps> = props => <Text as="h5" {...props} />;
-export const H6: FC<TextProps> = props => <Text as="h6" {...props} />;
-export const Span: FC<TextProps> = 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} />;

+ 10 - 12
client/consts/consts.ts

@@ -1,21 +1,17 @@
 export enum API {
-  LOGIN = "/api/auth/login",
-  SIGNUP = "/api/auth/signup",
-  RENEW = "/api/auth/renew",
-  REPORT = "/api/url/report",
-  RESET_PASSWORD = "/api/auth/resetpassword",
-  CHANGE_PASSWORD = "/api/auth/changepassword",
   BAN_LINK = "/api/url/admin/ban",
-  CUSTOM_DOMAIN = "/api/url/customdomain",
-  GENERATE_APIKEY = "/api/auth/generateapikey",
-  SETTINGS = "/api/auth/usersettings",
-  SUBMIT = "/api/url/submit",
-  GET_LINKS = "/api/url/geturls",
-  DELETE_LINK = "/api/url/deleteurl",
   STATS = "/api/url/stats"
 }
 
 export enum APIv2 {
+  AuthLogin = "/api/v2/auth/login",
+  AuthSignup = "/api/v2/auth/signup",
+  AuthRenew = "/api/v2/auth/renew",
+  AuthResetPassword = "/api/v2/auth/reset-password",
+  AuthChangePassword = "/api/v2/auth/change-password",
+  AuthGenerateApikey = "/api/v2/auth/apikey",
+  Users = "/api/v2/users",
+  Domains = "/api/v2/domains",
   Links = "/api/v2/links"
 }
 
@@ -32,6 +28,8 @@ export enum Colors {
   CheckIcon = "hsl(144, 50%, 60%)",
   TrashIcon = "hsl(0, 100%, 69%)",
   TrashIconBg = "hsl(0, 100%, 96%)",
+  StopIcon = "hsl(10, 100%, 40%)",
+  StopIconBg = "hsl(10, 100%, 96%)",
   QrCodeIcon = "hsl(0, 0%, 35%)",
   QrCodeIconBg = "hsl(0, 0%, 94%)",
   PieIcon = "hsl(260, 100%, 69%)",

+ 2 - 2
client/pages/login.tsx

@@ -16,7 +16,7 @@ import { Button } from "../components/Button";
 import Text, { H2 } from "../components/Text";
 import ALink from "../components/ALink";
 import Icon from "../components/Icon";
-import { API } from "../consts";
+import { APIv2 } from "../consts";
 
 const LoginForm = styled(Flex).attrs({
   as: "form",
@@ -80,7 +80,7 @@ const LoginPage = () => {
       if (type === "signup") {
         setLoading(s => ({ ...s, signup: true }));
         try {
-          await axios.post(API.SIGNUP, { email, password });
+          await axios.post(APIv2.AuthSignup, { email, password });
           setVerifying(true);
         } catch (error) {
           setError(error.response.data.error);

+ 19 - 15
client/pages/url-password.tsx → client/pages/protected/[id].tsx

@@ -2,20 +2,23 @@ import { useFormState } from "react-use-form-state";
 import { Flex } from "reflexbox/styled-components";
 import React, { useState } from "react";
 import { NextPage } from "next";
+import { useRouter } from "next/router";
 import axios from "axios";
 
-import AppWrapper from "../components/AppWrapper";
-import { TextInput } from "../components/Input";
-import { Button } from "../components/Button";
-import Text, { H2 } from "../components/Text";
-import { Col } from "../components/Layout";
-import Icon from "../components/Icon";
+import AppWrapper from "../../components/AppWrapper";
+import { TextInput } from "../../components/Input";
+import { Button } from "../../components/Button";
+import Text, { H2 } from "../../components/Text";
+import { Col } from "../../components/Layout";
+import Icon from "../../components/Icon";
+import { APIv2 } from "../../consts";
 
 interface Props {
   protectedLink?: string;
 }
 
-const UrlPasswordPage: NextPage<Props> = ({ protectedLink }) => {
+const ProtectedPage: NextPage<Props> = () => {
+  const router = useRouter();
   const [loading, setLoading] = useState(false);
   const [formState, { password }] = useFormState<{ password: string }>();
   const [error, setError] = useState<string>();
@@ -30,12 +33,13 @@ const UrlPasswordPage: NextPage<Props> = ({ protectedLink }) => {
 
     setError("");
     setLoading(true);
-    // TODO: better api calls
     try {
-      const { data } = await axios.post("/api/url/requesturl", {
-        id: protectedLink,
-        password
-      });
+      const { data } = await axios.post(
+        `${APIv2.Links}/${router.query.id}/protected`,
+        {
+          password
+        }
+      );
       window.location.replace(data.target);
     } catch ({ response }) {
       setError(response.data.error);
@@ -45,7 +49,7 @@ const UrlPasswordPage: NextPage<Props> = ({ protectedLink }) => {
 
   return (
     <AppWrapper>
-      {!protectedLink ? (
+      {!router.query.id ? (
         <H2 my={4} light>
           404 | Link could not be found.
         </H2>
@@ -84,10 +88,10 @@ const UrlPasswordPage: NextPage<Props> = ({ protectedLink }) => {
   );
 };
 
-UrlPasswordPage.getInitialProps = async ({ req }) => {
+ProtectedPage.getInitialProps = async ({ req }) => {
   return {
     protectedLink: req && (req as any).protectedLink
   };
 };
 
-export default UrlPasswordPage;
+export default ProtectedPage;

+ 2 - 2
client/pages/report.tsx

@@ -10,7 +10,7 @@ import { Button } from "../components/Button";
 import { Col } from "../components/Layout";
 import Icon from "../components/Icon";
 import { useMessage } from "../hooks";
-import { API } from "../consts";
+import { APIv2 } from "../consts";
 
 const ReportPage = () => {
   const [formState, { text }] = useFormState<{ url: string }>();
@@ -22,7 +22,7 @@ const ReportPage = () => {
     setLoading(true);
     setMessage();
     try {
-      await axios.post(API.REPORT, { link: formState.values.url }); // TODO: better api calls
+      await axios.post(`${APIv2.Links}/report`, { link: formState.values.url });
       setMessage("Thanks for the report, we'll take actions shortly.", "green");
       formState.clear();
     } catch (error) {

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

@@ -16,7 +16,7 @@ import { Col } from "../components/Layout";
 import { TokenPayload } from "../types";
 import { useMessage } from "../hooks";
 import Icon from "../components/Icon";
-import { API } from "../consts";
+import { API, APIv2 } from "../consts";
 
 interface Props {
   token?: string;
@@ -51,7 +51,7 @@ const ResetPassword: NextPage<Props> = ({ token }) => {
     setLoading(true);
     setMessage();
     try {
-      await axios.post(API.RESET_PASSWORD, {
+      await axios.post(APIv2.AuthResetPassword, {
         email: formState.values.email
       });
       setMessage("Reset password email has been sent.", "green");

+ 5 - 13
client/pages/settings.tsx

@@ -1,20 +1,18 @@
-import { Flex } from "reflexbox/styled-components";
-import React, { useEffect } from "react";
 import { NextPage } from "next";
+import React from "react";
 
 import SettingsPassword from "../components/Settings/SettingsPassword";
 import SettingsDomain from "../components/Settings/SettingsDomain";
-import SettingsBan from "../components/Settings/SettingsBan";
 import SettingsApi from "../components/Settings/SettingsApi";
-import { useStoreState, useStoreActions } from "../store";
 import AppWrapper from "../components/AppWrapper";
 import { H1, Span } from "../components/Text";
 import Divider from "../components/Divider";
-import Footer from "../components/Footer";
 import { Col } from "../components/Layout";
+import Footer from "../components/Footer";
+import { useStoreState } from "../store";
 
-const SettingsPage: NextPage = props => {
-  const { email, isAdmin } = useStoreState(s => s.auth);
+const SettingsPage: NextPage = () => {
+  const email = useStoreState(s => s.auth.email);
 
   return (
     <AppWrapper>
@@ -27,12 +25,6 @@ const SettingsPage: NextPage = props => {
           .
         </H1>
         <Divider mt={4} mb={48} />
-        {isAdmin && (
-          <>
-            <SettingsBan />
-            <Divider mt={4} mb={48} />
-          </>
-        )}
         <SettingsDomain />
         <Divider mt={4} mb={48} />
         <SettingsPassword />

+ 3 - 5
client/pages/stats.tsx

@@ -15,15 +15,14 @@ import AppWrapper from "../components/AppWrapper";
 import Divider from "../components/Divider";
 import { useStoreState } from "../store";
 import ALink from "../components/ALink";
-import { API, Colors } from "../consts";
+import { APIv2, Colors } from "../consts";
 import Icon from "../components/Icon";
 
 interface Props {
-  domain?: string;
   id?: string;
 }
 
-const StatsPage: NextPage<Props> = ({ domain, id }) => {
+const StatsPage: NextPage<Props> = ({ id }) => {
   const { isAuthenticated } = useStoreState(s => s.auth);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState(false);
@@ -35,7 +34,7 @@ const StatsPage: NextPage<Props> = ({ domain, id }) => {
   useEffect(() => {
     if (!id || !isAuthenticated) return;
     axios
-      .get(`${API.STATS}?id=${id}&domain=${domain}`, getAxiosConfig())
+      .get(`${APIv2.Links}/${id}/stats`, getAxiosConfig())
       .then(({ data }) => {
         setLoading(false);
         setError(!data);
@@ -208,7 +207,6 @@ StatsPage.getInitialProps = ({ query }) => {
 };
 
 StatsPage.defaultProps = {
-  domain: "",
   id: ""
 };
 

+ 5 - 14
client/pages/url-info.tsx

@@ -1,21 +1,16 @@
+import { useRouter } from "next/router";
 import React from "react";
-import styled from "styled-components";
-import { Flex } from "reflexbox/styled-components";
-import { NextPage } from "next";
 
 import AppWrapper from "../components/AppWrapper";
 import Footer from "../components/Footer";
 import { H2, H4 } from "../components/Text";
 import { Col } from "../components/Layout";
 
-interface Props {
-  linkTarget?: string;
-}
-
-const UrlInfoPage: NextPage<Props> = ({ linkTarget }) => {
+const UrlInfoPage = () => {
+  const { query } = useRouter();
   return (
     <AppWrapper>
-      {!linkTarget ? (
+      {!query.target ? (
         <H2 my={4} light>
           404 | Link could not be found.
         </H2>
@@ -25,7 +20,7 @@ const UrlInfoPage: NextPage<Props> = ({ linkTarget }) => {
             <H2 my={3} light>
               Target:
             </H2>
-            <H4 bold>{linkTarget}</H4>
+            <H4 bold>{query.target}</H4>
           </Col>
           <Footer />
         </>
@@ -34,8 +29,4 @@ const UrlInfoPage: NextPage<Props> = ({ linkTarget }) => {
   );
 };
 
-UrlInfoPage.getInitialProps = async ctx => {
-  return { linkTarget: (ctx?.req as any)?.linkTarget };
-};
-
 export default UrlInfoPage;

+ 3 - 3
client/store/auth.ts

@@ -4,7 +4,7 @@ import cookie from "js-cookie";
 import axios from "axios";
 
 import { TokenPayload } from "../types";
-import { API } from "../consts";
+import { API, APIv2 } from "../consts";
 import { getAxiosConfig } from "../utils";
 
 export interface Auth {
@@ -35,14 +35,14 @@ export const auth: Auth = {
     state.isAdmin = false;
   }),
   login: thunk(async (actions, payload) => {
-    const res = await axios.post(API.LOGIN, payload);
+    const res = await axios.post(APIv2.AuthLogin, payload);
     const { token } = res.data;
     cookie.set("token", token, { expires: 7 });
     const tokenPayload: TokenPayload = decode(token);
     actions.add(tokenPayload);
   }),
   renew: thunk(async actions => {
-    const res = await axios.post(API.RENEW, null, getAxiosConfig());
+    const res = await axios.post(APIv2.AuthRenew, null, getAxiosConfig());
     const { token } = res.data;
     cookie.set("token", token, { expires: 7 });
     const tokenPayload: TokenPayload = decode(token);

+ 28 - 4
client/store/links.ts

@@ -6,7 +6,7 @@ import { getAxiosConfig } from "../utils";
 import { API, APIv2 } from "../consts";
 
 export interface Link {
-  id: number;
+  id: string;
   address: string;
   banned: boolean;
   banned_by_id?: number;
@@ -30,6 +30,14 @@ export interface NewLink {
   reCaptchaToken?: string;
 }
 
+export interface BanLink {
+  id: string;
+  host?: boolean;
+  domain?: boolean;
+  user?: boolean;
+  userLinks?: boolean;
+}
+
 export interface LinksQuery {
   limit: string;
   skip: string;
@@ -53,7 +61,9 @@ export interface Links {
   get: Thunk<Links, LinksQuery>;
   add: Action<Links, Link>;
   set: Action<Links, LinksListRes>;
-  deleteOne: Thunk<Links, { id: string; domain?: string }>;
+  update: Action<Links, Partial<Link>>;
+  remove: Thunk<Links, string>;
+  ban: Thunk<Links, BanLink>;
   setLoading: Action<Links, boolean>;
 }
 
@@ -80,8 +90,17 @@ export const links: Links = {
     actions.setLoading(false);
     return res.data;
   }),
-  deleteOne: thunk(async (actions, payload) => {
-    await axios.post(API.DELETE_LINK, payload, getAxiosConfig());
+  remove: thunk(async (actions, id) => {
+    await axios.delete(`${APIv2.Links}/${id}`, getAxiosConfig());
+  }),
+  ban: thunk(async (actions, { id, ...payload }) => {
+    const res = await axios.post(
+      `${APIv2.Links}/admin/ban/${id}`,
+      payload,
+      getAxiosConfig()
+    );
+    actions.update({ id, banned: true });
+    return res.data;
   }),
   add: action((state, payload) => {
     state.items.pop();
@@ -91,6 +110,11 @@ export const links: Links = {
     state.items = payload.data;
     state.total = payload.total;
   }),
+  update: action((state, payload) => {
+    state.items = state.items.map(item =>
+      item.id === payload.id ? { ...item, ...payload } : item
+    );
+  }),
   setLoading: action((state, payload) => {
     state.loading = payload;
   })

+ 35 - 27
client/store/settings.ts

@@ -3,60 +3,71 @@ import axios from "axios";
 
 import { getAxiosConfig } from "../utils";
 import { StoreModel } from "./store";
-import { API } from "../consts";
+import { APIv2 } from "../consts";
 
 export interface Domain {
-  customDomain: string;
-  homepage: string;
+  id: string;
+  address: string;
+  banned: boolean;
+  created_at: string;
+  homepage?: string;
+  updated_at: string;
 }
 
-export interface SettingsResp extends Domain {
+export interface NewDomain {
+  address: string;
+  homepage?: string;
+}
+
+export interface SettingsResp {
   apikey: string;
+  email: string;
+  domains: Domain[];
 }
 
 export interface Settings {
   domains: Array<Domain>;
   apikey: string;
+  email: string;
   fetched: boolean;
   setSettings: Action<Settings, SettingsResp>;
   getSettings: Thunk<Settings, null, null, StoreModel>;
   setApiKey: Action<Settings, string>;
   generateApiKey: Thunk<Settings>;
   addDomain: Action<Settings, Domain>;
-  removeDomain: Action<Settings>;
-  saveDomain: Thunk<Settings, Domain>;
-  deleteDomain: Thunk<Settings>;
+  removeDomain: Action<Settings, string>;
+  saveDomain: Thunk<Settings, NewDomain>;
+  deleteDomain: Thunk<Settings, string>;
 }
 
 export const settings: Settings = {
   domains: [],
+  email: null,
   apikey: null,
   fetched: false,
   getSettings: thunk(async (actions, payload, { getStoreActions }) => {
     getStoreActions().loading.show();
-    const res = await axios.get(API.SETTINGS, getAxiosConfig());
+    const res = await axios.get(APIv2.Users, getAxiosConfig());
     actions.setSettings(res.data);
     getStoreActions().loading.hide();
   }),
   generateApiKey: thunk(async actions => {
-    const res = await axios.post(API.GENERATE_APIKEY, null, getAxiosConfig());
+    const res = await axios.post(
+      APIv2.AuthGenerateApikey,
+      null,
+      getAxiosConfig()
+    );
     actions.setApiKey(res.data.apikey);
   }),
-  deleteDomain: thunk(async actions => {
-    await axios.delete(API.CUSTOM_DOMAIN, getAxiosConfig());
-    actions.removeDomain();
+  deleteDomain: thunk(async (actions, id) => {
+    await axios.delete(`${APIv2.Domains}/${id}`, getAxiosConfig());
+    actions.removeDomain(id);
   }),
   setSettings: action((state, payload) => {
     state.apikey = payload.apikey;
+    state.domains = payload.domains;
+    state.email = payload.email;
     state.fetched = true;
-    if (payload.customDomain) {
-      state.domains = [
-        {
-          customDomain: payload.customDomain,
-          homepage: payload.homepage
-        }
-      ];
-    }
   }),
   setApiKey: action((state, payload) => {
     state.apikey = payload;
@@ -64,14 +75,11 @@ export const settings: Settings = {
   addDomain: action((state, payload) => {
     state.domains.push(payload);
   }),
-  removeDomain: action(state => {
-    state.domains = [];
+  removeDomain: action((state, id) => {
+    state.domains = state.domains.filter(d => d.id !== id);
   }),
   saveDomain: thunk(async (actions, payload) => {
-    const res = await axios.post(API.CUSTOM_DOMAIN, payload, getAxiosConfig());
-    actions.addDomain({
-      customDomain: res.data.customDomain,
-      homepage: res.data.homepage
-    });
+    const res = await axios.post(APIv2.Domains, payload, getAxiosConfig());
+    actions.addDomain(res.data);
   })
 };

+ 35 - 0
global.d.ts

@@ -1,3 +1,9 @@
+type Raw = import("knex").Raw;
+
+type Match<T> = {
+  [K in keyof T]?: T[K] | [">" | ">=" | "<=" | "<", T[K]];
+};
+
 interface User {
   id: number;
   apikey?: string;
@@ -24,6 +30,7 @@ interface UserJoined extends User {
 
 interface Domain {
   id: number;
+  uuid: string;
   address: string;
   banned: boolean;
   banned_by_id?: number;
@@ -33,6 +40,18 @@ interface Domain {
   user_id?: number;
 }
 
+interface DomainSanitized {
+  id: string;
+  uuid: undefined;
+  address: string;
+  banned: boolean;
+  banned_by_id?: undefined;
+  created_at: string;
+  homepage?: string;
+  updated_at: string;
+  user_id?: undefined;
+}
+
 interface Host {
   id: number;
   address: string;
@@ -64,6 +83,22 @@ interface Link {
   visit_count: number;
 }
 
+interface LinkSanitized {
+  address: string;
+  banned_by_id?: undefined;
+  banned: boolean;
+  created_at: string;
+  domain_id?: undefined;
+  id: string;
+  link: string;
+  password: boolean;
+  target: string;
+  updated_at: string;
+  user_id?: undefined;
+  uuid?: undefined;
+  visit_count: number;
+}
+
 interface LinkJoinedDomain extends Link {
   domain?: string;
 }

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 1013 - 357
package-lock.json


+ 57 - 55
package.json

@@ -32,120 +32,122 @@
   },
   "homepage": "https://github.com/TheDevs-Network/kutt#readme",
   "dependencies": {
-    "axios": "^0.19.0",
+    "axios": "^0.19.1",
     "babel-plugin-inline-react-svg": "^1.1.0",
     "bcryptjs": "^2.4.3",
-    "bull": "^3.11.0",
+    "bull": "^3.12.1",
     "cookie-parser": "^1.4.4",
     "cors": "^2.8.5",
-    "date-fns": "^2.4.1",
-    "dotenv": "^8.0.0",
-    "easy-peasy": "^3.2.3",
+    "date-fns": "^2.9.0",
+    "dotenv": "^8.2.0",
+    "easy-peasy": "^3.3.0",
     "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.3.8",
-    "helmet": "^3.21.1",
-    "isbot": "^2.2.1",
-    "js-cookie": "^2.2.0",
+    "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.19.5",
     "morgan": "^1.9.1",
-    "ms": "^2.1.1",
+    "ms": "^2.1.2",
     "nanoid": "^1.3.4",
-    "neo4j-driver": "^1.7.5",
-    "next": "^9.1.7",
+    "neo4j-driver": "^1.7.6",
+    "next": "^9.2.0",
     "node-cron": "^2.0.3",
-    "nodemailer": "^6.3.0",
-    "p-queue": "^6.1.1",
-    "passport": "^0.4.0",
+    "nodemailer": "^6.4.2",
+    "p-queue": "^6.2.1",
+    "passport": "^0.4.1",
     "passport-jwt": "^4.0.0",
     "passport-local": "^1.0.0",
     "passport-localapikey-update": "^0.6.0",
-    "pg": "^7.12.1",
-    "pg-query-stream": "^2.0.0",
+    "pg": "^7.17.1",
+    "pg-query-stream": "^2.1.2",
     "prop-types": "^15.7.2",
     "qrcode.react": "^0.8.0",
-    "query-string": "^6.9.0",
+    "query-string": "^6.10.1",
     "raven": "^2.6.4",
-    "react": "^16.8.1",
-    "react-copy-to-clipboard": "^5.0.1",
-    "react-dom": "^16.8.1",
-    "react-ga": "^2.5.7",
+    "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.1",
-    "react-use-form-state": "^0.12.0",
-    "recharts": "^1.4.3",
+    "react-tooltip": "^3.11.2",
+    "react-use-form-state": "^0.12.1",
+    "recharts": "^1.8.5",
     "redis": "^2.8.0",
     "reflexbox": "^4.0.6",
-    "styled-components": "^4.4.1",
+    "signale": "^1.4.0",
+    "styled-components": "^5.0.0",
     "styled-tools": "^1.7.1",
     "universal-analytics": "^0.4.20",
     "url-regex": "^4.1.1",
     "use-media": "^1.4.0",
     "useragent": "^2.2.1",
-    "uuid": "^3.3.2"
+    "uuid": "^3.4.0"
   },
   "devDependencies": {
-    "@babel/cli": "^7.2.3",
-    "@babel/core": "^7.2.2",
-    "@babel/node": "^7.2.2",
-    "@babel/preset-env": "^7.3.1",
-    "@babel/register": "^7.0.0",
+    "@babel/cli": "^7.8.3",
+    "@babel/core": "^7.8.3",
+    "@babel/node": "^7.8.3",
+    "@babel/preset-env": "^7.8.3",
+    "@babel/register": "^7.8.3",
     "@types/bcryptjs": "^2.4.2",
-    "@types/body-parser": "^1.17.0",
-    "@types/bull": "^3.10.5",
-    "@types/cookie-parser": "^1.4.1",
-    "@types/cors": "^2.8.5",
+    "@types/body-parser": "^1.17.1",
+    "@types/bull": "^3.12.0",
+    "@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.16.0",
+    "@types/express": "^4.17.2",
     "@types/helmet": "0.0.38",
     "@types/jsonwebtoken": "^7.2.8",
     "@types/jwt-decode": "^2.2.1",
-    "@types/mongodb": "^3.1.17",
-    "@types/morgan": "^1.7.36",
-    "@types/ms": "^0.7.30",
+    "@types/mongodb": "^3.3.14",
+    "@types/morgan": "^1.7.37",
+    "@types/ms": "^0.7.31",
     "@types/next": "^9.0.0",
     "@types/node-cron": "^2.0.2",
-    "@types/nodemailer": "^6.2.1",
-    "@types/pg": "^7.11.0",
+    "@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.16",
+    "@types/react": "^16.9.17",
     "@types/react-dom": "^16.9.4",
     "@types/react-tooltip": "^3.11.0",
-    "@types/redis": "^2.8.10",
+    "@types/redis": "^2.8.14",
     "@types/reflexbox": "^4.0.0",
     "@types/styled-components": "^4.1.8",
-    "@typescript-eslint/eslint-plugin": "^2.0.0",
-    "@typescript-eslint/parser": "^2.0.0",
+    "@typescript-eslint/eslint-plugin": "^2.16.0",
+    "@typescript-eslint/parser": "^2.16.0",
     "babel": "^6.23.0",
     "babel-cli": "^6.26.0",
     "babel-core": "^6.26.3",
     "babel-eslint": "^8.2.6",
-    "babel-plugin-styled-components": "^1.10.0",
+    "babel-plugin-styled-components": "^1.10.6",
     "babel-preset-env": "^1.7.0",
     "chai": "^4.1.2",
-    "copyfiles": "^2.1.1",
+    "copyfiles": "^2.2.0",
     "deep-freeze": "^0.0.1",
-    "eslint": "^5.4.0",
+    "eslint": "^5.16.0",
     "eslint-config-airbnb": "^16.1.0",
-    "eslint-config-prettier": "^6.7.0",
-    "eslint-plugin-import": "^2.16.0",
-    "eslint-plugin-jsx-a11y": "^6.2.1",
+    "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.14.3",
+    "eslint-plugin-react": "^7.18.0",
     "husky": "^0.15.0-rc.13",
     "mocha": "^5.2.0",
     "nock": "^9.3.3",
-    "nodemon": "^1.18.10",
+    "nodemon": "^1.19.4",
     "prettier": "^1.19.1",
     "rimraf": "^3.0.0",
     "sinon": "^6.0.0",
-    "typescript": "^3.7.3"
+    "typescript": "^3.7.5"
   }
 }

+ 24 - 26
server/controllers/linkController.ts → server/__v1/controllers/linkController.ts

@@ -9,6 +9,7 @@ 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,
@@ -18,9 +19,9 @@ import {
   getStats,
   getUserLinksCount
 } from "../db/link";
-import transporter from "../mail/mail";
-import * as redis from "../redis";
-import { addProtocol, generateShortLink, getStatsCacheTime } from "../utils";
+import transporter from "../../mail/mail";
+import * as redis from "../../redis";
+import { addProtocol, generateShortLink, getStatsCacheTime } from "../../utils";
 import {
   checkBannedDomain,
   checkBannedHost,
@@ -29,14 +30,14 @@ import {
   preservedUrls,
   urlCountsCheck
 } from "./validateBodyController";
-import { visitQueue } from "../queues";
+import queue from "../../queues";
 
 const dnsLookup = promisify(dns.lookup);
 
 const generateId = async () => {
   const address = generate(
     "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890",
-    Number(process.env.LINK_LENGTH) || 6
+    env.LINK_LENGTH
   );
   const link = await findLink({ address });
   if (!link) return address;
@@ -49,9 +50,8 @@ export const shortener: Handler = async (req, res) => {
     const targetDomain = URL.parse(target).hostname;
 
     const queries = await Promise.all([
-      process.env.GOOGLE_SAFE_BROWSING_KEY && cooldownCheck(req.user),
-      process.env.GOOGLE_SAFE_BROWSING_KEY &&
-        malwareCheck(req.user, req.body.target),
+      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 &&
@@ -101,7 +101,7 @@ export const shortener: Handler = async (req, res) => {
       },
       req.user
     );
-    if (!req.user && Number(process.env.NON_USER_COOLDOWN)) {
+    if (!req.user && env.NON_USER_COOLDOWN) {
       addIP(req.realIP);
     }
 
@@ -115,7 +115,7 @@ export const goToLink: Handler = async (req, res, next) => {
   const { host } = req.headers;
   const reqestedId = req.params.id || req.body.id;
   const address = reqestedId.replace("+", "");
-  const customDomain = host !== process.env.DEFAULT_DOMAIN && host;
+  const customDomain = host !== env.DEFAULT_DOMAIN && host;
   const isBot = isbot(req.headers["user-agent"]);
 
   let domain;
@@ -126,7 +126,7 @@ export const goToLink: Handler = async (req, res, next) => {
   const link = await findLink({ address, domain_id: domain && domain.id });
 
   if (!link) {
-    if (host !== process.env.DEFAULT_DOMAIN) {
+    if (host !== env.DEFAULT_DOMAIN) {
       if (!domain || !domain.homepage) return next();
       return res.redirect(301, domain.homepage);
     }
@@ -156,7 +156,7 @@ export const goToLink: Handler = async (req, res, next) => {
       return res.status(401).json({ error: "Password is not correct" });
     }
     if (link.user_id && !isBot) {
-      visitQueue.add({
+      queue.visit.add({
         headers: req.headers,
         realIP: req.realIP,
         referrer: req.get("Referrer"),
@@ -168,7 +168,7 @@ export const goToLink: Handler = async (req, res, next) => {
   }
 
   if (link.user_id && !isBot) {
-    visitQueue.add({
+    queue.visit.add({
       headers: req.headers,
       realIP: req.realIP,
       referrer: req.get("Referrer"),
@@ -177,8 +177,8 @@ export const goToLink: Handler = async (req, res, next) => {
     });
   }
 
-  if (process.env.GOOGLE_ANALYTICS_UNIVERSAL && !isBot) {
-    const visitor = ua(process.env.GOOGLE_ANALYTICS_UNIVERSAL);
+  if (env.GOOGLE_ANALYTICS_UNIVERSAL && !isBot) {
+    const visitor = ua(env.GOOGLE_ANALYTICS_UNIVERSAL);
     visitor
       .pageview({
         dp: `/${address}`,
@@ -210,7 +210,7 @@ export const setCustomDomain: Handler = async (req, res) => {
       .status(400)
       .json({ error: "Maximum custom domain length is 40." });
   }
-  if (customDomain === process.env.DEFAULT_DOMAIN) {
+  if (customDomain === env.DEFAULT_DOMAIN) {
     return res.status(400).json({ error: "You can't use default domain." });
   }
   const isValidHomepage =
@@ -260,7 +260,7 @@ export const deleteCustomDomain: Handler = async (req, res) => {
 export const customDomainRedirection: Handler = async (req, res, next) => {
   const { headers, path } = req;
   if (
-    headers.host !== process.env.DEFAULT_DOMAIN &&
+    headers.host !== env.DEFAULT_DOMAIN &&
     (path === "/" ||
       preservedUrls
         .filter(l => l !== "url-password")
@@ -269,8 +269,7 @@ export const customDomainRedirection: Handler = async (req, res, next) => {
     const domain = await getDomain({ address: headers.host });
     return res.redirect(
       301,
-      (domain && domain.homepage) ||
-        `https://${process.env.DEFAULT_DOMAIN + path}`
+      (domain && domain.homepage) || `https://${env.DEFAULT_DOMAIN + path}`
     );
   }
   return next();
@@ -285,7 +284,7 @@ export const deleteUserLink: Handler = async (req, res) => {
 
   const response = await deleteLink({
     address: id,
-    domain: !domain || domain === process.env.DEFAULT_DOMAIN ? null : domain,
+    domain: !domain || domain === env.DEFAULT_DOMAIN ? null : domain,
     user_id: req.user.id
   });
 
@@ -302,8 +301,7 @@ export const getLinkStats: Handler = async (req, res) => {
   }
 
   const { hostname } = URL.parse(req.query.domain);
-  const hasCustomDomain =
-    req.query.domain && hostname !== process.env.DEFAULT_DOMAIN;
+  const hasCustomDomain = req.query.domain && hostname !== env.DEFAULT_DOMAIN;
   const customDomain = hasCustomDomain
     ? (await getDomain({ address: req.query.domain })) || ({ id: -1 } as Domain)
     : ({} as Domain);
@@ -341,15 +339,15 @@ export const reportLink: Handler = async (req, res) => {
   }
 
   const { hostname } = URL.parse(req.body.link);
-  if (hostname !== process.env.DEFAULT_DOMAIN) {
+  if (hostname !== env.DEFAULT_DOMAIN) {
     return res.status(400).json({
-      error: `You can only report a ${process.env.DEFAULT_DOMAIN} link`
+      error: `You can only report a ${env.DEFAULT_DOMAIN} link`
     });
   }
 
   const mail = await transporter.sendMail({
-    from: process.env.MAIL_USER,
-    to: process.env.REPORT_MAIL,
+    from: env.MAIL_USER,
+    to: env.REPORT_MAIL,
     subject: "[REPORT]",
     text: req.body.link,
     html: req.body.link

+ 16 - 14
server/controllers/validateBodyController.ts → server/__v1/controllers/validateBodyController.ts

@@ -1,19 +1,20 @@
+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 dns from "dns";
+import urlRegex from "url-regex";
 import axios from "axios";
+import dns from "dns";
 import URL from "url";
-import urlRegex from "url-regex";
-import { body } from "express-validator";
-import { differenceInMinutes, subHours, subDays, isAfter } from "date-fns";
-import { validationResult } from "express-validator";
 
+import { addProtocol, CustomError } from "../../utils";
 import { addCooldown, banUser } from "../db/user";
-import { getIP } from "../db/ip";
 import { getUserLinksCount } from "../db/link";
 import { getDomain } from "../db/domain";
 import { getHost } from "../db/host";
-import { addProtocol, CustomError } from "../utils";
+import { getIP } from "../db/ip";
+import env from "../../env";
 
 const dnsLookup = promisify(dns.lookup);
 
@@ -59,6 +60,7 @@ export const preservedUrls = [
   "banned",
   "terms",
   "privacy",
+  "protected",
   "report",
   "pricing"
 ];
@@ -82,10 +84,10 @@ export const validateUrl: RequestHandler = async (req, res, next) => {
 
   // If target is the URL shortener itself
   const { host } = URL.parse(addProtocol(req.body.target));
-  if (host === process.env.DEFAULT_DOMAIN) {
+  if (host === env.DEFAULT_DOMAIN) {
     return res
       .status(400)
-      .json({ error: `${process.env.DEFAULT_DOMAIN} URLs are not allowed.` });
+      .json({ error: `${env.DEFAULT_DOMAIN} URLs are not allowed.` });
   }
 
   // Validate password length
@@ -134,7 +136,7 @@ export const cooldownCheck = async (user: User) => {
 };
 
 export const ipCooldownCheck: RequestHandler = async (req, res, next) => {
-  const cooldownConfig = Number(process.env.NON_USER_COOLDOWN);
+  const cooldownConfig = env.NON_USER_COOLDOWN;
   if (req.user || !cooldownConfig) return next();
   const ip = await getIP(req.realIP);
   if (ip) {
@@ -151,10 +153,10 @@ export const ipCooldownCheck: RequestHandler = async (req, res, next) => {
 
 export const malwareCheck = async (user: User, target: string) => {
   const isMalware = await axios.post(
-    `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${process.env.GOOGLE_SAFE_BROWSING_KEY}`,
+    `https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${env.GOOGLE_SAFE_BROWSING_KEY}`,
     {
       client: {
-        clientId: process.env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
+        clientId: env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
         clientVersion: "1.0.0"
       },
       threatInfo: {
@@ -190,9 +192,9 @@ export const urlCountsCheck = async (user: User) => {
     user_id: user.id,
     date: subDays(new Date(), 1)
   });
-  if (count > Number(process.env.USER_LIMIT_PER_DAY)) {
+  if (count > env.USER_LIMIT_PER_DAY) {
     throw new CustomError(
-      `You have reached your daily limit (${process.env.USER_LIMIT_PER_DAY}). Please wait 24h.`
+      `You have reached your daily limit (${env.USER_LIMIT_PER_DAY}). Please wait 24h.`
     );
   }
 };

+ 3 - 3
server/db/domain.ts → server/__v1/db/domain.ts

@@ -1,6 +1,6 @@
-import knex from "../knex";
-import * as redis from "../redis";
-import { getRedisKey } from "../utils";
+import knex from "../../knex";
+import * as redis from "../../redis";
+import { getRedisKey } from "../../utils";
 
 export const getDomain = async (data: Partial<Domain>): Promise<Domain> => {
   const getData = {

+ 3 - 3
server/db/host.ts → server/__v1/db/host.ts

@@ -1,6 +1,6 @@
-import knex from "../knex";
-import * as redis from "../redis";
-import { getRedisKey } from "../utils";
+import knex from "../../knex";
+import * as redis from "../../redis";
+import { getRedisKey } from "../../utils";
 
 export const getHost = async (data: Partial<Host>) => {
   const getData = {

+ 4 - 6
server/db/ip.ts → server/__v1/db/ip.ts

@@ -1,6 +1,7 @@
 import { subMinutes } from "date-fns";
 
-import knex from "../knex";
+import knex from "../../knex";
+import env from "../../env";
 
 export const addIP = async (ipToGet: string) => {
   const ip = ipToGet.toLowerCase();
@@ -24,7 +25,7 @@ export const addIP = async (ipToGet: string) => {
   return ip;
 };
 export const getIP = async (ip: string) => {
-  const cooldownConfig = Number(process.env.NON_USER_COOLDOWN);
+  const cooldownConfig = env.NON_USER_COOLDOWN;
   const matchedIp = await knex<IP>("ips")
     .where({ ip: ip.toLowerCase() })
     .andWhere(
@@ -41,9 +42,6 @@ export const clearIPs = async () =>
     .where(
       "created_at",
       "<",
-      subMinutes(
-        new Date(),
-        Number(process.env.NON_USER_COOLDOWN)
-      ).toISOString()
+      subMinutes(new Date(), env.NON_USER_COOLDOWN).toISOString()
     )
     .delete();

+ 3 - 3
server/db/link.ts → server/__v1/db/link.ts

@@ -1,14 +1,14 @@
 import bcrypt from "bcryptjs";
 import { isAfter, subDays, set } from "date-fns";
-import knex from "../knex";
-import * as redis from "../redis";
+import knex from "../../knex";
+import * as redis from "../../redis";
 import {
   generateShortLink,
   getRedisKey,
   getUTCDate,
   getDifferenceFunction,
   statsObjectToArray
-} from "../utils";
+} from "../../utils";
 import { banDomain } from "./domain";
 import { banHost } from "./host";
 import { banUser } from "./user";

+ 3 - 3
server/db/user.ts → server/__v1/db/user.ts

@@ -3,9 +3,9 @@ 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";
+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);

+ 63 - 0
server/__v1/index.ts

@@ -0,0 +1,63 @@
+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";
+
+const router = Router();
+
+/* URL shortener */
+router.post(
+  "url/submit",
+  cors(),
+  asyncHandler(auth.apikey),
+  asyncHandler(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;

+ 0 - 62
server/configToEnv.ts

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

+ 0 - 274
server/controllers/authController.ts

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

+ 4 - 3
server/cron.ts

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

+ 41 - 0
server/env.ts

@@ -0,0 +1,41 @@
+import { cleanEnv, num, str, bool } from "envalid";
+
+const env = cleanEnv(process.env, {
+  PORT: num({ default: 3000 }),
+  DEFAULT_DOMAIN: str({ example: "kutt.it" }),
+  LINK_LENGTH: num({ default: 6 }),
+  DB_HOST: str({ default: "localhost" }),
+  DB_PORT: num({ default: 5432 }),
+  DB_NAME: str({ default: "postgres" }),
+  DB_USER: str(),
+  DB_PASSWORD: str(),
+  DB_SSL: bool({ default: false }),
+  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: "" }),
+  USER_LIMIT_PER_DAY: num({ default: 50 }),
+  NON_USER_COOLDOWN: num({ default: 10 }),
+  DEFAULT_MAX_STATS_PER_LINK: num({ default: 5000 }),
+  CUSTOM_DOMAIN_USE_HTTPS: bool({ default: false }),
+  JWT_SECRET: str(),
+  ADMIN_EMAILS: str({ default: "" }),
+  RECAPTCHA_SITE_KEY: str(),
+  RECAPTCHA_SECRET_KEY: str(),
+  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 }),
+  MAIL_USER: str(),
+  MAIL_FROM: str({ default: "", example: "Kutt <support@kutt.it>" }),
+  MAIL_PASSWORD: str(),
+  REPORT_EMAIL: str({ default: "" }),
+  CONTACT_EMAIL: str({ default: "" }),
+  RAVEN_DSN: str({ default: "" })
+});
+
+export default env;

+ 127 - 11
server/handlers/auth.ts

@@ -1,10 +1,17 @@
-import { differenceInMinutes, subMinutes } from "date-fns";
+import { differenceInMinutes, addMinutes, subMinutes } from "date-fns";
 import { Handler } from "express";
 import passport from "passport";
+import bcrypt from "bcryptjs";
+import nanoid from "nanoid";
+import uuid from "uuid/v4";
 import axios from "axios";
 
-import { isAdmin, CustomError } from "../utils";
+import { CustomError } from "../utils";
+import * as utils from "../utils";
+import * as mail from "../mail";
+import query from "../queries";
 import knex from "../knex";
+import env from "../env";
 
 const authenticate = (
   type: "jwt" | "local" | "localapikey",
@@ -14,10 +21,8 @@ const authenticate = (
   async function auth(req, res, next) {
     if (req.user) return next();
 
-    return passport.authenticate(type, (err, user) => {
-      if (err) {
-        throw new CustomError("An error occurred");
-      }
+    passport.authenticate(type, (err, user) => {
+      if (err) return next(err);
 
       if (!user && isStrict) {
         throw new CustomError(error, 401);
@@ -38,7 +43,7 @@ const authenticate = (
       if (user) {
         req.user = {
           ...user,
-          admin: isAdmin(user.email)
+          admin: utils.isAdmin(user.email)
         };
         return next();
       }
@@ -56,7 +61,7 @@ export const apikey = authenticate(
 );
 
 export const cooldown: Handler = async (req, res, next) => {
-  const cooldownConfig = Number(process.env.NON_USER_COOLDOWN);
+  const cooldownConfig = env.NON_USER_COOLDOWN;
   if (req.user || !cooldownConfig) return next();
 
   const ip = await knex<IP>("ips")
@@ -80,8 +85,7 @@ export const cooldown: Handler = async (req, res, next) => {
 };
 
 export const recaptcha: Handler = async (req, res, next) => {
-  if (process.env.NODE_ENV !== "production") return next();
-  if (req.user) return next();
+  if (env.isDev || req.user) return next();
 
   const isReCaptchaValid = await axios({
     method: "post",
@@ -90,7 +94,7 @@ export const recaptcha: Handler = async (req, res, next) => {
       "Content-type": "application/x-www-form-urlencoded"
     },
     params: {
-      secret: process.env.RECAPTCHA_SECRET_KEY,
+      secret: env.RECAPTCHA_SECRET_KEY,
       response: req.body.reCaptchaToken,
       remoteip: req.realIP
     }
@@ -102,3 +106,115 @@ export const recaptcha: Handler = async (req, res, next) => {
 
   return next();
 };
+
+export const admin: Handler = async (req, res, next) => {
+  if (req.user.admin) return next();
+  throw new CustomError("Unauthorized", 401);
+};
+
+export const signup: Handler = async (req, res) => {
+  const salt = await bcrypt.genSalt(12);
+  const password = await bcrypt.hash(req.body.password, salt);
+
+  const user = await query.user.add(
+    { email: req.body.email, password },
+    req.user
+  );
+
+  await mail.verification(user);
+
+  return res.status(201).send({ message: "Verification email has been sent." });
+};
+
+export const token: Handler = async (req, res) => {
+  const token = utils.signToken(req.user);
+  return res.status(200).send({ token });
+};
+
+export const verify: Handler = async (req, res, next) => {
+  if (!req.params.verificationToken) return next();
+
+  const [user] = await query.user.update(
+    {
+      verification_token: req.params.verificationToken,
+      verification_expires: [">", new Date().toISOString()]
+    },
+    {
+      verified: true,
+      verification_token: null,
+      verification_expires: null
+    }
+  );
+
+  if (user) {
+    const token = utils.signToken(user);
+    req.token = token;
+  }
+
+  return next();
+};
+
+export const changePassword: Handler = async (req, res) => {
+  const salt = await bcrypt.genSalt(12);
+  const password = await bcrypt.hash(req.body.password, salt);
+
+  const [user] = await query.user.update({ id: req.user.id }, { password });
+
+  if (!user) {
+    throw new CustomError("Couldn't change the password. Try again later.");
+  }
+
+  return res
+    .status(200)
+    .send({ message: "Your password has been changed successfully." });
+};
+
+export const generateApiKey = async (req, res) => {
+  const apikey = nanoid(40);
+
+  const [user] = await query.user.update({ id: req.user.id }, { apikey });
+
+  if (!user) {
+    throw new CustomError("Couldn't generate API key. Please try again later.");
+  }
+
+  return res.status(201).send({ apikey });
+};
+
+export const resetPasswordRequest = async (req, res) => {
+  const [user] = await query.user.update(
+    { email: req.body.email },
+    {
+      reset_password_token: uuid(),
+      reset_password_expires: addMinutes(new Date(), 30).toISOString()
+    }
+  );
+
+  if (user) {
+    await mail.resetPasswordToken(user);
+  }
+
+  return res.status(200).json({
+    error: "If email address exists, a reset password email has been sent."
+  });
+};
+
+export const resetPassword = async (req, res, next) => {
+  const { resetPasswordToken } = req.params;
+
+  if (resetPasswordToken) {
+    const [user] = await query.user.update(
+      {
+        reset_password_token: resetPasswordToken,
+        reset_password_expires: [">", new Date().toISOString()]
+      },
+      { reset_password_expires: null, reset_password_token: null }
+    );
+
+    if (user) {
+      const token = utils.signToken(user as UserJoined);
+      req.token = token;
+    }
+  }
+  return next();
+};

+ 31 - 0
server/handlers/domains.ts

@@ -0,0 +1,31 @@
+import { Handler } from "express";
+import query from "../queries";
+import { CustomError, sanitize } from "../utils";
+
+export const add: Handler = async (req, res) => {
+  const { address, homepage } = req.body;
+
+  const domain = await query.domain.add({
+    address,
+    homepage,
+    user_id: req.user.id
+  });
+
+  return res.status(200).send(sanitize.domain(domain));
+};
+
+export const remove: Handler = async (req, res) => {
+  const [domain] = await query.domain.update(
+    {
+      uuid: req.params.id,
+      user_id: req.user.id
+    },
+    { user_id: null }
+  );
+
+  if (!domain) {
+    throw new CustomError("Could not delete the domain.", 500);
+  }
+
+  return res.status(200).send({ message: "Domain deleted successfully" });
+};

+ 40 - 1
server/handlers/helpers.ts

@@ -1,4 +1,43 @@
-import { Handler } from "express";
+import { Handler, ErrorRequestHandler } from "express";
+import { validationResult } from "express-validator";
+import Raven from "raven";
+import signale from "signale";
+
+import { CustomError } from "../utils";
+import env from "../env";
+
+export const ip: Handler = (req, res, next) => {
+  req.realIP =
+    (req.headers["x-real-ip"] as string) || req.connection.remoteAddress || "";
+  return next();
+};
+
+export const error: ErrorRequestHandler = (error, req, res, next) => {
+  if (env.isDev) {
+    signale.fatal(error);
+  }
+
+  if (error instanceof CustomError) {
+    return res.status(error.statusCode || 500).json({ error: error.message });
+  }
+
+  if (env.RAVEN_DSN) {
+    Raven.captureException(error, {
+      user: { email: req.user && req.user.email }
+    });
+  }
+
+  return res.status(500).json({ error: "An error occurred." });
+};
+
+export const verify = (req, res, next) => {
+  const errors = validationResult(req);
+  if (!errors.isEmpty()) {
+    const message = errors.array()[0].msg;
+    throw new CustomError(message, 400);
+  }
+  return next();
+};
 
 export const query: Handler = (req, res, next) => {
   const { limit, skip, all } = req.query;

+ 335 - 96
server/handlers/links.ts

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

+ 0 - 21
server/handlers/sanitizers.ts

@@ -1,21 +0,0 @@
-import { sanitizeBody, CustomSanitizer } from "express-validator";
-import { addProtocol } from "../utils";
-
-const passIfUser: CustomSanitizer = (value, { req }) =>
-  req.user ? value : undefined;
-
-export const createLink = [
-  sanitizeBody("target")
-    .trim()
-    .customSanitizer(value => value && addProtocol(value)),
-  sanitizeBody("domain")
-    .customSanitizer(value =>
-      typeof value === "string" ? value.toLowerCase() : undefined
-    )
-    .customSanitizer(passIfUser),
-  sanitizeBody("password").customSanitizer(passIfUser),
-  sanitizeBody("customurl")
-    .customSanitizer(passIfUser)
-    .customSanitizer(value => value && value.trim()),
-  sanitizeBody("reuse").customSanitizer(passIfUser)
-];

+ 11 - 0
server/handlers/types.d.ts

@@ -0,0 +1,11 @@
+import { Request } from "express";
+
+export interface CreateLinkReq extends Request {
+  body: {
+    reuse?: boolean;
+    password?: string;
+    customurl?: string;
+    domain?: Domain;
+    target: string;
+  };
+}

+ 14 - 0
server/handlers/users.ts

@@ -0,0 +1,14 @@
+import query from "../queries";
+import * as utils from "../utils";
+
+export const get = async (req, res) => {
+  const domains = await query.domain.get({ user_id: req.user.id });
+
+  const data = {
+    apikey: req.user.apikey,
+    email: req.user.email,
+    domains: domains.map(utils.sanitize.domain)
+  };
+
+  return res.status(200).send(data);
+};

+ 305 - 18
server/handlers/validators.ts

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

+ 8 - 6
server/knex.ts

@@ -1,20 +1,22 @@
 import knex from "knex";
+
 import { createUserTable } from "./models/user";
 import { createDomainTable } from "./models/domain";
 import { createLinkTable } from "./models/link";
 import { createVisitTable } from "./models/visit";
 import { createIPTable } from "./models/ip";
 import { createHostTable } from "./models/host";
+import env from "./env";
 
 const db = knex({
   client: "postgres",
   connection: {
-    host: process.env.DB_HOST,
-    port: Number(process.env.DB_PORT) || 5432,
-    database: process.env.DB_NAME,
-    user: process.env.DB_USER,
-    password: process.env.DB_PASSWORD,
-    ssl: process.env.DB_SSL === "true"
+    host: env.DB_HOST,
+    port: env.DB_PORT,
+    database: env.DB_NAME,
+    user: env.DB_USER,
+    password: env.DB_PASSWORD,
+    ssl: env.DB_SSL
   }
 });
 

+ 1 - 0
server/mail/index.ts

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

+ 61 - 5
server/mail/mail.ts

@@ -1,15 +1,71 @@
 import nodemailer from "nodemailer";
+import path from "path";
+import fs from "fs";
+
+import { resetMailText, verifyMailText } from "./text";
+import { CustomError } from "../utils";
+import env from "../env";
 
 const mailConfig = {
-  host: process.env.MAIL_HOST,
-  port: Number(process.env.MAIL_PORT),
-  secure: process.env.MAIL_SECURE === "true",
+  host: env.MAIL_HOST,
+  port: env.MAIL_PORT,
+  secure: env.MAIL_SECURE,
   auth: {
-    user: process.env.MAIL_USER,
-    pass: process.env.MAIL_PASSWORD
+    user: env.MAIL_USER,
+    pass: env.MAIL_PASSWORD
   }
 };
 
 const transporter = nodemailer.createTransport(mailConfig);
 
 export default transporter;
+
+// Read email templates
+const resetEmailTemplatePath = path.join(__dirname, "template-reset.html");
+const verifyEmailTemplatePath = path.join(__dirname, "template-verify.html");
+const resetEmailTemplate = fs
+  .readFileSync(resetEmailTemplatePath, { encoding: "utf-8" })
+  .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN);
+const verifyEmailTemplate = fs
+  .readFileSync(verifyEmailTemplatePath, { encoding: "utf-8" })
+  .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN);
+
+export const verification = async (user: User) => {
+  const mail = await transporter.sendMail({
+    from: env.MAIL_FROM || env.MAIL_USER,
+    to: user.email,
+    subject: "Verify your account",
+    text: verifyMailText.replace(
+      /{{verification}}/gim,
+      user.verification_token
+    ),
+    html: verifyEmailTemplate.replace(
+      /{{verification}}/gim,
+      user.verification_token
+    )
+  });
+
+  if (!mail.accepted.length) {
+    throw new CustomError("Couldn't send verification email. Try again later.");
+  }
+};
+
+export const resetPasswordToken = async (user: User) => {
+  const mail = await transporter.sendMail({
+    from: env.MAIL_USER,
+    to: user.email,
+    subject: "Reset your password",
+    text: resetMailText
+      .replace(/{{resetpassword}}/gm, user.reset_password_token)
+      .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN),
+    html: resetEmailTemplate
+      .replace(/{{resetpassword}}/gm, user.reset_password_token)
+      .replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
+  });
+
+  if (!mail.accepted.length) {
+    throw new CustomError(
+      "Couldn't send reset password email. Try again later."
+    );
+  }
+};

+ 8 - 7
server/migration/01_host.ts

@@ -1,4 +1,5 @@
-require("dotenv").config();
+import env from "../env";
+
 import { v1 as NEO4J } from "neo4j-driver";
 import knex from "knex";
 import PQueue from "p-queue";
@@ -7,17 +8,17 @@ const queue = new PQueue({ concurrency: 10 });
 
 // 1. Connect to Neo4j database
 const neo4j = NEO4J.driver(
-  process.env.NEO4J_DB_URI,
-  NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
+  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: process.env.DB_HOST,
-    database: process.env.DB_NAME,
-    user: process.env.DB_USER,
-    password: process.env.DB_PASSWORD
+    host: env.DB_HOST,
+    database: env.DB_NAME,
+    user: env.DB_USER,
+    password: env.DB_PASSWORD
   }
 });
 

+ 9 - 8
server/migration/02_users.ts

@@ -1,23 +1,24 @@
-require("dotenv").config();
+import env from "../env";
+
 import { v1 as NEO4J } from "neo4j-driver";
-import knex from "knex";
 import PQuque from "p-queue";
+import knex from "knex";
 
 const queue = new PQuque({ concurrency: 10 });
 
 // 1. Connect to Neo4j database
 const neo4j = NEO4J.driver(
-  process.env.NEO4J_DB_URI,
-  NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
+  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: process.env.DB_HOST,
-    database: process.env.DB_NAME,
-    user: process.env.DB_USER,
-    password: process.env.DB_PASSWORD
+    host: env.DB_HOST,
+    database: env.DB_NAME,
+    user: env.DB_USER,
+    password: env.DB_PASSWORD
   }
 });
 

+ 9 - 8
server/migration/03_domains.ts

@@ -1,23 +1,24 @@
-require("dotenv").config();
+import env from "../env";
+
 import { v1 as NEO4J } from "neo4j-driver";
-import knex from "knex";
 import PQueue from "p-queue";
+import knex from "knex";
 
 const queue = new PQueue({ concurrency: 1 });
 
 // 1. Connect to Neo4j database
 const neo4j = NEO4J.driver(
-  process.env.NEO4J_DB_URI,
-  NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
+  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: process.env.DB_HOST,
-    database: process.env.DB_NAME,
-    user: process.env.DB_USER,
-    password: process.env.DB_PASSWORD
+    host: env.DB_HOST,
+    database: env.DB_NAME,
+    user: env.DB_USER,
+    password: env.DB_PASSWORD
   }
 });
 

+ 10 - 9
server/migration/04_links.ts

@@ -1,8 +1,9 @@
-require("dotenv").config();
+import env from "../env";
+
 import { v1 as NEO4J } from "neo4j-driver";
-import knex from "knex";
-import PQueue from "p-queue";
 import { startOfHour } from "date-fns";
+import PQueue from "p-queue";
+import knex from "knex";
 
 let count = 0;
 const queue = new PQueue({ concurrency: 5 });
@@ -11,18 +12,18 @@ queue.on("active", () => (count % 1000 === 0 ? console.log(count++) : count++));
 
 // 1. Connect to Neo4j database
 const neo4j = NEO4J.driver(
-  process.env.NEO4J_DB_URI,
-  NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
+  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: process.env.DB_HOST,
-    database: process.env.DB_NAME,
-    user: process.env.DB_USER,
-    password: process.env.DB_PASSWORD
+    host: env.DB_HOST,
+    database: env.DB_NAME,
+    user: env.DB_USER,
+    password: env.DB_PASSWORD
   }
 });
 

+ 4 - 3
server/migration/neo4j_delete_duplicated.ts

@@ -1,4 +1,5 @@
-require("dotenv").config();
+import env from "../env";
+
 import { v1 as NEO4J } from "neo4j-driver";
 import PQueue from "p-queue";
 
@@ -8,8 +9,8 @@ queue.on("active", () => console.log(count++));
 
 // 1. Connect to Neo4j database
 const neo4j = NEO4J.driver(
-  process.env.NEO4J_DB_URI,
-  NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
+  env.NEO4J_DB_URI,
+  NEO4J.auth.basic(env.NEO4J_DB_USERNAME, env.NEO4J_DB_PASSWORD)
 );
 
 (async function() {

+ 12 - 0
server/models/domain.ts

@@ -3,6 +3,7 @@ import * as Knex from "knex";
 export async function createDomainTable(knex: Knex) {
   const hasTable = await knex.schema.hasTable("domains");
   if (!hasTable) {
+    await knex.schema.raw('create extension if not exists "uuid-ossp"');
     await knex.schema.createTable("domains", table => {
       table.increments("id").primary();
       table
@@ -26,4 +27,15 @@ export async function createDomainTable(knex: Knex) {
       table.timestamps(false, true);
     });
   }
+
+  const hasUUID = await knex.schema.hasColumn("domains", "uuid");
+  if (!hasUUID) {
+    await knex.schema.raw('create extension if not exists "uuid-ossp"');
+    await knex.schema.alterTable("domains", table => {
+      table
+        .uuid("uuid")
+        .notNullable()
+        .defaultTo(knex.raw("uuid_generate_v4()"));
+    });
+  }
 }

+ 8 - 7
server/passport.ts

@@ -1,20 +1,21 @@
-import passport from "passport";
+import { Strategy as LocalAPIKeyStrategy } from "passport-localapikey-update";
 import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt";
 import { Strategy as LocalStratergy } from "passport-local";
-import { Strategy as LocalAPIKeyStrategy } from "passport-localapikey-update";
+import passport from "passport";
 import bcrypt from "bcryptjs";
 
-import { getUser } from "./db/user";
+import query from "./queries";
+import env from "./env";
 
 const jwtOptions = {
   jwtFromRequest: ExtractJwt.fromHeader("authorization"),
-  secretOrKey: process.env.JWT_SECRET
+  secretOrKey: env.JWT_SECRET
 };
 
 passport.use(
   new JwtStrategy(jwtOptions, async (payload, done) => {
     try {
-      const user = await getUser(payload.sub);
+      const user = await query.user.find({ email: payload.sub });
       if (!user) return done(null, false);
       return done(null, user);
     } catch (err) {
@@ -30,7 +31,7 @@ const localOptions = {
 passport.use(
   new LocalStratergy(localOptions, async (email, password, done) => {
     try {
-      const user = await getUser(email);
+      const user = await query.user.find({ email });
       if (!user) {
         return done(null, false);
       }
@@ -53,7 +54,7 @@ const localAPIKeyOptions = {
 passport.use(
   new LocalAPIKeyStrategy(localAPIKeyOptions, async (apikey, done) => {
     try {
-      const user = await getUser(apikey);
+      const user = await query.user.find({ apikey });
       if (!user) {
         return done(null, false);
       }

+ 71 - 21
server/queries/domain.ts

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

+ 62 - 0
server/queries/host.ts

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

+ 15 - 0
server/queries/index.ts

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

+ 35 - 0
server/queries/ip.ts

@@ -0,0 +1,35 @@
+import { subMinutes } from "date-fns";
+
+import knex from "../knex";
+import env from "../env";
+
+export const add = async (ipToAdd: string) => {
+  const ip = ipToAdd.toLowerCase();
+
+  const currentIP = await knex<IP>("ips")
+    .where("ip", ip)
+    .first();
+
+  if (currentIP) {
+    const currentDate = new Date().toISOString();
+    await knex<IP>("ips")
+      .where({ ip })
+      .update({
+        created_at: currentDate,
+        updated_at: currentDate
+      });
+  } else {
+    await knex<IP>("ips").insert({ ip });
+  }
+
+  return ip;
+};
+
+export const clear = async () =>
+  knex<IP>("ips")
+    .where(
+      "created_at",
+      "<",
+      subMinutes(new Date(), env.NON_USER_COOLDOWN).toISOString()
+    )
+    .delete();

+ 117 - 96
server/queries/link.ts

@@ -1,74 +1,84 @@
 import bcrypt from "bcryptjs";
 
-import { getRedisKey, generateShortLink } from "../utils";
+import { CustomError } from "../utils";
 import * as redis from "../redis";
 import knex from "../knex";
 
-interface GetTotal {
-  all: boolean;
-  userId: number;
+const selectable = [
+  "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"
+];
+
+const normalizeMatch = (match: Partial<Link>): Partial<Link> => {
+  const newMatch = { ...match };
+
+  if (newMatch.address) {
+    newMatch["links.address"] = newMatch.address;
+    delete newMatch.address;
+  }
+
+  if (newMatch.user_id) {
+    newMatch["links.user_id"] = newMatch.user_id;
+    delete newMatch.user_id;
+  }
+
+  if (newMatch.uuid) {
+    newMatch["links.uuid"] = newMatch.uuid;
+    delete newMatch.uuid;
+  }
+
+  return newMatch;
+};
+
+interface TotalParams {
   search?: string;
 }
 
-export const getTotalQuery = async ({ all, search, userId }: GetTotal) => {
-  const query = knex<Link>("links").count("id");
+export const total = async (match: Match<Link>, params: TotalParams = {}) => {
+  const query = knex<Link>("links");
 
-  if (!all) {
-    query.where("user_id", userId);
-  }
+  Object.entries(match).forEach(([key, value]) => {
+    query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
+  });
 
-  if (search) {
+  if (params.search) {
     query.andWhereRaw("links.address || ' ' || target ILIKE '%' || ? || '%'", [
-      search
+      params.search
     ]);
   }
 
-  const [{ count }] = await query;
+  const [{ count }] = await query.count("id");
 
   return typeof count === "number" ? count : parseInt(count);
 };
 
-interface GetLinks {
-  all: boolean;
+interface GetParams {
   limit: number;
   search?: string;
   skip: number;
-  userId: number;
 }
 
-export const getLinksQuery = async ({
-  all,
-  limit,
-  search,
-  skip,
-  userId
-}: GetLinks) => {
+export const get = async (match: Partial<Link>, params: GetParams) => {
   const query = 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(skip)
-    .limit(limit)
+    .select(...selectable)
+    .where(normalizeMatch(match))
+    .offset(params.skip)
+    .limit(params.limit)
     .orderBy("created_at", "desc");
 
-  if (!all) {
-    query.where("links.user_id", userId);
-  }
-
-  if (search) {
+  if (params.search) {
     query.andWhereRaw("links.address || ' ' || target ILIKE '%' || ? || '%'", [
-      search
+      params.search
     ]);
   }
 
@@ -79,79 +89,90 @@ export const getLinksQuery = async ({
   return links;
 };
 
-interface FindLink {
-  address?: string;
-  domainId?: number;
-  userId?: number;
-  target?: string;
-}
-
-export const findLinkQuery = async ({
-  address,
-  domainId,
-  userId,
-  target
-}: FindLink): Promise<Link> => {
-  const redisKey = getRedisKey.link(address, domainId, userId);
-  const cachedLink = await redis.get(redisKey);
-
-  if (cachedLink) return JSON.parse(cachedLink);
+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);
+    if (cachedLink) return JSON.parse(cachedLink);
+  }
 
   const link = await knex<Link>("links")
-    .where({
-      ...(address && { address }),
-      ...(domainId && { domain_id: domainId }),
-      ...(userId && { user_id: userId }),
-      ...(target && { target })
-    })
+    .select(...selectable)
+    .where(normalizeMatch(match))
+    .leftJoin("domains", "links.domain_id", "domains.id")
     .first();
 
   if (link) {
-    redis.set(redisKey, JSON.stringify(link), "EX", 60 * 60 * 2);
+    const key = redis.key.link(link.address, link.domain_id);
+    redis.set(key, JSON.stringify(link), "EX", 60 * 60 * 2);
   }
 
   return link;
 };
 
-interface CreateLink {
-  userId?: number;
-  domainAddress?: string;
-  domainId?: number;
-  password?: string;
+interface Create extends Partial<Link> {
   address: string;
   target: string;
 }
 
-export const createLinkQuery = async ({
-  password,
-  address,
-  target,
-  domainAddress,
-  domainId = null,
-  userId = null
-}: CreateLink) => {
-  let encryptedPassword;
-
-  if (password) {
+export const create = async (params: Create) => {
+  let encryptedPassword: string = null;
+
+  if (params.password) {
     const salt = await bcrypt.genSalt(12);
-    encryptedPassword = await bcrypt.hash(password, salt);
+    encryptedPassword = await bcrypt.hash(params.password, salt);
   }
 
-  const [link]: Link[] = await knex<Link>("links").insert(
+  const [link]: LinkJoinedDomain[] = await knex<LinkJoinedDomain>(
+    "links"
+  ).insert(
     {
       password: encryptedPassword,
-      domain_id: domainId,
-      user_id: userId,
-      address,
-      target
+      domain_id: params.domain_id || null,
+      user_id: params.user_id || null,
+      address: params.address,
+      target: params.target
     },
     "*"
   );
 
-  return {
-    ...link,
-    id: link.uuid,
-    password: !!password,
-    link: generateShortLink(address, domainAddress)
-  };
+  return link;
+};
+
+export const remove = async (match: Partial<Link>) => {
+  const link = await knex<Link>("links")
+    .where(match)
+    .first();
+
+  if (!link) {
+    throw new CustomError("Link was not found.");
+  }
+
+  await knex<Visit>("visits")
+    .where("link_id", link.id)
+    .delete();
+
+  const deletedLink = await knex<Link>("links")
+    .where("id", link.id)
+    .delete();
+
+  redis.remove.link(link);
+
+  return !!deletedLink;
+};
+
+export const update = async (match: Partial<Link>, update: Partial<Link>) => {
+  const links = await knex<Link>("links")
+    .where(match)
+    .update({ ...update, updated_at: new Date().toISOString() }, "*");
+
+  links.forEach(redis.remove.link);
+
+  return links;
+};
+
+export const increamentVisit = async (match: Partial<Link>) => {
+  return knex<Link>("links")
+    .where(match)
+    .increment("visit_count", 1);
 };

+ 75 - 0
server/queries/user.ts

@@ -0,0 +1,75 @@
+import uuid from "uuid/v4";
+import { addMinutes } from "date-fns";
+
+import * 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);
+    if (cachedUser) return JSON.parse(cachedUser) as User;
+  }
+
+  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);
+
+    if (user.apikey) {
+      const apikeyKey = redis.key.user(user.apikey);
+      redis.set(apikeyKey, JSON.stringify(user), "EX", 60 * 60 * 1);
+    }
+  }
+
+  return user;
+};
+
+interface Add {
+  email: string;
+  password: string;
+}
+
+export const add = async (params: Add, user?: User) => {
+  const data = {
+    email: params.email,
+    password: params.password,
+    verification_token: uuid(),
+    verification_expires: addMinutes(new Date(), 60).toISOString()
+  };
+
+  if (user) {
+    await knex<User>("users")
+      .where("id", user.id)
+      .update({ ...data, updated_at: new Date().toISOString() });
+  } else {
+    await knex<User>("users").insert(data);
+  }
+
+  redis.remove.user(user);
+
+  return {
+    ...user,
+    ...data
+  };
+};
+
+export const update = async (match: Match<User>, update: Partial<User>) => {
+  const query = knex<User>("users");
+
+  Object.entries(match).forEach(([key, value]) => {
+    query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
+  });
+
+  const users = await query.update(
+    { ...update, updated_at: new Date().toISOString() },
+    "*"
+  );
+
+  users.forEach(redis.remove.user);
+
+  return users;
+};

+ 245 - 0
server/queries/visit.ts

@@ -0,0 +1,245 @@
+import { isAfter, subDays, set } from "date-fns";
+
+import * as utils from "../utils";
+import * as redis from "../redis";
+import knex from "../knex";
+
+interface Add {
+  browser: string;
+  country: string;
+  domain?: string;
+  id: number;
+  os: string;
+  referrer: string;
+}
+
+export const add = async (params: Add) => {
+  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 StatsResult {
+  stats: {
+    browser: { name: string; value: number }[];
+    os: { name: string; value: number }[];
+    country: { name: string; value: number }[];
+    referrer: { name: string; value: number }[];
+  };
+  views: number[];
+}
+
+interface IGetStatsResponse {
+  allTime: StatsResult;
+  lastDay: StatsResult;
+  lastMonth: StatsResult;
+  lastWeek: StatsResult;
+  updatedAt: string;
+}
+
+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);
+    if (cached) return JSON.parse(cached);
+  }
+
+  const stats = {
+    lastDay: {
+      stats: utils.getInitStats(),
+      views: new Array(24).fill(0)
+    },
+    lastWeek: {
+      stats: utils.getInitStats(),
+      views: new Array(7).fill(0)
+    },
+    lastMonth: {
+      stats: utils.getInitStats(),
+      views: new Array(30).fill(0)
+    },
+    allTime: {
+      stats: utils.getInitStats(),
+      views: new Array(18).fill(0)
+    }
+  };
+
+  const visitsStream: any = knex<Visit>("visits")
+    .where(match)
+    .stream();
+  const nowUTC = utils.getUTCDate();
+  const now = new Date();
+
+  for await (const visit of visitsStream as Visit[]) {
+    utils.STATS_PERIODS.forEach(([days, type]) => {
+      const isIncluded = isAfter(
+        new Date(visit.created_at),
+        subDays(nowUTC, days)
+      );
+      if (isIncluded) {
+        const diffFunction = utils.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 = utils.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: utils.statsObjectToArray(stats.allTime.stats),
+      views: stats.allTime.views
+    },
+    lastDay: {
+      stats: utils.statsObjectToArray(stats.lastDay.stats),
+      views: stats.lastDay.views
+    },
+    lastMonth: {
+      stats: utils.statsObjectToArray(stats.lastMonth.stats),
+      views: stats.lastMonth.views
+    },
+    lastWeek: {
+      stats: utils.statsObjectToArray(stats.lastWeek.stats),
+      views: stats.lastWeek.views
+    },
+    updatedAt: new Date().toISOString()
+  };
+
+  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);
+  }
+
+  return response;
+};

+ 5 - 1
server/queues/index.ts

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

+ 9 - 7
server/queues/queues.ts

@@ -1,18 +1,20 @@
 import Queue from "bull";
 import path from "path";
 
+import env from "../env";
+
 const redis = {
-  port: Number(process.env.REDIS_PORT) || 6379,
-  host: process.env.REDIS_HOST || "127.0.0.1",
-  ...(process.env.REDIS_PASSWORD && { password: process.env.REDIS_PASSWORD })
+  port: env.REDIS_PORT,
+  host: env.REDIS_HOST,
+  ...(env.REDIS_PASSWORD && { password: env.REDIS_PASSWORD })
 };
 
 const removeJob = job => job.remove();
 
-export const visitQueue = new Queue("visit", { redis });
+export const visit = new Queue("visit", { redis });
 
-visitQueue.clean(5000, "completed");
+visit.clean(5000, "completed");
 
-visitQueue.process(4, path.resolve(__dirname, "visitQueue.js"));
+visit.process(4, path.resolve(__dirname, "visit.js"));
 
-visitQueue.on("completed", removeJob);
+visit.on("completed", removeJob);

+ 3 - 4
server/queues/visitQueue.ts → server/queues/visit.ts

@@ -2,7 +2,7 @@ import useragent from "useragent";
 import geoip from "geoip-lite";
 import URL from "url";
 
-import { createVisit, addLinkCount } from "../db/link";
+import query from "../queries";
 import { getStatsLimit } from "../utils";
 
 const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"];
@@ -15,7 +15,7 @@ const filterInOs = agent => item =>
 export default function({ data }) {
   const tasks = [];
 
-  tasks.push(addLinkCount(data.link.id));
+  tasks.push(query.link.increamentVisit(data.link.id));
 
   if (data.link.visit_count < getStatsLimit()) {
     const agent = useragent.parse(data.headers["user-agent"]);
@@ -25,10 +25,9 @@ export default function({ data }) {
     const location = geoip.lookup(data.realIP);
     const country = location && location.country;
     tasks.push(
-      createVisit({
+      query.visit.add({
         browser: browser.toLowerCase(),
         country: country || "Unknown",
-        domain: data.customDomain,
         id: data.link.id,
         os: os.toLowerCase().replace(/\s/gi, ""),
         referrer: (referrer && referrer.replace(/\./gi, "[dot]")) || "Direct"

+ 34 - 3
server/redis.ts

@@ -1,10 +1,12 @@
 import { promisify } from "util";
 import redis from "redis";
 
+import env from "./env";
+
 const client = redis.createClient({
-  host: process.env.REDIS_HOST || "127.0.0.1",
-  port: Number(process.env.REDIS_PORT) || 6379,
-  ...(process.env.REDIS_PASSWORD && { password: process.env.REDIS_PASSWORD })
+  host: env.REDIS_HOST,
+  port: env.REDIS_PORT,
+  ...(env.REDIS_PASSWORD && { password: env.REDIS_PASSWORD })
 });
 
 export const get: (key: string) => Promise<any> = promisify(client.get).bind(
@@ -21,3 +23,32 @@ export const set: (
 export const del: (key: string) => Promise<any> = promisify(client.del).bind(
   client
 );
+
+export const key = {
+  link: (address: string, domain_id?: number, user_id?: number) =>
+    `${address}-${domain_id || ""}-${user_id || ""}`,
+  domain: (address: string) => `d-${address}`,
+  stats: (link_id: number) => `s-${link_id}`,
+  host: (address: string) => `h-${address}`,
+  user: (emailOrKey: string) => `u-${emailOrKey}`
+};
+
+export const remove = {
+  domain: (domain?: Domain) => {
+    if (!domain) return;
+    del(key.domain(domain.address));
+  },
+  host: (host?: Host) => {
+    if (!host) return;
+    del(key.host(host.address));
+  },
+  link: (link?: Link) => {
+    if (!link) return;
+    del(key.link(link.address, link.domain_id));
+  },
+  user: (user?: User) => {
+    if (!user) return;
+    del(key.user(user.email));
+    del(key.user(user.apikey));
+  }
+};

+ 43 - 0
server/routes/auth.ts

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

+ 29 - 0
server/routes/domains.ts

@@ -0,0 +1,29 @@
+import { Router } from "express";
+import asyncHandler from "express-async-handler";
+
+import * as validators from "../handlers/validators";
+import * as helpers from "../handlers/helpers";
+import * as domains from "../handlers/domains";
+import * as auth from "../handlers/auth";
+
+const router = Router();
+
+router.post(
+  "/",
+  asyncHandler(auth.apikey),
+  asyncHandler(auth.jwt),
+  validators.addDomain,
+  asyncHandler(helpers.verify),
+  asyncHandler(domains.add)
+);
+
+router.delete(
+  "/:id",
+  asyncHandler(auth.apikey),
+  asyncHandler(auth.jwt),
+  validators.removeDomain,
+  asyncHandler(helpers.verify),
+  asyncHandler(domains.remove)
+);
+
+export default router;

+ 1 - 11
server/routes/index.ts

@@ -1,11 +1 @@
-import { Router } from "express";
-
-import health from "./health";
-import links from "./links";
-
-const router = Router();
-
-router.use("/api/v2/health", health);
-router.use("/api/v2/links", links);
-
-export default router;
+export { default } from "./routes";

+ 46 - 7
server/routes/links.ts

@@ -2,11 +2,10 @@ import { Router } from "express";
 import asyncHandler from "express-async-handler";
 import cors from "cors";
 
-import * as auth from "../handlers/auth";
 import * as validators from "../handlers/validators";
-import * as sanitizers from "../handlers/sanitizers";
 import * as helpers from "../handlers/helpers";
-import { getLinks, createLink } from "../handlers/links";
+import * as link from "../handlers/links";
+import * as auth from "../handlers/auth";
 
 const router = Router();
 
@@ -15,7 +14,7 @@ router.get(
   asyncHandler(auth.apikey),
   asyncHandler(auth.jwt),
   helpers.query,
-  getLinks
+  asyncHandler(link.get)
 );
 
 router.post(
@@ -24,10 +23,50 @@ router.post(
   asyncHandler(auth.apikey),
   asyncHandler(auth.jwtLoose),
   asyncHandler(auth.recaptcha),
-  sanitizers.createLink,
   validators.createLink,
-  asyncHandler(validators.verify),
-  createLink
+  asyncHandler(helpers.verify),
+  asyncHandler(link.create)
+);
+
+router.delete(
+  "/:id",
+  asyncHandler(auth.apikey),
+  asyncHandler(auth.jwt),
+  validators.deleteLink,
+  asyncHandler(helpers.verify),
+  asyncHandler(link.remove)
+);
+
+router.get(
+  "/:id/stats",
+  asyncHandler(auth.apikey),
+  asyncHandler(auth.jwt),
+  validators.getStats,
+  asyncHandler(link.stats)
+);
+
+router.post(
+  "/:id/protected",
+  validators.redirectProtected,
+  asyncHandler(helpers.verify),
+  asyncHandler(link.redirectProtected)
+);
+
+router.post(
+  "/report",
+  validators.reportLink,
+  asyncHandler(helpers.verify),
+  asyncHandler(link.report)
+);
+
+router.post(
+  "/admin/ban/:id",
+  asyncHandler(auth.apikey),
+  asyncHandler(auth.jwt),
+  asyncHandler(auth.admin),
+  validators.banLink,
+  asyncHandler(helpers.verify),
+  asyncHandler(link.ban)
 );
 
 export default router;

+ 17 - 0
server/routes/routes.ts

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

+ 16 - 0
server/routes/users.ts

@@ -0,0 +1,16 @@
+import { Router } from "express";
+import asyncHandler from "express-async-handler";
+
+import * as auth from "../handlers/auth";
+import * as user from "../handlers/users";
+
+const router = Router();
+
+router.get(
+  "/",
+  asyncHandler(auth.jwt),
+  asyncHandler(auth.apikey),
+  asyncHandler(user.get)
+);
+
+export default router;

+ 27 - 168
server/server.ts

@@ -1,52 +1,39 @@
-import "./configToEnv";
+import env from "./env";
 
-import dotenv from "dotenv";
-dotenv.config();
-
-import nextApp from "next";
-import express, { Request, Response } from "express";
+import asyncHandler from "express-async-handler";
+import cookieParser from "cookie-parser";
+import passport from "passport";
+import express from "express";
 import helmet from "helmet";
 import morgan from "morgan";
+import nextApp from "next";
 import Raven from "raven";
-import cookieParser from "cookie-parser";
-import passport from "passport";
-import cors from "cors";
-
-import {
-  validateBody,
-  validationCriterias,
-  validateUrl,
-  ipCooldownCheck
-} from "./controllers/validateBodyController";
-import * as auth from "./controllers/authController";
-import * as link from "./controllers/linkController";
+
+import * as helpers from "./handlers/helpers";
+import * as links from "./handlers/links";
+import * as auth from "./handlers/auth";
 import { initializeDb } from "./knex";
+import __v1Routes from "./__v1";
 import routes from "./routes";
 
 import "./cron";
 import "./passport";
-import { CustomError } from "./utils";
 
-if (process.env.RAVEN_DSN) {
-  Raven.config(process.env.RAVEN_DSN).install();
+if (env.RAVEN_DSN) {
+  Raven.config(env.RAVEN_DSN).install();
 }
 
-const catchErrors = fn => (req, res, next) =>
-  Promise.resolve(fn(req, res, next)).catch(next);
-
-const port = Number(process.env.PORT) || 3000;
-const dev = process.env.NODE_ENV !== "production";
-const app = nextApp({ dir: "./client", dev });
+const port = env.PORT;
+const app = nextApp({ dir: "./client", dev: env.isDev });
 const handle = app.getRequestHandler();
 
 app.prepare().then(async () => {
   await initializeDb();
 
   const server = express();
-
   server.set("trust proxy", true);
 
-  if (process.env.NODE_ENV !== "production") {
+  if (env.isDev) {
     server.use(morgan("dev"));
   }
 
@@ -56,161 +43,33 @@ app.prepare().then(async () => {
   server.use(express.urlencoded({ extended: true }));
   server.use(passport.initialize());
   server.use(express.static("static"));
+  server.use(helpers.ip);
 
-  server.use((req, _res, next) => {
-    req.realIP =
-      (req.headers["x-real-ip"] as string) ||
-      req.connection.remoteAddress ||
-      "";
-    return next();
-  });
-
-  server.use(link.customDomainRedirection);
-
-  server.use(routes);
+  server.use(asyncHandler(links.redirectCustomDomain));
 
-  server.get("/", (req, res) => app.render(req, res, "/"));
-  server.get("/login", (req, res) => app.render(req, res, "/login"));
-  server.get("/logout", (req, res) => app.render(req, res, "/logout"));
-  server.get("/settings", (req, res) => app.render(req, res, "/settings"));
-  server.get("/stats", (req, res) => app.render(req, res, "/stats", req.query));
-  server.get("/terms", (req, res) => app.render(req, res, "/terms"));
-  server.get("/report", (req, res) => app.render(req, res, "/report"));
-  server.get("/banned", (req, res) => app.render(req, res, "/banned"));
+  server.use("/api/v2", routes);
+  server.use("/api", __v1Routes);
 
-  /* View routes */
   server.get(
     "/reset-password/:resetPasswordToken?",
-    catchErrors(auth.resetUserPassword),
+    asyncHandler(auth.resetPassword),
     (req, res) => app.render(req, res, "/reset-password", { token: req.token })
   );
+
   server.get(
     "/verify/:verificationToken?",
-    catchErrors(auth.verify),
+    asyncHandler(auth.verify),
     (req, res) => app.render(req, res, "/verify", { token: req.token })
   );
 
-  /* User and authentication */
-  server.post(
-    "/api/auth/signup",
-    validationCriterias,
-    catchErrors(validateBody),
-    catchErrors(auth.signup)
-  );
-  server.post(
-    "/api/auth/login",
-    validationCriterias,
-    catchErrors(validateBody),
-    catchErrors(auth.authLocal),
-    catchErrors(auth.login)
-  );
-  server.post(
-    "/api/auth/renew",
-    catchErrors(auth.authJwt),
-    catchErrors(auth.renew)
-  );
-  server.post(
-    "/api/auth/changepassword",
-    catchErrors(auth.authJwt),
-    catchErrors(auth.changeUserPassword)
-  );
-  server.post(
-    "/api/auth/generateapikey",
-    catchErrors(auth.authJwt),
-    catchErrors(auth.generateUserApiKey)
-  );
-  server.post(
-    "/api/auth/resetpassword",
-    catchErrors(auth.requestUserPasswordReset)
-  );
-  server.get(
-    "/api/auth/usersettings",
-    catchErrors(auth.authJwt),
-    catchErrors(auth.userSettings)
-  );
+  server.get("/:id", asyncHandler(links.redirect(app)));
 
-  /* URL shortener */
-  server.post(
-    "/api/url/submit",
-    cors(),
-    catchErrors(auth.authApikey),
-    catchErrors(auth.authJwtLoose),
-    catchErrors(auth.recaptcha),
-    catchErrors(validateUrl),
-    catchErrors(ipCooldownCheck),
-    catchErrors(link.shortener)
-  );
-  server.post(
-    "/api/url/deleteurl",
-    catchErrors(auth.authApikey),
-    catchErrors(auth.authJwt),
-    catchErrors(link.deleteUserLink)
-  );
-  server.get(
-    "/api/url/geturls",
-    catchErrors(auth.authApikey),
-    catchErrors(auth.authJwt),
-    catchErrors(link.getUserLinks)
-  );
-  server.post(
-    "/api/url/customdomain",
-    catchErrors(auth.authJwt),
-    catchErrors(link.setCustomDomain)
-  );
-  server.delete(
-    "/api/url/customdomain",
-    catchErrors(auth.authJwt),
-    catchErrors(link.deleteCustomDomain)
-  );
-  server.get(
-    "/api/url/stats",
-    catchErrors(auth.authApikey),
-    catchErrors(auth.authJwt),
-    catchErrors(link.getLinkStats)
-  );
-  server.post("/api/url/requesturl", catchErrors(link.goToLink));
-  server.post("/api/url/report", catchErrors(link.reportLink));
-  server.post(
-    "/api/url/admin/ban",
-    catchErrors(auth.authApikey),
-    catchErrors(auth.authJwt),
-    catchErrors(auth.authAdmin),
-    catchErrors(link.ban)
-  );
-  server.get(
-    "/:id",
-    catchErrors(link.goToLink),
-    (req: Request, res: Response) => {
-      switch (req.pageType) {
-        case "password":
-          return app.render(req, res, "/url-password", {
-            protectedLink: req.protectedLink
-          });
-        case "info":
-        default:
-          return app.render(req, res, "/url-info", {
-            linkTarget: req.linkTarget
-          });
-      }
-    }
-  );
+  // Error handler
+  server.use(helpers.error);
 
+  // Handler everything else by Next.js
   server.get("*", (req, res) => handle(req, res));
 
-  server.use((error, req, res, next) => {
-    if (error instanceof CustomError) {
-      return res.status(error.statusCode || 500).json({ error: error.message });
-    }
-
-    if (process.env.RAVEN_DSN) {
-      Raven.captureException(error, {
-        user: { email: req.user && req.user.email }
-      });
-    }
-
-    return res.status(500).json({ error: "An error occurred." });
-  });
-
   server.listen(port, err => {
     if (err) throw err;
     console.log(`> Ready on http://localhost:${port}`);

+ 84 - 35
server/utils/index.ts

@@ -1,11 +1,15 @@
 import ms from "ms";
+import generate from "nanoid/generate";
+import JWT from "jsonwebtoken";
 import {
   differenceInDays,
   differenceInHours,
-  differenceInMonths
+  differenceInMonths,
+  addDays
 } from "date-fns";
-import generate from "nanoid/generate";
-import { findLinkQuery } from "../queries/link";
+
+import query from "../queries";
+import env from "../env";
 
 export class CustomError extends Error {
   public statusCode?: number;
@@ -18,14 +22,32 @@ export class CustomError extends Error {
   }
 }
 
-export const generateId = async (domainId: number = null) => {
+export const isAdmin = (email: string): boolean =>
+  env.ADMIN_EMAILS.split(",")
+    .map(e => e.trim())
+    .includes(email);
+
+export const signToken = (user: UserJoined) =>
+  JWT.sign(
+    {
+      iss: "ApiAuth",
+      sub: user.email,
+      domain: user.domain || "",
+      admin: isAdmin(user.email),
+      iat: parseInt((new Date().getTime() / 1000).toFixed(0)),
+      exp: parseInt((addDays(new Date(), 7).getTime() / 1000).toFixed(0))
+    } as Record<string, any>,
+    env.JWT_SECRET
+  );
+
+export const generateId = async (domain_id: number = null) => {
   const address = generate(
     "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890",
-    Number(process.env.LINK_LENGTH) || 6
+    env.LINK_LENGTH
   );
-  const link = await findLinkQuery({ address, domainId });
+  const link = await query.link.find({ address, domain_id });
   if (!link) return address;
-  return generateId(domainId);
+  return generateId(domain_id);
 };
 
 export const addProtocol = (url: string): string => {
@@ -35,18 +57,12 @@ export const addProtocol = (url: string): string => {
 
 export const generateShortLink = (id: string, domain?: string): string => {
   const protocol =
-    process.env.CUSTOM_DOMAIN_USE_HTTPS === "true" || !domain
-      ? "https://"
-      : "http://";
-  return `${protocol}${domain || process.env.DEFAULT_DOMAIN}/${id}`;
+    env.CUSTOM_DOMAIN_USE_HTTPS || !domain ? "https://" : "http://";
+  return `${protocol}${domain || env.DEFAULT_DOMAIN}/${id}`;
 };
 
-export const isAdmin = (email: string): boolean =>
-  process.env.ADMIN_EMAILS.split(",")
-    .map(e => e.trim())
-    .includes(email);
-
 export const getRedisKey = {
+  // TODO: remove user id and make domain id required
   link: (address: string, domain_id?: number, user_id?: number) =>
     `${address}-${domain_id || ""}-${user_id || ""}`,
   domain: (address: string) => `d-${address}`,
@@ -56,27 +72,10 @@ export const getRedisKey = {
 
 // TODO: Add statsLimit
 export const getStatsLimit = (): number =>
-  Number(process.env.DEFAULT_MAX_STATS_PER_LINK) || 100000000;
+  env.DEFAULT_MAX_STATS_PER_LINK || 100000000;
 
 export const getStatsCacheTime = (total?: number): number => {
-  let durationInMs;
-  switch (true) {
-    case total <= 5000:
-      durationInMs = ms("5 minutes");
-      break;
-    case total > 5000 && total < 20000:
-      durationInMs = ms("10 minutes");
-      break;
-    case total < 40000:
-      durationInMs = ms("15 minutes");
-      break;
-    case total > 40000:
-      durationInMs = ms("30 minutes");
-      break;
-    default:
-      durationInMs = ms("5 minutes");
-  }
-  return durationInMs / 1000;
+  return (total > 50000 ? ms("5 minutes") : ms("1 minutes")) / 1000;
 };
 
 export const statsObjectToArray = (obj: Stats) => {
@@ -115,3 +114,53 @@ export const getUTCDate = (dateString?: Date) => {
     date.getUTCHours()
   );
 };
+
+export const STATS_PERIODS: [number, "lastDay" | "lastWeek" | "lastMonth"][] = [
+  [1, "lastDay"],
+  [7, "lastWeek"],
+  [30, "lastMonth"]
+];
+
+export const getInitStats = (): Stats => {
+  return Object.create({
+    browser: {
+      chrome: 0,
+      edge: 0,
+      firefox: 0,
+      ie: 0,
+      opera: 0,
+      other: 0,
+      safari: 0
+    },
+    os: {
+      android: 0,
+      ios: 0,
+      linux: 0,
+      macos: 0,
+      other: 0,
+      windows: 0
+    },
+    country: {},
+    referrer: {}
+  });
+};
+
+export const sanitize = {
+  domain: (domain: Domain): DomainSanitized => ({
+    ...domain,
+    id: domain.uuid,
+    uuid: undefined,
+    user_id: undefined,
+    banned_by_id: undefined
+  }),
+  link: (link: LinkJoinedDomain): LinkSanitized => ({
+    ...link,
+    banned_by_id: undefined,
+    domain_id: undefined,
+    user_id: undefined,
+    uuid: undefined,
+    id: link.uuid,
+    password: !!link.password,
+    link: generateShortLink(link.address, link.domain)
+  })
+};

Vissa filer visades inte eftersom för många filer har ändrats