Procházet zdrojové kódy

feat: (wip) api v3

poeti8 před 6 roky
rodič
revize
d1d335c8b1
40 změnil soubory, kde provedl 1109 přidání a 339 odebrání
  1. 13 2
      client/components/AppWrapper.tsx
  2. 0 110
      client/components/Checkbox.tsx
  3. 252 0
      client/components/Input.tsx
  4. 64 34
      client/components/LinksTable.tsx
  5. 11 6
      client/components/Settings/SettingsApi.tsx
  6. 4 5
      client/components/Settings/SettingsBan.tsx
  7. 11 12
      client/components/Settings/SettingsDomain.tsx
  8. 3 3
      client/components/Settings/SettingsPassword.tsx
  9. 43 14
      client/components/Shortener.tsx
  10. 1 1
      client/components/Table.ts
  11. 0 83
      client/components/TextInput.tsx
  12. 4 0
      client/consts/consts.ts
  13. 5 5
      client/pages/login.tsx
  14. 3 3
      client/pages/report.tsx
  15. 1 1
      client/pages/reset-password.tsx
  16. 0 5
      client/pages/settings.tsx
  17. 2 2
      client/pages/stats.tsx
  18. 1 1
      client/pages/url-password.tsx
  19. 18 12
      client/store/links.ts
  20. 17 14
      client/store/settings.ts
  21. 1 1
      client/types.ts
  22. 6 1
      client/utils.ts
  23. 1 0
      global.d.ts
  24. 1 1
      server/controllers/authController.ts
  25. 6 9
      server/controllers/validateBodyController.ts
  26. 1 0
      server/db/link.ts
  27. 104 0
      server/handlers/auth.ts
  28. 17 0
      server/handlers/helpers.ts
  29. 118 0
      server/handlers/links.ts
  30. 21 0
      server/handlers/sanitizers.ts
  31. 84 0
      server/handlers/validators.ts
  32. 13 0
      server/models/link.ts
  33. 34 0
      server/queries/domain.ts
  34. 157 0
      server/queries/link.ts
  35. 0 2
      server/queues/queues.ts
  36. 7 0
      server/routes/health.ts
  37. 11 0
      server/routes/index.ts
  38. 33 0
      server/routes/links.ts
  39. 18 12
      server/server.ts
  40. 23 0
      server/utils/index.ts

+ 13 - 2
client/components/AppWrapper.tsx

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

+ 0 - 110
client/components/Checkbox.tsx

@@ -1,110 +0,0 @@
-import React, { FC } from "react";
-import styled, { css, keyframes } from "styled-components";
-import { ifProp } from "styled-tools";
-import { Flex, BoxProps } from "reflexbox/styled-components";
-
-import { Span } from "./Text";
-
-interface InputProps {
-  checked: boolean;
-  id?: string;
-  name: string;
-  onChange: any;
-}
-
-const Input = styled(Flex).attrs({
-  as: "input",
-  type: "checkbox",
-  m: 0,
-  p: 0,
-  width: 0,
-  height: 0,
-  opacity: 0
-})<InputProps>`
-  position: relative;
-  opacity: 0;
-`;
-
-const Box = styled(Flex).attrs({
-  alignItems: "center",
-  justifyContent: "center"
-})<{ checked: boolean }>`
-  position: relative;
-  transition: color 0.3s ease-out;
-  border-radius: 4px;
-  background-color: white;
-  box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2);
-  cursor: pointer;
-
-  input:focus + & {
-    outline: 3px solid rgba(65, 164, 245, 0.5);
-  }
-
-  ${ifProp(
-    "checked",
-    css`
-      box-shadow: 0 3px 5px rgba(50, 50, 50, 0.4);
-
-      :after {
-        content: "";
-        position: absolute;
-        width: 80%;
-        height: 80%;
-        display: block;
-        border-radius: 2px;
-        background-color: #9575cd;
-        box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2);
-        cursor: pointer;
-        animation: ${keyframes`
-          from {
-            opacity: 0;
-            transform: scale(0, 0);
-          }
-          to {
-            opacity: 1;
-            transform: scale(1, 1);
-          }
-        `} 0.1s ease-in;
-      }
-    `
-  )}
-`;
-
-interface Props extends InputProps, BoxProps {
-  label: string;
-}
-
-const Checkbox: FC<Props> = ({
-  checked,
-  height,
-  id,
-  label,
-  name,
-  width,
-  onChange,
-  ...rest
-}) => {
-  return (
-    <Flex
-      flex="0 0 auto"
-      as="label"
-      alignItems="center"
-      style={{ cursor: "pointer" }}
-      {...(rest as any)}
-    >
-      <Input onChange={onChange} name={name} id={id} checked={checked} />
-      <Box checked={checked} width={width} height={height} />
-      <Span ml={[10, 12]} mt="1px" color="#555">
-        {label}
-      </Span>
-    </Flex>
-  );
-};
-
-Checkbox.defaultProps = {
-  width: [16, 18],
-  height: [16, 18],
-  fontSize: [15, 16]
-};
-
-export default Checkbox;

+ 252 - 0
client/components/Input.tsx

