Browse Source

feat: api v2

tags/v2.3.0
poeti8 9 months ago
parent
commit
25903bf3cd
72 changed files with 6033 additions and 3497 deletions
  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. +3447
    -2151
      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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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);


client/pages/url-password.tsx → client/pages/protected/[id].tsx View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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;
}


+ 3447
- 2151
package-lock.json
File diff suppressed because it is too large
View File


+ 57
- 55
package.json View File

@@ -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"
}
}

server/controllers/linkController.ts → server/__v1/controllers/linkController.ts View File

@@ -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

server/controllers/validateBodyController.ts → server/__v1/controllers/validateBodyController.ts View File

@@ -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.`
);
}
};

server/db/domain.ts → server/__v1/db/domain.ts View File

@@ -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 = {

server/db/host.ts → server/__v1/db/host.ts View File

@@ -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 = {

server/db/ip.ts → server/__v1/db/ip.ts View File

@@ -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();

server/db/link.ts → server/__v1/db/link.ts View File

@@ -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";

server/db/user.ts → server/__v1/db/user.ts View File

@@ -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 View File

@@ -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),