@@ -0,0 +1,252 @@
+import { Flex, BoxProps } from "reflexbox/styled-components";
+import styled, { css, keyframes } from "styled-components";
+import { withProp, prop, ifProp } from "styled-tools";
+import { FC } from "react";
+
+import { Span } from "./Text";
+
+interface StyledSelectProps extends BoxProps {
+  autoFocus?: boolean;
+  name?: string;
+  id?: string;
+  type?: string;
+  value?: string;
+  required?: boolean;
+  onChange?: any;
+  placeholderSize?: number[];
+  br?: string;
+  bbw?: string;
+}
+
+export const TextInput = styled(Flex).attrs({
+  as: "input"
+})<StyledSelectProps>`
+  position: relative;
+  box-sizing: border-box;
+  letter-spacing: 0.05em;
+  color: #444;
+  background-color: white;
+  box-shadow: 0 10px 35px hsla(200, 15%, 70%, 0.2);
+  border: none;
+  border-radius: ${prop("br", "100px")};
+  border-bottom: 5px solid #f5f5f5;
+  border-bottom-width: ${prop("bbw", "5px")};
+  transition: all 0.5s ease-out;
+
+  :focus {
+    outline: none;
+    box-shadow: 0 20px 35px hsla(200, 15%, 70%, 0.4);
+  }
+
+  ::placeholder {
+    font-size: ${withProp("placeholderSize", s => s[0] || 14)}px;
+    letter-spacing: 0.05em;
+    color: #888;
+  }
+
+  @media screen and (min-width: 64em) {
+    ::placeholder {
+      font-size: ${withProp(
+        "placeholderSize",
+        s => s[3] || s[2] || s[1] || s[0] || 16
+      )}px;
+    }
+  }
+
+  @media screen and (min-width: 52em) {
+    letter-spacing: 0.1em;
+    border-bottom-width: ${prop("bbw", "6px")};
+    ::placeholder {
+      font-size: ${withProp(
+        "placeholderSize",
+        s => s[2] || s[1] || s[0] || 15
+      )}px;
+    }
+  }
+
+  @media screen and (min-width: 40em) {
+    ::placeholder {
+      font-size: ${withProp("placeholderSize", s => s[1] || s[0] || 15)}px;
+    }
+  }
+`;
+
+TextInput.defaultProps = {
+  value: "",
+  height: [40, 44],
+  py: 0,
+  px: [3, 24],
+  fontSize: [14, 15],
+  placeholderSize: [13, 14]
+};
+
+interface StyledSelectProps extends BoxProps {
+  name?: string;
+  id?: string;
+  type?: string;
+  value?: string;
+  required?: boolean;
+  onChange?: any;
+  br?: string;
+  bbw?: string;
+}
+
+interface SelectOptions extends StyledSelectProps {
+  options: Array<{ key: string; value: string | number }>;
+}
+
+const StyledSelect: FC<StyledSelectProps> = styled(Flex).attrs({
+  as: "select"
+})<StyledSelectProps>`
+  position: relative;
+  box-sizing: border-box;
+  letter-spacing: 0.05em;
+  color: #444;
+  background-color: white;
+  box-shadow: 0 10px 35px hsla(200, 15%, 70%, 0.2);
+  border: none;
+  border-radius: ${prop("br", "100px")};
+  border-bottom: 5px solid #f5f5f5;
+  border-bottom-width: ${prop("bbw", "5px")};
+  transition: all 0.5s ease-out;
+  appearance: none;
+  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='48' viewBox='0 0 24 24' fill='none' stroke='%235c666b' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
+  background-repeat: no-repeat, repeat;
+  background-position: right 1.2em top 50%, 0 0;
+  background-size: 1em auto, 100%;
+
+  :focus {
+    outline: none;
+    box-shadow: 0 20px 35px hsla(200, 15%, 70%, 0.4);
+  }
+
+  @media screen and (min-width: 52em) {
+    letter-spacing: 0.1em;
+    border-bottom-width: ${prop("bbw", "6px")};
+  }
+`;
+
+export const Select: FC<SelectOptions> = ({ options, ...props }) => (
+  <StyledSelect {...props}>
+    {options.map(({ key, value }) => (
+      <option key={value} value={value}>
+        {key}
+      </option>
+    ))}
+  </StyledSelect>
+);
+
+Select.defaultProps = {
+  value: "",
+  height: [40, 44],
+  py: 0,
+  px: [3, 24],
+  fontSize: [14, 15]
+};
+
+interface ChecknoxInputProps {
+  checked: boolean;
+  id?: string;
+  name: string;
+  onChange: any;
+}
+
+const CheckboxInput = styled(Flex).attrs({
+  as: "input",
+  type: "checkbox",
+  m: 0,
+  p: 0,
+  width: 0,
+  height: 0,
+  opacity: 0
+})<ChecknoxInputProps>`
+  position: relative;
+  opacity: 0;
+`;
+
+const CheckboxBox = styled(Flex).attrs({
+  alignItems: "center",
+  justifyContent: "center"
+})<{ checked: boolean }>`
+  position: relative;
+  transition: color 0.3s ease-out;
+  border-radius: 4px;
+  background-color: white;
+  box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2);
+  cursor: pointer;
+
+  input:focus + & {
+    outline: 3px solid rgba(65, 164, 245, 0.5);
+  }
+
+  ${ifProp(
+    "checked",
+    css`
+      box-shadow: 0 3px 5px rgba(50, 50, 50, 0.4);
+
+      :after {
+        content: "";
+        position: absolute;
+        width: 80%;
+        height: 80%;
+        display: block;
+        border-radius: 2px;
+        background-color: #9575cd;
+        box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2);
+        cursor: pointer;
+        animation: ${keyframes`
+          from {
+            opacity: 0;
+            transform: scale(0, 0);
+          }
+          to {
+            opacity: 1;
+            transform: scale(1, 1);
+          }
+        `} 0.1s ease-in;
+      }
+    `
+  )}
+`;
+
+interface CheckboxProps extends ChecknoxInputProps, BoxProps {
+  label: string;
+}
+
+export const Checkbox: FC<CheckboxProps> = ({
+  checked,
+  height,
+  id,
+  label,
+  name,
+  width,
+  onChange,
+  ...rest
+}) => {
+  return (
+    <Flex
+      flex="0 0 auto"
+      as="label"
+      alignItems="center"
+      style={{ cursor: "pointer" }}
+      {...(rest as any)}
+    >
+      <CheckboxInput
+        onChange={onChange}
+        name={name}
+        id={id}
+        checked={checked}
+      />
+      <CheckboxBox checked={checked} width={width} height={height} />
+      <Span ml={[10, 12]} mt="1px" color="#555">
+        {label}
+      </Span>
+    </Flex>
+  );
+};
+
+Checkbox.defaultProps = {
+  width: [16, 18],
+  height: [16, 18],
+  fontSize: [15, 16]
+};

+ 64 - 34
client/components/LinksTable.tsx

@@ -8,19 +8,20 @@ import QRCode from "qrcode.react";
 import Link from "next/link";
 
 import { useStoreActions, useStoreState } from "../store";
-import { removeProtocol, withComma } from "../utils";
+import { removeProtocol, withComma, errorMessage } from "../utils";
+import { Checkbox, TextInput } from "./Input";
 import { NavButton, Button } from "./Button";
 import { Col, RowCenter } from "./Layout";
+import Text, { H2, Span } from "./Text";
 import { ifProp } from "styled-tools";
-import TextInput from "./TextInput";
 import Animation from "./Animation";
+import { Colors } from "../consts";
 import Tooltip from "./Tooltip";
 import Table from "./Table";
 import ALink from "./ALink";
 import Modal from "./Modal";
-import Text, { H2, Span } from "./Text";
 import Icon from "./Icon";
-import { Colors } from "../consts";
+import { useMessage } from "../hooks";
 
 const Tr = styled(Flex).attrs({ as: "tr", px: [12, 12, 2] })``;
 const Th = styled(Flex)``;
@@ -87,26 +88,33 @@ const viewsFlex = {
 const actionsFlex = { flexGrow: [1, 1, 2.5], flexShrink: [1, 1, 2.5] };
 
 interface Form {
-  count?: string;
-  page?: string;
-  search?: string;
+  all: boolean;
+  limit: string;
+  skip: string;
+  search: string;
 }
 
 const LinksTable: FC = () => {
+  const isAdmin = useStoreState(s => s.auth.isAdmin);
   const links = useStoreState(s => s.links);
   const { get, deleteOne } = 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 [formState, { text }] = useFormState<Form>({ page: "1", count: "10" });
+  const [deleteMessage, setDeleteMessage] = useMessage();
+  const [formState, { label, checkbox, text }] = useFormState<Form>(
+    { skip: "0", limit: "10", all: false },
+    { withIds: true }
+  );
 
   const options = formState.values;
   const linkToDelete = links.items[deleteModal];
 
   useEffect(() => {
-    get(options);
-  }, [options.count, options.page]);
+    get(options).catch(err => setTableMessage(err?.response?.data?.error));
+  }, [options.limit, options.skip, options.all]);
 
   const onSubmit = e => {
     e.preventDefault();
@@ -122,14 +130,21 @@ const LinksTable: FC = () => {
 
   const onDelete = async () => {
     setDeleteLoading(true);
-    await deleteOne({ id: linkToDelete.address, domain: linkToDelete.domain });
-    await get(options);
+    try {
+      await deleteOne({
+        id: linkToDelete.address,
+        domain: linkToDelete.domain
+      });
+      await get(options);
+      setDeleteModal(-1);
+    } catch (err) {
+      setDeleteMessage(errorMessage(err));
+    }
     setDeleteLoading(false);
-    setDeleteModal(-1);
   };
 
   const onNavChange = (nextPage: number) => () => {
-    formState.setField("page", (parseInt(options.page) + nextPage).toString());
+    formState.setField("skip", (parseInt(options.skip) + nextPage).toString());
   };
 
   const Nav = (
@@ -143,8 +158,11 @@ const LinksTable: FC = () => {
         {["10", "25", "50"].map(c => (
           <Flex key={c} ml={[10, 12]}>
             <NavButton
-              disabled={options.count === c}
-              onClick={() => formState.setField("count", c)}
+              disabled={options.limit === c}
+              onClick={() => {
+                formState.setField("limit", c);
+                formState.setField("skip", "0");
+              }}
             >
               {c}
             </NavButton>
@@ -159,16 +177,16 @@ const LinksTable: FC = () => {
       />
       <Flex>
         <NavButton
-          onClick={onNavChange(-1)}
-          disabled={options.page === "1"}
+          onClick={onNavChange(-parseInt(options.limit))}
+          disabled={options.skip === "0"}
           px={2}
         >
           <Icon name="chevronLeft" size={15} />
         </NavButton>
         <NavButton
-          onClick={onNavChange(1)}
+          onClick={onNavChange(parseInt(options.limit))}
           disabled={
-            parseInt(options.page) * parseInt(options.count) > links.total
+            parseInt(options.skip) + parseInt(options.limit) > links.total
           }
           ml={12}
           px={2}
@@ -184,11 +202,11 @@ const LinksTable: FC = () => {
       <H2 mb={3} light>
         Recent shortened links.
       </H2>
-      <Table scrollWidth="700px">
+      <Table scrollWidth="800px">
         <thead>
           <Tr justifyContent="space-between">
             <Th flexGrow={1} flexShrink={1}>
-              <form onSubmit={onSubmit}>
+              <Flex as="form" onSubmit={onSubmit}>
                 <TextInput
                   {...text("search")}
                   placeholder="Search..."
@@ -201,7 +219,19 @@ const LinksTable: FC = () => {
                   br="3px"
                   bbw="2px"
                 />
-              </form>
+
+                {isAdmin && (
+                  <Checkbox
+                    {...label("all")}
+                    {...checkbox("all")}
+                    label="All links"
+                    ml={3}
+                    fontSize={[14, 15]}
+                    width={[15, 16]}
+                    height={[15, 16]}
+                  />
+                )}
+              </Flex>
             </Th>
             {Nav}
           </Tr>
@@ -218,7 +248,7 @@ const LinksTable: FC = () => {
             <Tr width={1} justifyContent="center">
               <Td flex="1 1 auto" justifyContent="center">
                 <Text fontSize={18} light>
-                  {links.loading ? "Loading links..." : "No links to show."}
+                  {links.loading ? "Loading links..." : tableMessage}
                 </Text>
               </Td>
             </Tr>
@@ -235,6 +265,7 @@ const LinksTable: FC = () => {
                   <Td {...shortLinkFlex} withFade>
                     {copied.includes(index) ? (
                       <Animation
+                        minWidth={32}
                         offset="10px"
                         duration="0.2s"
                         alignItems="center"
@@ -251,11 +282,8 @@ const LinksTable: FC = () => {
                         />
                       </Animation>
                     ) : (
-                      <Animation offset="-10px" duration="0.2s">
-                        <CopyToClipboard
-                          text={l.shortLink}
-                          onCopy={onCopy(index)}
-                        >
+                      <Animation minWidth={32} offset="-10px" duration="0.2s">
+                        <CopyToClipboard text={l.link} onCopy={onCopy(index)}>
                           <Action
                             name="copy"
                             strokeWidth="2.5"
@@ -265,9 +293,7 @@ const LinksTable: FC = () => {
                         </CopyToClipboard>
                       </Animation>
                     )}
-                    <ALink href={l.shortLink}>
-                      {removeProtocol(l.shortLink)}
-                    </ALink>
+                    <ALink href={l.link}>{removeProtocol(l.link)}</ALink>
                   </Td>
                   <Td {...viewsFlex}>{withComma(l.visit_count)}</Td>
                   <Td {...actionsFlex} justifyContent="flex-end">
@@ -336,7 +362,7 @@ const LinksTable: FC = () => {
       >
         {links.items[qrModal] && (
           <RowCenter width={192}>
-            <QRCode size={192} value={links.items[qrModal].shortLink} />
+            <QRCode size={192} value={links.items[qrModal].link} />
           </RowCenter>
         )}
       </Modal>
@@ -352,13 +378,17 @@ const LinksTable: FC = () => {
             </H2>
             <Text textAlign="center">
               Are you sure do you want to delete the link{" "}
-              <Span bold>"{removeProtocol(linkToDelete.shortLink)}"</Span>?
+              <Span bold>"{removeProtocol(linkToDelete.link)}"</Span>?
             </Text>
             <Flex justifyContent="center" mt={44}>
               {deleteLoading ? (
                 <>
                   <Icon name="spinner" size={20} stroke={Colors.Spinner} />
                 </>
+              ) : deleteMessage.text ? (
+                <Text fontSize={15} color={deleteMessage.color}>
+                  {deleteMessage.text}
+                </Text>
               ) : (
                 <>
                   <Button

+ 11 - 6
client/components/Settings/SettingsApi.tsx

@@ -4,14 +4,15 @@ import React, { FC, useState } from "react";
 import styled from "styled-components";
 
 import { useStoreState, useStoreActions } from "../../store";
+import { useCopy, useMessage } from "../../hooks";
+import { errorMessage } from "../../utils";
+import { Colors } from "../../consts";
+import Animation from "../Animation";
 import { Button } from "../Button";
-import ALink from "../ALink";
-import Icon from "../Icon";
 import Text, { H2 } from "../Text";
 import { Col } from "../Layout";
-import { useCopy } from "../../hooks";
-import Animation from "../Animation";
-import { Colors } from "../../consts";
+import ALink from "../ALink";
+import Icon from "../Icon";
 
 const ApiKey = styled(Text).attrs({
   mt: [0, "2px"],
@@ -29,6 +30,7 @@ const ApiKey = styled(Text).attrs({
 
 const SettingsApi: FC = () => {
   const [copied, setCopied] = useCopy();
+  const [message, setMessage] = useMessage(1500);
   const [loading, setLoading] = useState(false);
   const apikey = useStoreState(s => s.settings.apikey);
   const generateApiKey = useStoreActions(s => s.settings.generateApiKey);
@@ -36,7 +38,7 @@ const SettingsApi: FC = () => {
   const onSubmit = async () => {
     if (loading) return;
     setLoading(true);
-    await generateApiKey();
+    await generateApiKey().catch(err => setMessage(errorMessage(err)));
     setLoading(false);
   };
 
@@ -100,6 +102,9 @@ const SettingsApi: FC = () => {
         <Icon name={loading ? "spinner" : "zap"} mr={2} stroke="white" />
         {loading ? "Generating..." : apikey ? "Regenerate" : "Generate"} key
       </Button>
+      <Text fontSize={15} mt={3} color={message.color}>
+        {message.text}
+      </Text>
     </Col>
   );
 };

+ 4 - 5
client/components/Settings/SettingsBan.tsx

@@ -1,17 +1,16 @@
-import React, { FC, useState } from "react";
-import { Flex } from "reflexbox/styled-components";
 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 TextInput from "../TextInput";
-import Checkbox from "../Checkbox";
 import { API } from "../../consts";
 import { Button } from "../Button";
-import Icon from "../Icon";
 import Text, { H2 } from "../Text";
 import { Col } from "../Layout";
+import Icon from "../Icon";
 
 interface BanForm {
   id: string;

+ 11 - 12
client/components/Settings/SettingsDomain.tsx

@@ -1,19 +1,20 @@
+import { useFormState } from "react-use-form-state";
 import { Flex } from "reflexbox/styled-components";
 import React, { FC, useState } from "react";
 import styled from "styled-components";
 
 import { useStoreState, useStoreActions } from "../../store";
-import { useFormState } from "react-use-form-state";
 import { Domain } from "../../store/settings";
 import { useMessage } from "../../hooks";
+import Text, { H2, Span } from "../Text";
 import { Colors } from "../../consts";
-import TextInput from "../TextInput";
+import { TextInput } from "../Input";
 import { Button } from "../Button";
+import { Col } from "../Layout";
 import Table from "../Table";
 import Modal from "../Modal";
 import Icon from "../Icon";
-import Text, { H2, Span } from "../Text";
-import { Col } from "../Layout";
+import { errorMessage } from "../../utils";
 
 const Th = styled(Flex).attrs({ as: "th", py: 3, px: 3 })`
   font-size: 15px;
@@ -55,12 +56,10 @@ const SettingsDomain: FC = () => {
 
   const onDelete = async () => {
     setDeleteLoading(true);
-    try {
-      await deleteDomain();
-      setMessage("Domain has been deleted successfully.", "green");
-    } catch (err) {
-      setMessage(err?.response?.data?.error || "Couldn't delete the domain.");
-    }
+    await deleteDomain().catch(err =>
+      setMessage(errorMessage(err, "Couldn't delete the domain."))
+    );
+    setMessage("Domain has been deleted successfully.", "green");
     closeModal();
     setDeleteLoading(false);
   };
@@ -122,7 +121,7 @@ const SettingsDomain: FC = () => {
           my={[3, 4]}
         >
           <Flex width={1} flexDirection={["column", "row"]}>
-            <Col mr={[0, 2]} mb={[3, 0]} flex="1 1 auto">
+            <Col mr={[0, 2]} mb={[3, 0]} flex="0 0 auto">
               <Text
                 {...label("customDomain")}
                 as="label"
@@ -139,7 +138,7 @@ const SettingsDomain: FC = () => {
                 required
               />
             </Col>
-            <Col ml={[0, 2]} flex="1 1 auto">
+            <Col ml={[0, 2]} flex="0 0 auto">
               <Text
                 {...label("homepage")}
                 as="label"

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

@@ -5,16 +5,16 @@ import axios from "axios";
 
 import { getAxiosConfig } from "../../utils";
 import { useMessage } from "../../hooks";
-import TextInput from "../TextInput";
+import { TextInput } from "../Input";
 import { API } from "../../consts";
 import { Button } from "../Button";
-import Icon from "../Icon";
 import Text, { H2 } from "../Text";
 import { Col } from "../Layout";
+import Icon from "../Icon";
 
 const SettingsPassword: FC = () => {
   const [loading, setLoading] = useState(false);
-  const [message, setMessage] = useMessage();
+  const [message, setMessage] = useMessage(2000);
   const [formState, { password, label }] = useFormState<{ password: string }>(
     null,
     { withIds: true }

+ 43 - 14
client/components/Shortener.tsx

@@ -1,19 +1,18 @@
 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 styled from "styled-components";
 
 import { useStoreActions, useStoreState } from "../store";
+import { Checkbox, Select, TextInput } from "./Input";
 import { Col, RowCenterH, RowCenter } from "./Layout";
-import { useFormState } from "react-use-form-state";
+import { useMessage, useCopy } from "../hooks";
 import { removeProtocol } from "../utils";
+import Text, { H1, Span } from "./Text";
 import { Link } from "../store/links";
-import { useMessage, useCopy } from "../hooks";
-import TextInput from "./TextInput";
 import Animation from "./Animation";
 import { Colors } from "../consts";
-import Checkbox from "./Checkbox";
-import Text, { H1, Span } from "./Text";
 import Icon from "./Icon";
 
 const SubmitIconWrapper = styled.div`
@@ -49,20 +48,25 @@ const ShortenedLink = styled(H1)`
 
 interface Form {
   target: string;
+  domain?: string;
   customurl?: string;
   password?: string;
   showAdvanced?: boolean;
 }
 
+const defaultDomain = process.env.DEFAULT_DOMAIN;
+
 const Shortener = () => {
   const { isAuthenticated } = useStoreState(s => s.auth);
-  const [domain] = useStoreState(s => s.settings.domains);
+  const domains = useStoreState(s => s.settings.domains);
   const submit = useStoreActions(s => s.links.submit);
   const [link, setLink] = useState<Link | null>(null);
   const [message, setMessage] = useMessage(3000);
   const [loading, setLoading] = useState(false);
   const [copied, setCopied] = useCopy();
-  const [formState, { raw, password, text, label }] = useFormState<Form>(
+  const [formState, { raw, password, text, select, label }] = useFormState<
+    Form
+  >(
     { showAdvanced: false },
     {
       withIds: true,
@@ -147,7 +151,7 @@ const Shortener = () => {
         </Animation>
       ) : (
         <Animation offset="-10px" duration="0.2s">
-          <CopyToClipboard text={link.shortLink} onCopy={setCopied}>
+          <CopyToClipboard text={link.link} onCopy={setCopied}>
             <Icon
               as="button"
               py={0}
@@ -163,9 +167,9 @@ const Shortener = () => {
           </CopyToClipboard>
         </Animation>
       )}
-      <CopyToClipboard text={link.shortLink} onCopy={setCopied}>
+      <CopyToClipboard text={link.link} onCopy={setCopied}>
         <ShortenedLink fontSize={[24, 26, 30]} pb="2px" light>
-          {removeProtocol(link.shortLink)}
+          {removeProtocol(link.link)}
         </ShortenedLink>
       </CopyToClipboard>
     </Animation>
@@ -236,6 +240,33 @@ const Shortener = () => {
       {formState.values.showAdvanced && (
         <Flex mt={4} flexDirection={["column", "row"]}>
           <Col mb={[3, 0]}>
+            <Text
+              as="label"
+              {...label("domain")}
+              fontSize={[14, 15]}
+              mb={2}
+              bold
+            >
+              Domain
+            </Text>
+            <Select
+              {...select("domain")}
+              data-lpignore
+              pl={[3, 24]}
+              pr={[3, 24]}
+              fontSize={[14, 15]}
+              height={[40, 44]}
+              width={[170, 200]}
+              options={[
+                { key: defaultDomain, value: "" },
+                ...domains.map(d => ({
+                  key: d.customDomain,
+                  value: d.customDomain
+                }))
+              ]}
+            />
+          </Col>
+          <Col mb={[3, 0]} ml={[0, 24]}>
             <Text
               as="label"
               {...label("customurl")}
@@ -243,9 +274,7 @@ const Shortener = () => {
               mb={2}
               bold
             >
-              {(domain || {}).customDomain ||
-                (typeof window !== "undefined" && window.location.hostname)}
-              /
+              {formState.values.domain || defaultDomain}/
             </Text>
             <TextInput
               {...text("customurl")}
@@ -259,7 +288,7 @@ const Shortener = () => {
               width={[210, 240]}
             />
           </Col>
-          <Col ml={[0, 4]}>
+          <Col ml={[0, 24]}>
             <Text
               as="label"
               {...label("password")}

+ 1 - 1
client/components/Table.ts

@@ -9,7 +9,7 @@ const Table = styled(Flex)<{ scrollWidth?: string }>`
   border-radius: 12px;
   box-shadow: 0 6px 15px ${Colors.TableShadow};
   text-align: center;
-  overflow: scroll;
+  overflow: auto;
 
   tr,
   th,

+ 0 - 83
client/components/TextInput.tsx

@@ -1,83 +0,0 @@
-import styled from "styled-components";
-import { withProp, prop } from "styled-tools";
-import { Flex, BoxProps } from "reflexbox/styled-components";
-
-import { fadeIn } from "../helpers/animations";
-
-interface Props extends BoxProps {
-  autoFocus?: boolean;
-  name?: string;
-  id?: string;
-  type?: string;
-  value?: string;
-  required?: boolean;
-  onChange?: any;
-  placeholderSize?: number[];
-  br?: string;
-  bbw?: string;
-}
-
-const TextInput = styled(Flex).attrs({
-  as: "input"
-})<Props>`
-  position: relative;
-  box-sizing: border-box;
-  letter-spacing: 0.05em;
-  color: #444;
-  background-color: white;
-  box-shadow: 0 10px 35px hsla(200, 15%, 70%, 0.2);
-  border: none;
-  border-radius: ${prop("br", "100px")};
-  border-bottom: 5px solid #f5f5f5;
-  border-bottom-width: ${prop("bbw", "5px")};
-  animation: ${fadeIn} 0.5s ease-out;
-  transition: all 0.5s ease-out;
-
-  :focus {
-    outline: none;
-    box-shadow: 0 20px 35px hsla(200, 15%, 70%, 0.4);
-  }
-
-  ::placeholder {
-    font-size: ${withProp("placeholderSize", s => s[0] || 14)}px;
-    letter-spacing: 0.05em;
-    color: #888;
-  }
-
-  @media screen and (min-width: 64em) {
-    ::placeholder {
-      font-size: ${withProp(
-        "placeholderSize",
-        s => s[3] || s[2] || s[1] || s[0] || 16
-      )}px;
-    }
-  }
-
-  @media screen and (min-width: 52em) {
-    letter-spacing: 0.1em;
-    border-bottom-width: ${prop("bbw", "6px")};
-    ::placeholder {
-      font-size: ${withProp(
-        "placeholderSize",
-        s => s[2] || s[1] || s[0] || 15
-      )}px;
-    }
-  }
-
-  @media screen and (min-width: 40em) {
-    ::placeholder {
-      font-size: ${withProp("placeholderSize", s => s[1] || s[0] || 15)}px;
-    }
-  }
-`;
-
-TextInput.defaultProps = {
-  value: "",
-  height: [40, 44],
-  py: 0,
-  px: [3, 24],
-  fontSize: [14, 15],
-  placeholderSize: [13, 14]
-};
-
-export default TextInput;

+ 4 - 0
client/consts/consts.ts

@@ -15,6 +15,10 @@ export enum API {
   STATS = "/api/url/stats"
 }
 
+export enum APIv2 {
+  Links = "/api/v2/links"
+}
+
 export enum Colors {
   Text = "hsl(200, 35%, 25%)",
   Bg = "hsl(206, 12%, 95%)",

+ 5 - 5
client/pages/login.tsx

@@ -1,16 +1,16 @@
+import { useFormState } from "react-use-form-state";
 import React, { useEffect, useState } from "react";
+import { Flex } from "reflexbox/styled-components";
+import emailValidator from "email-validator";
+import styled from "styled-components";
 import Router from "next/router";
 import Link from "next/link";
 import axios from "axios";
-import styled from "styled-components";
-import emailValidator from "email-validator";
-import { useFormState } from "react-use-form-state";
-import { Flex } from "reflexbox/styled-components";
 
 import { useStoreState, useStoreActions } from "../store";
 import { ColCenterV } from "../components/Layout";
 import AppWrapper from "../components/AppWrapper";
-import TextInput from "../components/TextInput";
+import { TextInput } from "../components/Input";
 import { fadeIn } from "../helpers/animations";
 import { Button } from "../components/Button";
 import Text, { H2 } from "../components/Text";

+ 3 - 3
client/pages/report.tsx

@@ -1,11 +1,11 @@
-import React, { useState } from "react";
-import axios from "axios";
 import { useFormState } from "react-use-form-state";
 import { Flex } from "reflexbox/styled-components";
+import React, { useState } from "react";
+import axios from "axios";
 
 import Text, { H2, Span } from "../components/Text";
 import AppWrapper from "../components/AppWrapper";
-import TextInput from "../components/TextInput";
+import { TextInput } from "../components/Input";
 import { Button } from "../components/Button";
 import { Col } from "../components/Layout";
 import Icon from "../components/Icon";

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

@@ -9,7 +9,7 @@ import axios from "axios";
 
 import { useStoreState, useStoreActions } from "../store";
 import AppWrapper from "../components/AppWrapper";
-import TextInput from "../components/TextInput";
+import { TextInput } from "../components/Input";
 import { Button } from "../components/Button";
 import Text, { H2 } from "../components/Text";
 import { Col } from "../components/Layout";

+ 0 - 5
client/pages/settings.tsx

@@ -15,11 +15,6 @@ import { Col } from "../components/Layout";
 
 const SettingsPage: NextPage = props => {
   const { email, isAdmin } = useStoreState(s => s.auth);
-  const getSettings = useStoreActions(s => s.settings.getSettings);
-
-  useEffect(() => {
-    getSettings();
-  }, [false]);
 
   return (
     <AppWrapper>

+ 2 - 2
client/pages/stats.tsx

@@ -83,8 +83,8 @@ const StatsPage: NextPage<Props> = ({ domain, id }) => {
             <Flex justifyContent="space-between" alignItems="center" mb={3}>
               <H1 fontSize={[18, 20, 24]} light>
                 Stats for:{" "}
-                <ALink href={data.shortLink} title="Short link">
-                  {removeProtocol(data.shortLink)}
+                <ALink href={data.link} title="Short link">
+                  {removeProtocol(data.link)}
                 </ALink>
               </H1>
               <Text fontSize={[13, 14]} textAlign="right">

+ 1 - 1
client/pages/url-password.tsx

@@ -5,7 +5,7 @@ import { NextPage } from "next";
 import axios from "axios";
 
 import AppWrapper from "../components/AppWrapper";
-import TextInput from "../components/TextInput";
+import { TextInput } from "../components/Input";
 import { Button } from "../components/Button";
 import Text, { H2 } from "../components/Text";
 import { Col } from "../components/Layout";

+ 18 - 12
client/store/links.ts

@@ -3,8 +3,7 @@ import axios from "axios";
 import query from "query-string";
 
 import { getAxiosConfig } from "../utils";
-import { API } from "../consts";
-import { string } from "prop-types";
+import { API, APIv2 } from "../consts";
 
 export interface Link {
   id: number;
@@ -12,7 +11,7 @@ export interface Link {
   banned: boolean;
   banned_by_id?: number;
   created_at: string;
-  shortLink: string;
+  link: string;
   domain?: string;
   domain_id?: number;
   password?: string;
@@ -26,19 +25,23 @@ export interface NewLink {
   target: string;
   customurl?: string;
   password?: string;
+  domain?: string;
   reuse?: boolean;
   reCaptchaToken?: string;
 }
 
 export interface LinksQuery {
-  count?: string;
-  page?: string;
-  search?: string;
+  limit: string;
+  skip: string;
+  search: string;
+  all: boolean;
 }
 
 export interface LinksListRes {
-  list: Link[];
-  countAll: number;
+  data: Link[];
+  total: number;
+  limit: number;
+  skip: number;
 }
 
 export interface Links {
@@ -60,14 +63,17 @@ export const links: Links = {
   total: 0,
   loading: true,
   submit: thunk(async (actions, payload) => {
-    const res = await axios.post(API.SUBMIT, payload, getAxiosConfig());
+    const data = Object.fromEntries(
+      Object.entries(payload).filter(([, value]) => value !== "")
+    );
+    const res = await axios.post(APIv2.Links, data, getAxiosConfig());
     actions.add(res.data);
     return res.data;
   }),
   get: thunk(async (actions, payload) => {
     actions.setLoading(true);
     const res = await axios.get(
-      `${API.GET_LINKS}?${query.stringify(payload)}`,
+      `${APIv2.Links}?${query.stringify(payload)}`,
       getAxiosConfig()
     );
     actions.set(res.data);
@@ -82,8 +88,8 @@ export const links: Links = {
     state.items.unshift(payload);
   }),
   set: action((state, payload) => {
-    state.items = payload.list;
-    state.total = payload.countAll;
+    state.items = payload.data;
+    state.total = payload.total;
   }),
   setLoading: action((state, payload) => {
     state.loading = payload;

+ 17 - 14
client/store/settings.ts

@@ -17,6 +17,7 @@ export interface SettingsResp extends Domain {
 export interface Settings {
   domains: Array<Domain>;
   apikey: string;
+  fetched: boolean;
   setSettings: Action<Settings, SettingsResp>;
   getSettings: Thunk<Settings, null, null, StoreModel>;
   setApiKey: Action<Settings, string>;
@@ -30,8 +31,24 @@ export interface Settings {
 export const settings: Settings = {
   domains: [],
   apikey: null,
+  fetched: false,
+  getSettings: thunk(async (actions, payload, { getStoreActions }) => {
+    getStoreActions().loading.show();
+    const res = await axios.get(API.SETTINGS, getAxiosConfig());
+    actions.setSettings(res.data);
+    getStoreActions().loading.hide();
+  }),
+  generateApiKey: thunk(async actions => {
+    const res = await axios.post(API.GENERATE_APIKEY, null, getAxiosConfig());
+    actions.setApiKey(res.data.apikey);
+  }),
+  deleteDomain: thunk(async actions => {
+    await axios.delete(API.CUSTOM_DOMAIN, getAxiosConfig());
+    actions.removeDomain();
+  }),
   setSettings: action((state, payload) => {
     state.apikey = payload.apikey;
+    state.fetched = true;
     if (payload.customDomain) {
       state.domains = [
         {
@@ -41,19 +58,9 @@ export const settings: Settings = {
       ];
     }
   }),
-  getSettings: thunk(async (actions, payload, { getStoreActions }) => {
-    getStoreActions().loading.show();
-    const res = await axios.get(API.SETTINGS, getAxiosConfig());
-    actions.setSettings(res.data);
-    getStoreActions().loading.hide();
-  }),
   setApiKey: action((state, payload) => {
     state.apikey = payload;
   }),
-  generateApiKey: thunk(async actions => {
-    const res = await axios.post(API.GENERATE_APIKEY, null, getAxiosConfig());
-    actions.setApiKey(res.data.apikey);
-  }),
   addDomain: action((state, payload) => {
     state.domains.push(payload);
   }),
@@ -66,9 +73,5 @@ export const settings: Settings = {
       customDomain: res.data.customDomain,
       homepage: res.data.homepage
     });
-  }),
-  deleteDomain: thunk(async actions => {
-    await axios.delete(API.CUSTOM_DOMAIN, getAxiosConfig());
-    actions.removeDomain();
   })
 };

+ 1 - 1
client/types.ts

@@ -1,5 +1,5 @@
 export interface TokenPayload {
-  iss: 'ApiAuth';
+  iss: "ApiAuth";
   sub: string;
   domain: string;
   admin: boolean;

+ 6 - 1
client/utils.ts

@@ -1,5 +1,5 @@
 import cookie from "js-cookie";
-import { AxiosRequestConfig } from "axios";
+import { AxiosRequestConfig, AxiosError } from "axios";
 
 export const removeProtocol = (link: string) =>
   link.replace(/^https?:\/\//, "");
@@ -16,3 +16,8 @@ export const getAxiosConfig = (
     Authorization: cookie.get("token")
   }
 });
+
+export const errorMessage = (err: AxiosError, defaultMessage?: string) => {
+  const data = err?.response?.data;
+  return data?.message || data?.error || defaultMessage || "";
+};

+ 1 - 0
global.d.ts

@@ -60,6 +60,7 @@ interface Link {
   target: string;
   updated_at: string;
   user_id?: number;
+  uuid: string;
   visit_count: number;
 }
 

+ 1 - 1
server/controllers/authController.ts

@@ -69,7 +69,7 @@ const authenticate = (
       }
       if (user && user.banned) {
         return res
-          .status(400)
+          .status(403)
           .json({ error: "Your are banned from using this website." });
       }
       if (user) {

+ 6 - 9
server/controllers/validateBodyController.ts

@@ -4,9 +4,9 @@ import dns from "dns";
 import axios from "axios";
 import URL from "url";
 import urlRegex from "url-regex";
-import validator from "express-validator/check";
+import { body } from "express-validator";
 import { differenceInMinutes, subHours, subDays, isAfter } from "date-fns";
-import { validationResult } from "express-validator/check";
+import { validationResult } from "express-validator";
 
 import { addCooldown, banUser } from "../db/user";
 import { getIP } from "../db/ip";
@@ -18,16 +18,13 @@ import { addProtocol } from "../utils";
 const dnsLookup = promisify(dns.lookup);
 
 export const validationCriterias = [
-  validator
-    .body("email")
+  body("email")
     .exists()
     .withMessage("Email must be provided.")
     .isEmail()
     .withMessage("Email is not valid.")
-    .trim()
-    .normalizeEmail(),
-  validator
-    .body("password", "Password must be at least 8 chars long.")
+    .trim(),
+  body("password", "Password must be at least 8 chars long.")
     .exists()
     .withMessage("Password must be provided.")
     .isLength({ min: 8 })
@@ -125,7 +122,7 @@ export const cooldownCheck = async (user: User) => {
   if (user && user.cooldowns) {
     if (user.cooldowns.length > 4) {
       await banUser(user.id);
-      throw new Error("Too much malware requests. You are now banned.");
+      throw new Error("Too much malware requests. You are banned.");
     }
     const hasCooldownNow = user.cooldowns.some(cooldown =>
       isAfter(subHours(new Date(), 12), new Date(cooldown))

+ 1 - 0
server/db/link.ts

@@ -189,6 +189,7 @@ export const getLinks = async (
       "links.target",
       "links.visit_count",
       "links.user_id",
+      "links.uuid",
       "domains.address as domain"
     )
     .offset(offset)

+ 104 - 0
server/handlers/auth.ts

@@ -0,0 +1,104 @@
+import { differenceInMinutes, subMinutes } from "date-fns";
+import { Handler } from "express";
+import passport from "passport";
+import axios from "axios";
+
+import { isAdmin, CustomError } from "../utils";
+import knex from "../knex";
+
+const authenticate = (
+  type: "jwt" | "local" | "localapikey",
+  error: string,
+  isStrict = true
+) =>
+  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");
+      }
+
+      if (!user && isStrict) {
+        throw new CustomError(error, 401);
+      }
+
+      if (user && isStrict && !user.verified) {
+        throw new CustomError(
+          "Your email address is not verified. " +
+            "Click on signup to get the verification link again.",
+          400
+        );
+      }
+
+      if (user && user.banned) {
+        throw new CustomError("Your are banned from using this website.", 403);
+      }
+
+      if (user) {
+        req.user = {
+          ...user,
+          admin: isAdmin(user.email)
+        };
+        return next();
+      }
+      return next();
+    })(req, res, next);
+  };
+
+export const local = authenticate("local", "Login credentials are wrong.");
+export const jwt = authenticate("jwt", "Unauthorized.");
+export const jwtLoose = authenticate("jwt", "Unauthorized.", false);
+export const apikey = authenticate(
+  "localapikey",
+  "API key is not correct.",
+  false
+);
+
+export const cooldown: Handler = async (req, res, next) => {
+  const cooldownConfig = Number(process.env.NON_USER_COOLDOWN);
+  if (req.user || !cooldownConfig) return next();
+
+  const ip = await knex<IP>("ips")
+    .where({ ip: req.realIP.toLowerCase() })
+    .andWhere(
+      "created_at",
+      ">",
+      subMinutes(new Date(), cooldownConfig).toISOString()
+    )
+    .first();
+
+  if (ip) {
+    const timeToWait =
+      cooldownConfig - differenceInMinutes(new Date(), new Date(ip.created_at));
+    throw new CustomError(
+      `Non-logged in users are limited. Wait ${timeToWait} minutes or log in.`,
+      400
+    );
+  }
+  next();
+};
+
+export const recaptcha: Handler = async (req, res, next) => {
+  if (process.env.NODE_ENV !== "production") return next();
+  if (!req.user) return next();
+
+  const isReCaptchaValid = await axios({
+    method: "post",
+    url: "https://www.google.com/recaptcha/api/siteverify",
+    headers: {
+      "Content-type": "application/x-www-form-urlencoded"
+    },
+    params: {
+      secret: process.env.RECAPTCHA_SECRET_KEY,
+      response: req.body.reCaptchaToken,
+      remoteip: req.realIP
+    }
+  });
+
+  if (!isReCaptchaValid.data.success) {
+    throw new CustomError("reCAPTCHA is not valid. Try again.", 401);
+  }
+
+  return next();
+};

+ 17 - 0
server/handlers/helpers.ts

@@ -0,0 +1,17 @@
+import { Handler } from "express";
+
+export const query: Handler = (req, res, next) => {
+  const { limit, skip, all } = req.query;
+  const { admin } = req.user || {};
+
+  req.query.limit = parseInt(limit) || 10;
+  req.query.skip = parseInt(skip) || 0;
+
+  if (req.query.limit > 50) {
+    req.query.limit = 50;
+  }
+
+  req.query.all = admin ? all === "true" : false;
+
+  next();
+};

+ 118 - 0
server/handlers/links.ts

@@ -0,0 +1,118 @@
+import { Handler, Request } from "express";
+import URL from "url";
+
+import { generateShortLink, generateId } from "../utils";
+import {
+  getLinksQuery,
+  getTotalQuery,
+  findLinkQuery,
+  createLinkQuery
+} from "../queries/link";
+import {
+  cooldownCheck,
+  malwareCheck,
+  urlCountsCheck,
+  checkBannedDomain,
+  checkBannedHost
+} from "../controllers/validateBodyController";
+
+export const getLinks: Handler = async (req, res) => {
+  const { limit, skip, search, all } = req.query;
+  const userId = req.user.id;
+
+  const [links, total] = await Promise.all([
+    getLinksQuery({ all, limit, search, skip, userId }),
+    getTotalQuery({ all, search, userId })
+  ]);
+
+  const data = links.map(link => ({
+    ...link,
+    id: link.uuid,
+    password: !!link.password,
+    link: generateShortLink(link.address, link.domain)
+  }));
+
+  return res.send({
+    total,
+    limit,
+    skip,
+    data
+  });
+};
+
+interface CreateLinkReq extends Request {
+  body: {
+    reuse?: boolean;
+    password?: string;
+    customurl?: string;
+    domain?: Domain;
+    target: string;
+  };
+}
+
+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 Error("Custom URL is already in use.");
+    }
+
+    // Create new link
+    const address = customurl || queries[5];
+    const link = await createLinkQuery({
+      password,
+      address,
+      domainAddress,
+      domainId,
+      target
+    });
+
+    if (!req.user && Number(process.env.NON_USER_COOLDOWN)) {
+      // addIP(req.realIP);
+    }
+
+    return res.json({ ...link, id: link.uuid });
+  } catch (error) {
+    return res.status(400).json({ error: error.message });
+  }
+};

+ 21 - 0
server/handlers/sanitizers.ts

@@ -0,0 +1,21 @@
+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)
+];

+ 84 - 0
server/handlers/validators.ts

@@ -0,0 +1,84 @@
+import { body, validationResult } from "express-validator";
+import urlRegex from "url-regex";
+import URL from "url";
+
+import { findDomain } from "../queries/domain";
+import { CustomError } from "../utils";
+
+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 preservedUrls = [
+  "login",
+  "logout",
+  "signup",
+  "reset-password",
+  "resetpassword",
+  "url-password",
+  "url-info",
+  "settings",
+  "stats",
+  "verify",
+  "api",
+  "404",
+  "static",
+  "images",
+  "banned",
+  "terms",
+  "privacy",
+  "report",
+  "pricing"
+];
+
+export const createLink = [
+  body("target")
+    .exists({ checkNull: true, checkFalsy: true })
+    .withMessage("Target is missing.")
+    .isLength({ min: 1, max: 2040 })
+    .withMessage("Maximum URL length is 2040.")
+    .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.`),
+  body("password")
+    .optional()
+    .isLength({ min: 3, max: 64 })
+    .withMessage("Password length must be between 3 and 64."),
+  body("customurl")
+    .optional()
+    .isLength({ min: 1, max: 64 })
+    .withMessage("Custom URL length must be between 1 and 64.")
+    .custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
+    .withMessage("Custom URL is not valid")
+    .custom(value => preservedUrls.some(url => url.toLowerCase() === value))
+    .withMessage("You can't use this custom URL."),
+  body("reuse")
+    .optional()
+    .isBoolean()
+    .withMessage("Reuse must be boolean."),
+  body("domain")
+    .optional()
+    .isString()
+    .withMessage("Domain should be string.")
+    .custom(async (address, { req }) => {
+      const domain = await findDomain({
+        address,
+        userId: req.user && req.user.id
+      });
+      req.body.domain = domain || null;
+
+      if (domain) return true;
+
+      throw new CustomError("You can't use this domain.", 400);
+    })
+];

+ 13 - 0
server/models/link.ts

@@ -4,7 +4,9 @@ export async function createLinkTable(knex: Knex) {
   const hasTable = await knex.schema.hasTable("links");
 
   if (!hasTable) {
+    await knex.schema.raw('create extension if not exists "uuid-ossp"');
     await knex.schema.createTable("links", table => {
+      knex.raw('create extension if not exists "uuid-ossp"');
       table.increments("id").primary();
       table.string("address").notNullable();
       table
@@ -32,4 +34,15 @@ export async function createLinkTable(knex: Knex) {
       table.timestamps(false, true);
     });
   }
+
+  const hasUUID = await knex.schema.hasColumn("links", "uuid");
+  if (!hasUUID) {
+    await knex.schema.raw('create extension if not exists "uuid-ossp"');
+    await knex.schema.alterTable("links", table => {
+      table
+        .uuid("uuid")
+        .notNullable()
+        .defaultTo(knex.raw("uuid_generate_v4()"));
+    });
+  }
 }

+ 34 - 0
server/queries/domain.ts

@@ -0,0 +1,34 @@
+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 findDomain = async ({
+  userId,
+  ...data
+}: FindDomain): Promise<Domain> => {
+  const redisKey = getRedisKey.domain(data.address);
+  const cachedDomain = await redis.get(redisKey);
+
+  if (cachedDomain) return JSON.parse(cachedDomain);
+
+  const query = knex<Domain>("domains").where(data);
+
+  if (userId) {
+    query.andWhere("user_id", userId);
+  }
+
+  const domain = await query.first();
+
+  if (domain) {
+    redis.set(redisKey, JSON.stringify(domain), "EX", 60 * 60 * 6);
+  }
+
+  return domain;
+};

+ 157 - 0
server/queries/link.ts

@@ -0,0 +1,157 @@
+import bcrypt from "bcryptjs";
+
+import { getRedisKey, generateShortLink } from "../utils";
+import * as redis from "../redis";
+import knex from "../knex";
+
+interface GetTotal {
+  all: boolean;
+  userId: number;
+  search?: string;
+}
+
+export const getTotalQuery = async ({ all, search, userId }: GetTotal) => {
+  const query = knex<Link>("links").count("id");
+
+  if (!all) {
+    query.where("user_id", userId);
+  }
+
+  if (search) {
+    query.andWhereRaw("links.address || ' ' || target ILIKE '%' || ? || '%'", [
+      search
+    ]);
+  }
+
+  const [{ count }] = await query;
+
+  return typeof count === "number" ? count : parseInt(count);
+};
+
+interface GetLinks {
+  all: boolean;
+  limit: number;
+  search?: string;
+  skip: number;
+  userId: number;
+}
+
+export const getLinksQuery = async ({
+  all,
+  limit,
+  search,
+  skip,
+  userId
+}: GetLinks) => {
+  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)
+    .orderBy("created_at", "desc");
+
+  if (!all) {
+    query.where("links.user_id", userId);
+  }
+
+  if (search) {
+    query.andWhereRaw("links.address || ' ' || target ILIKE '%' || ? || '%'", [
+      search
+    ]);
+  }
+
+  query.leftJoin("domains", "links.domain_id", "domains.id");
+
+  const links: LinkJoinedDomain[] = await query;
+
+  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);
+
+  const link = await knex<Link>("links")
+    .where({
+      ...(address && { address }),
+      ...(domainId && { domain_id: domainId }),
+      ...(userId && { user_id: userId }),
+      ...(target && { target })
+    })
+    .first();
+
+  if (link) {
+    redis.set(redisKey, JSON.stringify(link), "EX", 60 * 60 * 2);
+  }
+
+  return link;
+};
+
+interface CreateLink {
+  userId?: number;
+  domainAddress?: string;
+  domainId?: number;
+  password?: string;
+  address: string;
+  target: string;
+}
+
+export const createLinkQuery = async ({
+  password,
+  address,
+  target,
+  domainAddress,
+  domainId = null,
+  userId = null
+}: CreateLink) => {
+  let encryptedPassword;
+
+  if (password) {
+    const salt = await bcrypt.genSalt(12);
+    encryptedPassword = await bcrypt.hash(password, salt);
+  }
+
+  const [link]: Link[] = await knex<Link>("links").insert(
+    {
+      password: encryptedPassword,
+      domain_id: domainId,
+      user_id: userId,
+      address,
+      target
+    },
+    "*"
+  );
+
+  return {
+    ...link,
+    id: link.uuid,
+    password: !!password,
+    link: generateShortLink(address, domainAddress)
+  };
+};

+ 0 - 2
server/queues/queues.ts

@@ -12,9 +12,7 @@ const removeJob = job => job.remove();
 export const visitQueue = new Queue("visit", { redis });
 
 visitQueue.clean(5000, "completed");
-visitQueue.clean(5000, "failed");
 
 visitQueue.process(4, path.resolve(__dirname, "visitQueue.js"));
 
 visitQueue.on("completed", removeJob);
-visitQueue.on("failed", removeJob);

+ 7 - 0
server/routes/health.ts

@@ -0,0 +1,7 @@
+import { Router } from "express";
+
+const router = Router();
+
+router.get("/", (_, res) => res.send("OK"));
+
+export default router;

+ 11 - 0
server/routes/index.ts

@@ -0,0 +1,11 @@
+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;

+ 33 - 0
server/routes/links.ts

@@ -0,0 +1,33 @@
+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";
+
+const router = Router();
+
+router.get(
+  "/",
+  asyncHandler(auth.apikey),
+  asyncHandler(auth.jwt),
+  helpers.query,
+  getLinks
+);
+
+router.post(
+  "/",
+  cors(),
+  asyncHandler(auth.apikey),
+  asyncHandler(auth.jwtLoose),
+  asyncHandler(auth.recaptcha),
+  sanitizers.createLink,
+  validators.createLink,
+  asyncHandler(validators.verify),
+  createLink
+);
+
+export default router;

+ 18 - 12
server/server.ts

@@ -21,9 +21,11 @@ import {
 import * as auth from "./controllers/authController";
 import * as link from "./controllers/linkController";
 import { initializeDb } from "./knex";
+import routes from "./routes";
 
 import "./cron";
 import "./passport";
+import { CustomError } from "./utils";
 
 if (process.env.RAVEN_DSN) {
   Raven.config(process.env.RAVEN_DSN).install();
@@ -55,18 +57,6 @@ app.prepare().then(async () => {
   server.use(passport.initialize());
   server.use(express.static("static"));
 
-  server.use((error, req, res, next) => {
-    res
-      .status(500)
-      .json({ error: "Sorry an error ocurred. Please try again later." });
-    if (process.env.RAVEN_DSN) {
-      Raven.captureException(error, {
-        user: { email: req.user && req.user.email }
-      });
-    }
-    next();
-  });
-
   server.use((req, _res, next) => {
     req.realIP =
       (req.headers["x-real-ip"] as string) ||
@@ -77,6 +67,8 @@ app.prepare().then(async () => {
 
   server.use(link.customDomainRedirection);
 
+  server.use(routes);
+
   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"));
@@ -205,6 +197,20 @@ app.prepare().then(async () => {
 
   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}`);

+ 23 - 0
server/utils/index.ts

@@ -4,6 +4,29 @@ import {
   differenceInHours,
   differenceInMonths
 } from "date-fns";
+import generate from "nanoid/generate";
+import { findLinkQuery } from "../queries/link";
+
+export class CustomError extends Error {
+  public statusCode?: number;
+  public data?: any;
+  public constructor(message: string, statusCode = 500, data?: any) {
+    super(message);
+    this.name = this.constructor.name;
+    this.statusCode = statusCode;
+    this.data = data;
+  }
+}
+
+export const generateId = async (domainId: number = null) => {
+  const address = generate(
+    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890",
+    Number(process.env.LINK_LENGTH) || 6
+  );
+  const link = await findLinkQuery({ address, domainId });
+  if (!link) return address;
+  return generateId(domainId);
+};
 
 export const addProtocol = (url: string): string => {
   const hasProtocol = /^\w+:\/\//.test(url);