feat: api v2
This commit is contained in:
parent
524863c340
commit
25903bf3cd
|
@ -15,7 +15,7 @@
|
||||||
"no-var": "warn",
|
"no-var": "warn",
|
||||||
"no-console": "warn",
|
"no-console": "warn",
|
||||||
"max-len": ["warn", { "comments": 80 }],
|
"max-len": ["warn", { "comments": 80 }],
|
||||||
"no-param-reassign": ["warn", { "props": false }],
|
"no-param-reassign": 0,
|
||||||
"require-atomic-updates": 0,
|
"require-atomic-updates": 0,
|
||||||
"@typescript-eslint/interface-name-prefix": "off",
|
"@typescript-eslint/interface-name-prefix": "off",
|
||||||
"@typescript-eslint/no-unused-vars": "off", // "warn" for production
|
"@typescript-eslint/no-unused-vars": "off", // "warn" for production
|
||||||
|
|
|
@ -18,6 +18,7 @@ import Trash from "./Trash";
|
||||||
import Check from "./Check";
|
import Check from "./Check";
|
||||||
import Login from "./Login";
|
import Login from "./Login";
|
||||||
import Heart from "./Heart";
|
import Heart from "./Heart";
|
||||||
|
import Stop from "./Stop";
|
||||||
import Plus from "./Plus";
|
import Plus from "./Plus";
|
||||||
import Lock from "./Lock";
|
import Lock from "./Lock";
|
||||||
import Edit from "./Edit";
|
import Edit from "./Edit";
|
||||||
|
@ -33,10 +34,9 @@ const icons = {
|
||||||
chevronLeft: ChevronLeft,
|
chevronLeft: ChevronLeft,
|
||||||
chevronRight: ChevronRight,
|
chevronRight: ChevronRight,
|
||||||
clipboard: Clipboard,
|
clipboard: Clipboard,
|
||||||
shuffle: Shuffle,
|
|
||||||
copy: Copy,
|
copy: Copy,
|
||||||
heart: Heart,
|
|
||||||
edit: Edit,
|
edit: Edit,
|
||||||
|
heart: Heart,
|
||||||
key: Key,
|
key: Key,
|
||||||
lock: Lock,
|
lock: Lock,
|
||||||
login: Login,
|
login: Login,
|
||||||
|
@ -45,8 +45,10 @@ const icons = {
|
||||||
qrcode: QRCode,
|
qrcode: QRCode,
|
||||||
refresh: Refresh,
|
refresh: Refresh,
|
||||||
send: Send,
|
send: Send,
|
||||||
|
shuffle: Shuffle,
|
||||||
signup: Signup,
|
signup: Signup,
|
||||||
spinner: Spinner,
|
spinner: Spinner,
|
||||||
|
stop: Stop,
|
||||||
trash: Trash,
|
trash: Trash,
|
||||||
x: X,
|
x: X,
|
||||||
zap: Zap
|
zap: Zap
|
||||||
|
|
|
@ -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);
|
|
@ -4,16 +4,18 @@ import React, { FC, useState, useEffect } from "react";
|
||||||
import { useFormState } from "react-use-form-state";
|
import { useFormState } from "react-use-form-state";
|
||||||
import { Flex } from "reflexbox/styled-components";
|
import { Flex } from "reflexbox/styled-components";
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
|
import { ifProp } from "styled-tools";
|
||||||
import QRCode from "qrcode.react";
|
import QRCode from "qrcode.react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { useStoreActions, useStoreState } from "../store";
|
|
||||||
import { removeProtocol, withComma, errorMessage } from "../utils";
|
import { removeProtocol, withComma, errorMessage } from "../utils";
|
||||||
|
import { useStoreActions, useStoreState } from "../store";
|
||||||
|
import { Link as LinkType } from "../store/links";
|
||||||
import { Checkbox, TextInput } from "./Input";
|
import { Checkbox, TextInput } from "./Input";
|
||||||
import { NavButton, Button } from "./Button";
|
import { NavButton, Button } from "./Button";
|
||||||
|
import Text, { H2, H4, Span } from "./Text";
|
||||||
import { Col, RowCenter } from "./Layout";
|
import { Col, RowCenter } from "./Layout";
|
||||||
import Text, { H2, Span } from "./Text";
|
import { useMessage } from "../hooks";
|
||||||
import { ifProp } from "styled-tools";
|
|
||||||
import Animation from "./Animation";
|
import Animation from "./Animation";
|
||||||
import { Colors } from "../consts";
|
import { Colors } from "../consts";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
@ -21,7 +23,6 @@ import Table from "./Table";
|
||||||
import ALink from "./ALink";
|
import ALink from "./ALink";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import { useMessage } from "../hooks";
|
|
||||||
|
|
||||||
const Tr = styled(Flex).attrs({ as: "tr", px: [12, 12, 2] })``;
|
const Tr = styled(Flex).attrs({ as: "tr", px: [12, 12, 2] })``;
|
||||||
const Th = styled(Flex)``;
|
const Th = styled(Flex)``;
|
||||||
|
@ -87,6 +88,218 @@ const viewsFlex = {
|
||||||
};
|
};
|
||||||
const actionsFlex = { flexGrow: [1, 1, 2.5], flexShrink: [1, 1, 2.5] };
|
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 {
|
interface Form {
|
||||||
all: boolean;
|
all: boolean;
|
||||||
limit: string;
|
limit: string;
|
||||||
|
@ -97,10 +310,8 @@ interface Form {
|
||||||
const LinksTable: FC = () => {
|
const LinksTable: FC = () => {
|
||||||
const isAdmin = useStoreState(s => s.auth.isAdmin);
|
const isAdmin = useStoreState(s => s.auth.isAdmin);
|
||||||
const links = useStoreState(s => s.links);
|
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 [tableMessage, setTableMessage] = useState("No links to show.");
|
||||||
const [copied, setCopied] = useState([]);
|
|
||||||
const [qrModal, setQRModal] = useState(-1);
|
|
||||||
const [deleteModal, setDeleteModal] = useState(-1);
|
const [deleteModal, setDeleteModal] = useState(-1);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
const [deleteMessage, setDeleteMessage] = useMessage();
|
const [deleteMessage, setDeleteMessage] = useMessage();
|
||||||
|
@ -113,7 +324,9 @@ const LinksTable: FC = () => {
|
||||||
const linkToDelete = links.items[deleteModal];
|
const linkToDelete = links.items[deleteModal];
|
||||||
|
|
||||||
useEffect(() => {
|
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]);
|
}, [options.limit, options.skip, options.all]);
|
||||||
|
|
||||||
const onSubmit = e => {
|
const onSubmit = e => {
|
||||||
|
@ -121,20 +334,10 @@ const LinksTable: FC = () => {
|
||||||
get(options);
|
get(options);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCopy = (index: number) => () => {
|
|
||||||
setCopied([index]);
|
|
||||||
setTimeout(() => {
|
|
||||||
setCopied(s => s.filter(i => i !== index));
|
|
||||||
}, 1500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDelete = async () => {
|
const onDelete = async () => {
|
||||||
setDeleteLoading(true);
|
setDeleteLoading(true);
|
||||||
try {
|
try {
|
||||||
await deleteOne({
|
await remove(linkToDelete.id);
|
||||||
id: linkToDelete.address,
|
|
||||||
domain: linkToDelete.domain
|
|
||||||
});
|
|
||||||
await get(options);
|
await get(options);
|
||||||
setDeleteModal(-1);
|
setDeleteModal(-1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -254,98 +457,12 @@ const LinksTable: FC = () => {
|
||||||
</Tr>
|
</Tr>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{links.items.map((l, index) => (
|
{links.items.map((link, index) => (
|
||||||
<Tr key={`link-${index}`}>
|
<Row
|
||||||
<Td {...ogLinkFlex} withFade>
|
setDeleteModal={setDeleteModal}
|
||||||
<ALink href={l.target}>{l.target}</ALink>
|
index={index}
|
||||||
</Td>
|
link={link}
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -354,18 +471,6 @@ const LinksTable: FC = () => {
|
||||||
<Tr justifyContent="flex-end">{Nav}</Tr>
|
<Tr justifyContent="flex-end">{Nav}</Tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</Table>
|
</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
|
<Modal
|
||||||
id="delete-custom-domain"
|
id="delete-custom-domain"
|
||||||
show={deleteModal > -1}
|
show={deleteModal > -1}
|
||||||
|
|
|
@ -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;
|
|
|
@ -5,6 +5,7 @@ import styled from "styled-components";
|
||||||
|
|
||||||
import { useStoreState, useStoreActions } from "../../store";
|
import { useStoreState, useStoreActions } from "../../store";
|
||||||
import { Domain } from "../../store/settings";
|
import { Domain } from "../../store/settings";
|
||||||
|
import { errorMessage } from "../../utils";
|
||||||
import { useMessage } from "../../hooks";
|
import { useMessage } from "../../hooks";
|
||||||
import Text, { H2, Span } from "../Text";
|
import Text, { H2, Span } from "../Text";
|
||||||
import { Colors } from "../../consts";
|
import { Colors } from "../../consts";
|
||||||
|
@ -14,7 +15,6 @@ import { Col } from "../Layout";
|
||||||
import Table from "../Table";
|
import Table from "../Table";
|
||||||
import Modal from "../Modal";
|
import Modal from "../Modal";
|
||||||
import Icon from "../Icon";
|
import Icon from "../Icon";
|
||||||
import { errorMessage } from "../../utils";
|
|
||||||
|
|
||||||
const Th = styled(Flex).attrs({ as: "th", py: 3, px: 3 })`
|
const Th = styled(Flex).attrs({ as: "th", py: 3, px: 3 })`
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
@ -24,15 +24,15 @@ const Td = styled(Flex).attrs({ as: "td", py: 12, px: 3 })`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const SettingsDomain: FC = () => {
|
const SettingsDomain: FC = () => {
|
||||||
const [modal, setModal] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
|
||||||
const [domainToDelete, setDomainToDelete] = useState<Domain>(null);
|
|
||||||
const [message, setMessage] = useMessage(2000);
|
|
||||||
const domains = useStoreState(s => s.settings.domains);
|
|
||||||
const { saveDomain, deleteDomain } = useStoreActions(s => s.settings);
|
const { saveDomain, deleteDomain } = useStoreActions(s => s.settings);
|
||||||
|
const [domainToDelete, setDomainToDelete] = useState<Domain>(null);
|
||||||
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const domains = useStoreState(s => s.settings.domains);
|
||||||
|
const [message, setMessage] = useMessage(2000);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modal, setModal] = useState(false);
|
||||||
const [formState, { label, text }] = useFormState<{
|
const [formState, { label, text }] = useFormState<{
|
||||||
customDomain: string;
|
address: string;
|
||||||
homepage: string;
|
homepage: string;
|
||||||
}>(null, { withIds: true });
|
}>(null, { withIds: true });
|
||||||
|
|
||||||
|
@ -56,7 +56,7 @@ const SettingsDomain: FC = () => {
|
||||||
|
|
||||||
const onDelete = async () => {
|
const onDelete = async () => {
|
||||||
setDeleteLoading(true);
|
setDeleteLoading(true);
|
||||||
await deleteDomain().catch(err =>
|
await deleteDomain(domainToDelete.id).catch(err =>
|
||||||
setMessage(errorMessage(err, "Couldn't delete the domain."))
|
setMessage(errorMessage(err, "Couldn't delete the domain."))
|
||||||
);
|
);
|
||||||
setMessage("Domain has been deleted successfully.", "green");
|
setMessage("Domain has been deleted successfully.", "green");
|
||||||
|
@ -88,9 +88,11 @@ const SettingsDomain: FC = () => {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{domains.map(d => (
|
{domains.map(d => (
|
||||||
<tr key={d.customDomain}>
|
<tr key={d.address}>
|
||||||
<Td width={2 / 5}>{d.customDomain}</Td>
|
<Td width={2 / 5}>{d.address}</Td>
|
||||||
<Td width={2 / 5}>{d.homepage || "default"}</Td>
|
<Td width={2 / 5}>
|
||||||
|
{d.homepage || process.env.DEFAULT_DOMAIN}
|
||||||
|
</Td>
|
||||||
<Td width={1 / 5} justifyContent="center">
|
<Td width={1 / 5} justifyContent="center">
|
||||||
<Icon
|
<Icon
|
||||||
as="button"
|
as="button"
|
||||||
|
@ -123,7 +125,7 @@ const SettingsDomain: FC = () => {
|
||||||
<Flex width={1} flexDirection={["column", "row"]}>
|
<Flex width={1} flexDirection={["column", "row"]}>
|
||||||
<Col mr={[0, 2]} mb={[3, 0]} flex="0 0 auto">
|
<Col mr={[0, 2]} mb={[3, 0]} flex="0 0 auto">
|
||||||
<Text
|
<Text
|
||||||
{...label("customDomain")}
|
{...label("address")}
|
||||||
as="label"
|
as="label"
|
||||||
mb={[2, 3]}
|
mb={[2, 3]}
|
||||||
fontSize={[15, 16]}
|
fontSize={[15, 16]}
|
||||||
|
@ -132,7 +134,7 @@ const SettingsDomain: FC = () => {
|
||||||
Domain
|
Domain
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
{...text("customDomain")}
|
{...text("address")}
|
||||||
placeholder="example.com"
|
placeholder="example.com"
|
||||||
maxWidth="240px"
|
maxWidth="240px"
|
||||||
required
|
required
|
||||||
|
@ -169,7 +171,7 @@ const SettingsDomain: FC = () => {
|
||||||
</H2>
|
</H2>
|
||||||
<Text textAlign="center">
|
<Text textAlign="center">
|
||||||
Are you sure do you want to delete the domain{" "}
|
Are you sure do you want to delete the domain{" "}
|
||||||
<Span bold>"{domainToDelete && domainToDelete.customDomain}"</Span>?
|
<Span bold>"{domainToDelete && domainToDelete.address}"</Span>?
|
||||||
</Text>
|
</Text>
|
||||||
<Flex justifyContent="center" mt={44}>
|
<Flex justifyContent="center" mt={44}>
|
||||||
{deleteLoading ? (
|
{deleteLoading ? (
|
||||||
|
|
|
@ -6,7 +6,7 @@ import axios from "axios";
|
||||||
import { getAxiosConfig } from "../../utils";
|
import { getAxiosConfig } from "../../utils";
|
||||||
import { useMessage } from "../../hooks";
|
import { useMessage } from "../../hooks";
|
||||||
import { TextInput } from "../Input";
|
import { TextInput } from "../Input";
|
||||||
import { API } from "../../consts";
|
import { APIv2 } from "../../consts";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import Text, { H2 } from "../Text";
|
import Text, { H2 } from "../Text";
|
||||||
import { Col } from "../Layout";
|
import { Col } from "../Layout";
|
||||||
|
@ -30,7 +30,7 @@ const SettingsPassword: FC = () => {
|
||||||
setMessage();
|
setMessage();
|
||||||
try {
|
try {
|
||||||
const res = await axios.post(
|
const res = await axios.post(
|
||||||
API.CHANGE_PASSWORD,
|
APIv2.AuthChangePassword,
|
||||||
formState.values,
|
formState.values,
|
||||||
getAxiosConfig()
|
getAxiosConfig()
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||||
import { useFormState } from "react-use-form-state";
|
import { useFormState } from "react-use-form-state";
|
||||||
import { Flex } from "reflexbox/styled-components";
|
import { Flex } from "reflexbox/styled-components";
|
||||||
import React, { useState } from "react";
|
import React, { FC, useState } from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
import { useStoreActions, useStoreState } from "../store";
|
import { useStoreActions, useStoreState } from "../store";
|
||||||
|
@ -260,8 +260,8 @@ const Shortener = () => {
|
||||||
options={[
|
options={[
|
||||||
{ key: defaultDomain, value: "" },
|
{ key: defaultDomain, value: "" },
|
||||||
...domains.map(d => ({
|
...domains.map(d => ({
|
||||||
key: d.customDomain,
|
key: d.address,
|
||||||
value: d.customDomain
|
value: d.address
|
||||||
}))
|
}))
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,17 +1,19 @@
|
||||||
import { switchProp, ifNotProp, ifProp } from "styled-tools";
|
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 styled, { css } from "styled-components";
|
||||||
|
|
||||||
|
import { FC, CSSProperties } from "react";
|
||||||
import { Colors } from "../consts";
|
import { Colors } from "../consts";
|
||||||
import { FC, ComponentProps } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props extends Omit<BoxProps, "as"> {
|
||||||
|
as?: string;
|
||||||
htmlFor?: string;
|
htmlFor?: string;
|
||||||
light?: boolean;
|
light?: boolean;
|
||||||
normal?: boolean;
|
normal?: boolean;
|
||||||
bold?: boolean;
|
bold?: boolean;
|
||||||
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
const Text = styled(Box)<Props>`
|
const Text: FC<Props> = styled(Box)<Props>`
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
${ifNotProp(
|
${ifNotProp(
|
||||||
"fontSize",
|
"fontSize",
|
||||||
|
@ -50,18 +52,15 @@ const Text = styled(Box)<Props>`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
Text.defaultProps = {
|
Text.defaultProps = {
|
||||||
as: "p",
|
|
||||||
color: Colors.Text
|
color: Colors.Text
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Text;
|
export default Text;
|
||||||
|
|
||||||
type TextProps = ComponentProps<typeof Text>;
|
export const H1: FC<Props> = props => <Text as="h1" {...props} />;
|
||||||
|
export const H2: FC<Props> = props => <Text as="h2" {...props} />;
|
||||||
export const H1: FC<TextProps> = props => <Text as="h1" {...props} />;
|
export const H3: FC<Props> = props => <Text as="h3" {...props} />;
|
||||||
export const H2: FC<TextProps> = props => <Text as="h2" {...props} />;
|
export const H4: FC<Props> = props => <Text as="h4" {...props} />;
|
||||||
export const H3: FC<TextProps> = props => <Text as="h3" {...props} />;
|
export const H5: FC<Props> = props => <Text as="h5" {...props} />;
|
||||||
export const H4: FC<TextProps> = props => <Text as="h4" {...props} />;
|
export const H6: FC<Props> = props => <Text as="h6" {...props} />;
|
||||||
export const H5: FC<TextProps> = props => <Text as="h5" {...props} />;
|
export const Span: FC<Props> = props => <Text as="span" {...props} />;
|
||||||
export const H6: FC<TextProps> = props => <Text as="h6" {...props} />;
|
|
||||||
export const Span: FC<TextProps> = props => <Text as="span" {...props} />;
|
|
||||||
|
|
|
@ -1,21 +1,17 @@
|
||||||
export enum API {
|
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",
|
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"
|
STATS = "/api/url/stats"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum APIv2 {
|
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"
|
Links = "/api/v2/links"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -32,6 +28,8 @@ export enum Colors {
|
||||||
CheckIcon = "hsl(144, 50%, 60%)",
|
CheckIcon = "hsl(144, 50%, 60%)",
|
||||||
TrashIcon = "hsl(0, 100%, 69%)",
|
TrashIcon = "hsl(0, 100%, 69%)",
|
||||||
TrashIconBg = "hsl(0, 100%, 96%)",
|
TrashIconBg = "hsl(0, 100%, 96%)",
|
||||||
|
StopIcon = "hsl(10, 100%, 40%)",
|
||||||
|
StopIconBg = "hsl(10, 100%, 96%)",
|
||||||
QrCodeIcon = "hsl(0, 0%, 35%)",
|
QrCodeIcon = "hsl(0, 0%, 35%)",
|
||||||
QrCodeIconBg = "hsl(0, 0%, 94%)",
|
QrCodeIconBg = "hsl(0, 0%, 94%)",
|
||||||
PieIcon = "hsl(260, 100%, 69%)",
|
PieIcon = "hsl(260, 100%, 69%)",
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { Button } from "../components/Button";
|
||||||
import Text, { H2 } from "../components/Text";
|
import Text, { H2 } from "../components/Text";
|
||||||
import ALink from "../components/ALink";
|
import ALink from "../components/ALink";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { API } from "../consts";
|
import { APIv2 } from "../consts";
|
||||||
|
|
||||||
const LoginForm = styled(Flex).attrs({
|
const LoginForm = styled(Flex).attrs({
|
||||||
as: "form",
|
as: "form",
|
||||||
|
@ -80,7 +80,7 @@ const LoginPage = () => {
|
||||||
if (type === "signup") {
|
if (type === "signup") {
|
||||||
setLoading(s => ({ ...s, signup: true }));
|
setLoading(s => ({ ...s, signup: true }));
|
||||||
try {
|
try {
|
||||||
await axios.post(API.SIGNUP, { email, password });
|
await axios.post(APIv2.AuthSignup, { email, password });
|
||||||
setVerifying(true);
|
setVerifying(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error.response.data.error);
|
setError(error.response.data.error);
|
||||||
|
|
|
@ -2,20 +2,23 @@ import { useFormState } from "react-use-form-state";
|
||||||
import { Flex } from "reflexbox/styled-components";
|
import { Flex } from "reflexbox/styled-components";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { NextPage } from "next";
|
import { NextPage } from "next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
import AppWrapper from "../components/AppWrapper";
|
import AppWrapper from "../../components/AppWrapper";
|
||||||
import { TextInput } from "../components/Input";
|
import { TextInput } from "../../components/Input";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../../components/Button";
|
||||||
import Text, { H2 } from "../components/Text";
|
import Text, { H2 } from "../../components/Text";
|
||||||
import { Col } from "../components/Layout";
|
import { Col } from "../../components/Layout";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../../components/Icon";
|
||||||
|
import { APIv2 } from "../../consts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
protectedLink?: string;
|
protectedLink?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UrlPasswordPage: NextPage<Props> = ({ protectedLink }) => {
|
const ProtectedPage: NextPage<Props> = () => {
|
||||||
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [formState, { password }] = useFormState<{ password: string }>();
|
const [formState, { password }] = useFormState<{ password: string }>();
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
@ -30,12 +33,13 @@ const UrlPasswordPage: NextPage<Props> = ({ protectedLink }) => {
|
||||||
|
|
||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// TODO: better api calls
|
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.post("/api/url/requesturl", {
|
const { data } = await axios.post(
|
||||||
id: protectedLink,
|
`${APIv2.Links}/${router.query.id}/protected`,
|
||||||
password
|
{
|
||||||
});
|
password
|
||||||
|
}
|
||||||
|
);
|
||||||
window.location.replace(data.target);
|
window.location.replace(data.target);
|
||||||
} catch ({ response }) {
|
} catch ({ response }) {
|
||||||
setError(response.data.error);
|
setError(response.data.error);
|
||||||
|
@ -45,7 +49,7 @@ const UrlPasswordPage: NextPage<Props> = ({ protectedLink }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppWrapper>
|
<AppWrapper>
|
||||||
{!protectedLink ? (
|
{!router.query.id ? (
|
||||||
<H2 my={4} light>
|
<H2 my={4} light>
|
||||||
404 | Link could not be found.
|
404 | Link could not be found.
|
||||||
</H2>
|
</H2>
|
||||||
|
@ -84,10 +88,10 @@ const UrlPasswordPage: NextPage<Props> = ({ protectedLink }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
UrlPasswordPage.getInitialProps = async ({ req }) => {
|
ProtectedPage.getInitialProps = async ({ req }) => {
|
||||||
return {
|
return {
|
||||||
protectedLink: req && (req as any).protectedLink
|
protectedLink: req && (req as any).protectedLink
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default UrlPasswordPage;
|
export default ProtectedPage;
|
|
@ -10,7 +10,7 @@ import { Button } from "../components/Button";
|
||||||
import { Col } from "../components/Layout";
|
import { Col } from "../components/Layout";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { useMessage } from "../hooks";
|
import { useMessage } from "../hooks";
|
||||||
import { API } from "../consts";
|
import { APIv2 } from "../consts";
|
||||||
|
|
||||||
const ReportPage = () => {
|
const ReportPage = () => {
|
||||||
const [formState, { text }] = useFormState<{ url: string }>();
|
const [formState, { text }] = useFormState<{ url: string }>();
|
||||||
|
@ -22,7 +22,7 @@ const ReportPage = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMessage();
|
setMessage();
|
||||||
try {
|
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");
|
setMessage("Thanks for the report, we'll take actions shortly.", "green");
|
||||||
formState.clear();
|
formState.clear();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { Col } from "../components/Layout";
|
||||||
import { TokenPayload } from "../types";
|
import { TokenPayload } from "../types";
|
||||||
import { useMessage } from "../hooks";
|
import { useMessage } from "../hooks";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { API } from "../consts";
|
import { API, APIv2 } from "../consts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
token?: string;
|
token?: string;
|
||||||
|
@ -51,7 +51,7 @@ const ResetPassword: NextPage<Props> = ({ token }) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMessage();
|
setMessage();
|
||||||
try {
|
try {
|
||||||
await axios.post(API.RESET_PASSWORD, {
|
await axios.post(APIv2.AuthResetPassword, {
|
||||||
email: formState.values.email
|
email: formState.values.email
|
||||||
});
|
});
|
||||||
setMessage("Reset password email has been sent.", "green");
|
setMessage("Reset password email has been sent.", "green");
|
||||||
|
|
|
@ -1,20 +1,18 @@
|
||||||
import { Flex } from "reflexbox/styled-components";
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { NextPage } from "next";
|
import { NextPage } from "next";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
import SettingsPassword from "../components/Settings/SettingsPassword";
|
import SettingsPassword from "../components/Settings/SettingsPassword";
|
||||||
import SettingsDomain from "../components/Settings/SettingsDomain";
|
import SettingsDomain from "../components/Settings/SettingsDomain";
|
||||||
import SettingsBan from "../components/Settings/SettingsBan";
|
|
||||||
import SettingsApi from "../components/Settings/SettingsApi";
|
import SettingsApi from "../components/Settings/SettingsApi";
|
||||||
import { useStoreState, useStoreActions } from "../store";
|
|
||||||
import AppWrapper from "../components/AppWrapper";
|
import AppWrapper from "../components/AppWrapper";
|
||||||
import { H1, Span } from "../components/Text";
|
import { H1, Span } from "../components/Text";
|
||||||
import Divider from "../components/Divider";
|
import Divider from "../components/Divider";
|
||||||
import Footer from "../components/Footer";
|
|
||||||
import { Col } from "../components/Layout";
|
import { Col } from "../components/Layout";
|
||||||
|
import Footer from "../components/Footer";
|
||||||
|
import { useStoreState } from "../store";
|
||||||
|
|
||||||
const SettingsPage: NextPage = props => {
|
const SettingsPage: NextPage = () => {
|
||||||
const { email, isAdmin } = useStoreState(s => s.auth);
|
const email = useStoreState(s => s.auth.email);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppWrapper>
|
<AppWrapper>
|
||||||
|
@ -27,12 +25,6 @@ const SettingsPage: NextPage = props => {
|
||||||
.
|
.
|
||||||
</H1>
|
</H1>
|
||||||
<Divider mt={4} mb={48} />
|
<Divider mt={4} mb={48} />
|
||||||
{isAdmin && (
|
|
||||||
<>
|
|
||||||
<SettingsBan />
|
|
||||||
<Divider mt={4} mb={48} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<SettingsDomain />
|
<SettingsDomain />
|
||||||
<Divider mt={4} mb={48} />
|
<Divider mt={4} mb={48} />
|
||||||
<SettingsPassword />
|
<SettingsPassword />
|
||||||
|
|
|
@ -15,15 +15,14 @@ import AppWrapper from "../components/AppWrapper";
|
||||||
import Divider from "../components/Divider";
|
import Divider from "../components/Divider";
|
||||||
import { useStoreState } from "../store";
|
import { useStoreState } from "../store";
|
||||||
import ALink from "../components/ALink";
|
import ALink from "../components/ALink";
|
||||||
import { API, Colors } from "../consts";
|
import { APIv2, Colors } from "../consts";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
domain?: string;
|
|
||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatsPage: NextPage<Props> = ({ domain, id }) => {
|
const StatsPage: NextPage<Props> = ({ id }) => {
|
||||||
const { isAuthenticated } = useStoreState(s => s.auth);
|
const { isAuthenticated } = useStoreState(s => s.auth);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
@ -35,7 +34,7 @@ const StatsPage: NextPage<Props> = ({ domain, id }) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id || !isAuthenticated) return;
|
if (!id || !isAuthenticated) return;
|
||||||
axios
|
axios
|
||||||
.get(`${API.STATS}?id=${id}&domain=${domain}`, getAxiosConfig())
|
.get(`${APIv2.Links}/${id}/stats`, getAxiosConfig())
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(!data);
|
setError(!data);
|
||||||
|
@ -208,7 +207,6 @@ StatsPage.getInitialProps = ({ query }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
StatsPage.defaultProps = {
|
StatsPage.defaultProps = {
|
||||||
domain: "",
|
|
||||||
id: ""
|
id: ""
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,21 +1,16 @@
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import React from "react";
|
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 AppWrapper from "../components/AppWrapper";
|
||||||
import Footer from "../components/Footer";
|
import Footer from "../components/Footer";
|
||||||
import { H2, H4 } from "../components/Text";
|
import { H2, H4 } from "../components/Text";
|
||||||
import { Col } from "../components/Layout";
|
import { Col } from "../components/Layout";
|
||||||
|
|
||||||
interface Props {
|
const UrlInfoPage = () => {
|
||||||
linkTarget?: string;
|
const { query } = useRouter();
|
||||||
}
|
|
||||||
|
|
||||||
const UrlInfoPage: NextPage<Props> = ({ linkTarget }) => {
|
|
||||||
return (
|
return (
|
||||||
<AppWrapper>
|
<AppWrapper>
|
||||||
{!linkTarget ? (
|
{!query.target ? (
|
||||||
<H2 my={4} light>
|
<H2 my={4} light>
|
||||||
404 | Link could not be found.
|
404 | Link could not be found.
|
||||||
</H2>
|
</H2>
|
||||||
|
@ -25,7 +20,7 @@ const UrlInfoPage: NextPage<Props> = ({ linkTarget }) => {
|
||||||
<H2 my={3} light>
|
<H2 my={3} light>
|
||||||
Target:
|
Target:
|
||||||
</H2>
|
</H2>
|
||||||
<H4 bold>{linkTarget}</H4>
|
<H4 bold>{query.target}</H4>
|
||||||
</Col>
|
</Col>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
@ -34,8 +29,4 @@ const UrlInfoPage: NextPage<Props> = ({ linkTarget }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
UrlInfoPage.getInitialProps = async ctx => {
|
|
||||||
return { linkTarget: (ctx?.req as any)?.linkTarget };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UrlInfoPage;
|
export default UrlInfoPage;
|
||||||
|
|
|
@ -4,7 +4,7 @@ import cookie from "js-cookie";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
import { TokenPayload } from "../types";
|
import { TokenPayload } from "../types";
|
||||||
import { API } from "../consts";
|
import { API, APIv2 } from "../consts";
|
||||||
import { getAxiosConfig } from "../utils";
|
import { getAxiosConfig } from "../utils";
|
||||||
|
|
||||||
export interface Auth {
|
export interface Auth {
|
||||||
|
@ -35,14 +35,14 @@ export const auth: Auth = {
|
||||||
state.isAdmin = false;
|
state.isAdmin = false;
|
||||||
}),
|
}),
|
||||||
login: thunk(async (actions, payload) => {
|
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;
|
const { token } = res.data;
|
||||||
cookie.set("token", token, { expires: 7 });
|
cookie.set("token", token, { expires: 7 });
|
||||||
const tokenPayload: TokenPayload = decode(token);
|
const tokenPayload: TokenPayload = decode(token);
|
||||||
actions.add(tokenPayload);
|
actions.add(tokenPayload);
|
||||||
}),
|
}),
|
||||||
renew: thunk(async actions => {
|
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;
|
const { token } = res.data;
|
||||||
cookie.set("token", token, { expires: 7 });
|
cookie.set("token", token, { expires: 7 });
|
||||||
const tokenPayload: TokenPayload = decode(token);
|
const tokenPayload: TokenPayload = decode(token);
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { getAxiosConfig } from "../utils";
|
||||||
import { API, APIv2 } from "../consts";
|
import { API, APIv2 } from "../consts";
|
||||||
|
|
||||||
export interface Link {
|
export interface Link {
|
||||||
id: number;
|
id: string;
|
||||||
address: string;
|
address: string;
|
||||||
banned: boolean;
|
banned: boolean;
|
||||||
banned_by_id?: number;
|
banned_by_id?: number;
|
||||||
|
@ -30,6 +30,14 @@ export interface NewLink {
|
||||||
reCaptchaToken?: string;
|
reCaptchaToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BanLink {
|
||||||
|
id: string;
|
||||||
|
host?: boolean;
|
||||||
|
domain?: boolean;
|
||||||
|
user?: boolean;
|
||||||
|
userLinks?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LinksQuery {
|
export interface LinksQuery {
|
||||||
limit: string;
|
limit: string;
|
||||||
skip: string;
|
skip: string;
|
||||||
|
@ -53,7 +61,9 @@ export interface Links {
|
||||||
get: Thunk<Links, LinksQuery>;
|
get: Thunk<Links, LinksQuery>;
|
||||||
add: Action<Links, Link>;
|
add: Action<Links, Link>;
|
||||||
set: Action<Links, LinksListRes>;
|
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>;
|
setLoading: Action<Links, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,8 +90,17 @@ export const links: Links = {
|
||||||
actions.setLoading(false);
|
actions.setLoading(false);
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
deleteOne: thunk(async (actions, payload) => {
|
remove: thunk(async (actions, id) => {
|
||||||
await axios.post(API.DELETE_LINK, payload, getAxiosConfig());
|
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) => {
|
add: action((state, payload) => {
|
||||||
state.items.pop();
|
state.items.pop();
|
||||||
|
@ -91,6 +110,11 @@ export const links: Links = {
|
||||||
state.items = payload.data;
|
state.items = payload.data;
|
||||||
state.total = payload.total;
|
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) => {
|
setLoading: action((state, payload) => {
|
||||||
state.loading = payload;
|
state.loading = payload;
|
||||||
})
|
})
|
||||||
|
|
|
@ -3,60 +3,71 @@ import axios from "axios";
|
||||||
|
|
||||||
import { getAxiosConfig } from "../utils";
|
import { getAxiosConfig } from "../utils";
|
||||||
import { StoreModel } from "./store";
|
import { StoreModel } from "./store";
|
||||||
import { API } from "../consts";
|
import { APIv2 } from "../consts";
|
||||||
|
|
||||||
export interface Domain {
|
export interface Domain {
|
||||||
customDomain: string;
|
id: string;
|
||||||
homepage: 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;
|
apikey: string;
|
||||||
|
email: string;
|
||||||
|
domains: Domain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
domains: Array<Domain>;
|
domains: Array<Domain>;
|
||||||
apikey: string;
|
apikey: string;
|
||||||
|
email: string;
|
||||||
fetched: boolean;
|
fetched: boolean;
|
||||||
setSettings: Action<Settings, SettingsResp>;
|
setSettings: Action<Settings, SettingsResp>;
|
||||||
getSettings: Thunk<Settings, null, null, StoreModel>;
|
getSettings: Thunk<Settings, null, null, StoreModel>;
|
||||||
setApiKey: Action<Settings, string>;
|
setApiKey: Action<Settings, string>;
|
||||||
generateApiKey: Thunk<Settings>;
|
generateApiKey: Thunk<Settings>;
|
||||||
addDomain: Action<Settings, Domain>;
|
addDomain: Action<Settings, Domain>;
|
||||||
removeDomain: Action<Settings>;
|
removeDomain: Action<Settings, string>;
|
||||||
saveDomain: Thunk<Settings, Domain>;
|
saveDomain: Thunk<Settings, NewDomain>;
|
||||||
deleteDomain: Thunk<Settings>;
|
deleteDomain: Thunk<Settings, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settings: Settings = {
|
export const settings: Settings = {
|
||||||
domains: [],
|
domains: [],
|
||||||
|
email: null,
|
||||||
apikey: null,
|
apikey: null,
|
||||||
fetched: false,
|
fetched: false,
|
||||||
getSettings: thunk(async (actions, payload, { getStoreActions }) => {
|
getSettings: thunk(async (actions, payload, { getStoreActions }) => {
|
||||||
getStoreActions().loading.show();
|
getStoreActions().loading.show();
|
||||||
const res = await axios.get(API.SETTINGS, getAxiosConfig());
|
const res = await axios.get(APIv2.Users, getAxiosConfig());
|
||||||
actions.setSettings(res.data);
|
actions.setSettings(res.data);
|
||||||
getStoreActions().loading.hide();
|
getStoreActions().loading.hide();
|
||||||
}),
|
}),
|
||||||
generateApiKey: thunk(async actions => {
|
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);
|
actions.setApiKey(res.data.apikey);
|
||||||
}),
|
}),
|
||||||
deleteDomain: thunk(async actions => {
|
deleteDomain: thunk(async (actions, id) => {
|
||||||
await axios.delete(API.CUSTOM_DOMAIN, getAxiosConfig());
|
await axios.delete(`${APIv2.Domains}/${id}`, getAxiosConfig());
|
||||||
actions.removeDomain();
|
actions.removeDomain(id);
|
||||||
}),
|
}),
|
||||||
setSettings: action((state, payload) => {
|
setSettings: action((state, payload) => {
|
||||||
state.apikey = payload.apikey;
|
state.apikey = payload.apikey;
|
||||||
|
state.domains = payload.domains;
|
||||||
|
state.email = payload.email;
|
||||||
state.fetched = true;
|
state.fetched = true;
|
||||||
if (payload.customDomain) {
|
|
||||||
state.domains = [
|
|
||||||
{
|
|
||||||
customDomain: payload.customDomain,
|
|
||||||
homepage: payload.homepage
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
setApiKey: action((state, payload) => {
|
setApiKey: action((state, payload) => {
|
||||||
state.apikey = payload;
|
state.apikey = payload;
|
||||||
|
@ -64,14 +75,11 @@ export const settings: Settings = {
|
||||||
addDomain: action((state, payload) => {
|
addDomain: action((state, payload) => {
|
||||||
state.domains.push(payload);
|
state.domains.push(payload);
|
||||||
}),
|
}),
|
||||||
removeDomain: action(state => {
|
removeDomain: action((state, id) => {
|
||||||
state.domains = [];
|
state.domains = state.domains.filter(d => d.id !== id);
|
||||||
}),
|
}),
|
||||||
saveDomain: thunk(async (actions, payload) => {
|
saveDomain: thunk(async (actions, payload) => {
|
||||||
const res = await axios.post(API.CUSTOM_DOMAIN, payload, getAxiosConfig());
|
const res = await axios.post(APIv2.Domains, payload, getAxiosConfig());
|
||||||
actions.addDomain({
|
actions.addDomain(res.data);
|
||||||
customDomain: res.data.customDomain,
|
|
||||||
homepage: res.data.homepage
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
|
type Raw = import("knex").Raw;
|
||||||
|
|
||||||
|
type Match<T> = {
|
||||||
|
[K in keyof T]?: T[K] | [">" | ">=" | "<=" | "<", T[K]];
|
||||||
|
};
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
apikey?: string;
|
apikey?: string;
|
||||||
|
@ -24,6 +30,7 @@ interface UserJoined extends User {
|
||||||
|
|
||||||
interface Domain {
|
interface Domain {
|
||||||
id: number;
|
id: number;
|
||||||
|
uuid: string;
|
||||||
address: string;
|
address: string;
|
||||||
banned: boolean;
|
banned: boolean;
|
||||||
banned_by_id?: number;
|
banned_by_id?: number;
|
||||||
|
@ -33,6 +40,18 @@ interface Domain {
|
||||||
user_id?: number;
|
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 {
|
interface Host {
|
||||||
id: number;
|
id: number;
|
||||||
address: string;
|
address: string;
|
||||||
|
@ -64,6 +83,22 @@ interface Link {
|
||||||
visit_count: number;
|
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 {
|
interface LinkJoinedDomain extends Link {
|
||||||
domain?: string;
|
domain?: string;
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
112
package.json
112
package.json
|
@ -32,120 +32,122 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/TheDevs-Network/kutt#readme",
|
"homepage": "https://github.com/TheDevs-Network/kutt#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.19.0",
|
"axios": "^0.19.1",
|
||||||
"babel-plugin-inline-react-svg": "^1.1.0",
|
"babel-plugin-inline-react-svg": "^1.1.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bull": "^3.11.0",
|
"bull": "^3.12.1",
|
||||||
"cookie-parser": "^1.4.4",
|
"cookie-parser": "^1.4.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"date-fns": "^2.4.1",
|
"date-fns": "^2.9.0",
|
||||||
"dotenv": "^8.0.0",
|
"dotenv": "^8.2.0",
|
||||||
"easy-peasy": "^3.2.3",
|
"easy-peasy": "^3.3.0",
|
||||||
"email-validator": "^1.2.3",
|
"email-validator": "^1.2.3",
|
||||||
|
"envalid": "^6.0.0",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"express-async-handler": "^1.1.4",
|
"express-async-handler": "^1.1.4",
|
||||||
"express-validator": "^6.3.1",
|
"express-validator": "^6.3.1",
|
||||||
"geoip-lite": "^1.3.8",
|
"geoip-lite": "^1.4.0",
|
||||||
"helmet": "^3.21.1",
|
"helmet": "^3.21.2",
|
||||||
"isbot": "^2.2.1",
|
"isbot": "^2.5.4",
|
||||||
"js-cookie": "^2.2.0",
|
"js-cookie": "^2.2.1",
|
||||||
"jsonwebtoken": "^8.4.0",
|
"jsonwebtoken": "^8.4.0",
|
||||||
"jwt-decode": "^2.2.0",
|
"jwt-decode": "^2.2.0",
|
||||||
"knex": "^0.19.5",
|
"knex": "^0.19.5",
|
||||||
"morgan": "^1.9.1",
|
"morgan": "^1.9.1",
|
||||||
"ms": "^2.1.1",
|
"ms": "^2.1.2",
|
||||||
"nanoid": "^1.3.4",
|
"nanoid": "^1.3.4",
|
||||||
"neo4j-driver": "^1.7.5",
|
"neo4j-driver": "^1.7.6",
|
||||||
"next": "^9.1.7",
|
"next": "^9.2.0",
|
||||||
"node-cron": "^2.0.3",
|
"node-cron": "^2.0.3",
|
||||||
"nodemailer": "^6.3.0",
|
"nodemailer": "^6.4.2",
|
||||||
"p-queue": "^6.1.1",
|
"p-queue": "^6.2.1",
|
||||||
"passport": "^0.4.0",
|
"passport": "^0.4.1",
|
||||||
"passport-jwt": "^4.0.0",
|
"passport-jwt": "^4.0.0",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"passport-localapikey-update": "^0.6.0",
|
"passport-localapikey-update": "^0.6.0",
|
||||||
"pg": "^7.12.1",
|
"pg": "^7.17.1",
|
||||||
"pg-query-stream": "^2.0.0",
|
"pg-query-stream": "^2.1.2",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"qrcode.react": "^0.8.0",
|
"qrcode.react": "^0.8.0",
|
||||||
"query-string": "^6.9.0",
|
"query-string": "^6.10.1",
|
||||||
"raven": "^2.6.4",
|
"raven": "^2.6.4",
|
||||||
"react": "^16.8.1",
|
"react": "^16.12.0",
|
||||||
"react-copy-to-clipboard": "^5.0.1",
|
"react-copy-to-clipboard": "^5.0.2",
|
||||||
"react-dom": "^16.8.1",
|
"react-dom": "^16.12.0",
|
||||||
"react-ga": "^2.5.7",
|
"react-ga": "^2.7.0",
|
||||||
"react-inlinesvg": "^1.2.0",
|
"react-inlinesvg": "^1.2.0",
|
||||||
"react-tippy": "^1.3.1",
|
"react-tippy": "^1.3.1",
|
||||||
"react-tooltip": "^3.11.1",
|
"react-tooltip": "^3.11.2",
|
||||||
"react-use-form-state": "^0.12.0",
|
"react-use-form-state": "^0.12.1",
|
||||||
"recharts": "^1.4.3",
|
"recharts": "^1.8.5",
|
||||||
"redis": "^2.8.0",
|
"redis": "^2.8.0",
|
||||||
"reflexbox": "^4.0.6",
|
"reflexbox": "^4.0.6",
|
||||||
"styled-components": "^4.4.1",
|
"signale": "^1.4.0",
|
||||||
|
"styled-components": "^5.0.0",
|
||||||
"styled-tools": "^1.7.1",
|
"styled-tools": "^1.7.1",
|
||||||
"universal-analytics": "^0.4.20",
|
"universal-analytics": "^0.4.20",
|
||||||
"url-regex": "^4.1.1",
|
"url-regex": "^4.1.1",
|
||||||
"use-media": "^1.4.0",
|
"use-media": "^1.4.0",
|
||||||
"useragent": "^2.2.1",
|
"useragent": "^2.2.1",
|
||||||
"uuid": "^3.3.2"
|
"uuid": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "^7.2.3",
|
"@babel/cli": "^7.8.3",
|
||||||
"@babel/core": "^7.2.2",
|
"@babel/core": "^7.8.3",
|
||||||
"@babel/node": "^7.2.2",
|
"@babel/node": "^7.8.3",
|
||||||
"@babel/preset-env": "^7.3.1",
|
"@babel/preset-env": "^7.8.3",
|
||||||
"@babel/register": "^7.0.0",
|
"@babel/register": "^7.8.3",
|
||||||
"@types/bcryptjs": "^2.4.2",
|
"@types/bcryptjs": "^2.4.2",
|
||||||
"@types/body-parser": "^1.17.0",
|
"@types/body-parser": "^1.17.1",
|
||||||
"@types/bull": "^3.10.5",
|
"@types/bull": "^3.12.0",
|
||||||
"@types/cookie-parser": "^1.4.1",
|
"@types/cookie-parser": "^1.4.2",
|
||||||
"@types/cors": "^2.8.5",
|
"@types/cors": "^2.8.6",
|
||||||
"@types/date-fns": "^2.6.0",
|
"@types/date-fns": "^2.6.0",
|
||||||
"@types/dotenv": "^4.0.3",
|
"@types/dotenv": "^4.0.3",
|
||||||
"@types/express": "^4.16.0",
|
"@types/express": "^4.17.2",
|
||||||
"@types/helmet": "0.0.38",
|
"@types/helmet": "0.0.38",
|
||||||
"@types/jsonwebtoken": "^7.2.8",
|
"@types/jsonwebtoken": "^7.2.8",
|
||||||
"@types/jwt-decode": "^2.2.1",
|
"@types/jwt-decode": "^2.2.1",
|
||||||
"@types/mongodb": "^3.1.17",
|
"@types/mongodb": "^3.3.14",
|
||||||
"@types/morgan": "^1.7.36",
|
"@types/morgan": "^1.7.37",
|
||||||
"@types/ms": "^0.7.30",
|
"@types/ms": "^0.7.31",
|
||||||
"@types/next": "^9.0.0",
|
"@types/next": "^9.0.0",
|
||||||
"@types/node-cron": "^2.0.2",
|
"@types/node-cron": "^2.0.2",
|
||||||
"@types/nodemailer": "^6.2.1",
|
"@types/nodemailer": "^6.4.0",
|
||||||
"@types/pg": "^7.11.0",
|
"@types/pg": "^7.14.1",
|
||||||
"@types/pg-query-stream": "^1.0.3",
|
"@types/pg-query-stream": "^1.0.3",
|
||||||
"@types/qrcode.react": "^1.0.0",
|
"@types/qrcode.react": "^1.0.0",
|
||||||
"@types/react": "^16.9.16",
|
"@types/react": "^16.9.17",
|
||||||
"@types/react-dom": "^16.9.4",
|
"@types/react-dom": "^16.9.4",
|
||||||
"@types/react-tooltip": "^3.11.0",
|
"@types/react-tooltip": "^3.11.0",
|
||||||
"@types/redis": "^2.8.10",
|
"@types/redis": "^2.8.14",
|
||||||
"@types/reflexbox": "^4.0.0",
|
"@types/reflexbox": "^4.0.0",
|
||||||
"@types/styled-components": "^4.1.8",
|
"@types/styled-components": "^4.1.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^2.0.0",
|
"@typescript-eslint/eslint-plugin": "^2.16.0",
|
||||||
"@typescript-eslint/parser": "^2.0.0",
|
"@typescript-eslint/parser": "^2.16.0",
|
||||||
"babel": "^6.23.0",
|
"babel": "^6.23.0",
|
||||||
"babel-cli": "^6.26.0",
|
"babel-cli": "^6.26.0",
|
||||||
"babel-core": "^6.26.3",
|
"babel-core": "^6.26.3",
|
||||||
"babel-eslint": "^8.2.6",
|
"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",
|
"babel-preset-env": "^1.7.0",
|
||||||
"chai": "^4.1.2",
|
"chai": "^4.1.2",
|
||||||
"copyfiles": "^2.1.1",
|
"copyfiles": "^2.2.0",
|
||||||
"deep-freeze": "^0.0.1",
|
"deep-freeze": "^0.0.1",
|
||||||
"eslint": "^5.4.0",
|
"eslint": "^5.16.0",
|
||||||
"eslint-config-airbnb": "^16.1.0",
|
"eslint-config-airbnb": "^16.1.0",
|
||||||
"eslint-config-prettier": "^6.7.0",
|
"eslint-config-prettier": "^6.9.0",
|
||||||
"eslint-plugin-import": "^2.16.0",
|
"eslint-plugin-import": "^2.20.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.2.1",
|
"eslint-plugin-jsx-a11y": "^6.2.3",
|
||||||
"eslint-plugin-prettier": "^3.1.2",
|
"eslint-plugin-prettier": "^3.1.2",
|
||||||
"eslint-plugin-react": "^7.14.3",
|
"eslint-plugin-react": "^7.18.0",
|
||||||
"husky": "^0.15.0-rc.13",
|
"husky": "^0.15.0-rc.13",
|
||||||
"mocha": "^5.2.0",
|
"mocha": "^5.2.0",
|
||||||
"nock": "^9.3.3",
|
"nock": "^9.3.3",
|
||||||
"nodemon": "^1.18.10",
|
"nodemon": "^1.19.4",
|
||||||
"prettier": "^1.19.1",
|
"prettier": "^1.19.1",
|
||||||
"rimraf": "^3.0.0",
|
"rimraf": "^3.0.0",
|
||||||
"sinon": "^6.0.0",
|
"sinon": "^6.0.0",
|
||||||
"typescript": "^3.7.3"
|
"typescript": "^3.7.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import urlRegex from "url-regex";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { deleteDomain, getDomain, setDomain } from "../db/domain";
|
import { deleteDomain, getDomain, setDomain } from "../db/domain";
|
||||||
import { addIP } from "../db/ip";
|
import { addIP } from "../db/ip";
|
||||||
|
import env from "../../env";
|
||||||
import {
|
import {
|
||||||
banLink,
|
banLink,
|
||||||
createShortLink,
|
createShortLink,
|
||||||
|
@ -18,9 +19,9 @@ import {
|
||||||
getStats,
|
getStats,
|
||||||
getUserLinksCount
|
getUserLinksCount
|
||||||
} from "../db/link";
|
} from "../db/link";
|
||||||
import transporter from "../mail/mail";
|
import transporter from "../../mail/mail";
|
||||||
import * as redis from "../redis";
|
import * as redis from "../../redis";
|
||||||
import { addProtocol, generateShortLink, getStatsCacheTime } from "../utils";
|
import { addProtocol, generateShortLink, getStatsCacheTime } from "../../utils";
|
||||||
import {
|
import {
|
||||||
checkBannedDomain,
|
checkBannedDomain,
|
||||||
checkBannedHost,
|
checkBannedHost,
|
||||||
|
@ -29,14 +30,14 @@ import {
|
||||||
preservedUrls,
|
preservedUrls,
|
||||||
urlCountsCheck
|
urlCountsCheck
|
||||||
} from "./validateBodyController";
|
} from "./validateBodyController";
|
||||||
import { visitQueue } from "../queues";
|
import queue from "../../queues";
|
||||||
|
|
||||||
const dnsLookup = promisify(dns.lookup);
|
const dnsLookup = promisify(dns.lookup);
|
||||||
|
|
||||||
const generateId = async () => {
|
const generateId = async () => {
|
||||||
const address = generate(
|
const address = generate(
|
||||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890",
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890",
|
||||||
Number(process.env.LINK_LENGTH) || 6
|
env.LINK_LENGTH
|
||||||
);
|
);
|
||||||
const link = await findLink({ address });
|
const link = await findLink({ address });
|
||||||
if (!link) return address;
|
if (!link) return address;
|
||||||
|
@ -49,9 +50,8 @@ export const shortener: Handler = async (req, res) => {
|
||||||
const targetDomain = URL.parse(target).hostname;
|
const targetDomain = URL.parse(target).hostname;
|
||||||
|
|
||||||
const queries = await Promise.all([
|
const queries = await Promise.all([
|
||||||
process.env.GOOGLE_SAFE_BROWSING_KEY && cooldownCheck(req.user),
|
env.GOOGLE_SAFE_BROWSING_KEY && cooldownCheck(req.user),
|
||||||
process.env.GOOGLE_SAFE_BROWSING_KEY &&
|
env.GOOGLE_SAFE_BROWSING_KEY && malwareCheck(req.user, req.body.target),
|
||||||
malwareCheck(req.user, req.body.target),
|
|
||||||
req.user && urlCountsCheck(req.user),
|
req.user && urlCountsCheck(req.user),
|
||||||
req.user &&
|
req.user &&
|
||||||
req.body.reuse &&
|
req.body.reuse &&
|
||||||
|
@ -101,7 +101,7 @@ export const shortener: Handler = async (req, res) => {
|
||||||
},
|
},
|
||||||
req.user
|
req.user
|
||||||
);
|
);
|
||||||
if (!req.user && Number(process.env.NON_USER_COOLDOWN)) {
|
if (!req.user && env.NON_USER_COOLDOWN) {
|
||||||
addIP(req.realIP);
|
addIP(req.realIP);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,7 +115,7 @@ export const goToLink: Handler = async (req, res, next) => {
|
||||||
const { host } = req.headers;
|
const { host } = req.headers;
|
||||||
const reqestedId = req.params.id || req.body.id;
|
const reqestedId = req.params.id || req.body.id;
|
||||||
const address = reqestedId.replace("+", "");
|
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"]);
|
const isBot = isbot(req.headers["user-agent"]);
|
||||||
|
|
||||||
let domain;
|
let domain;
|
||||||
|
@ -126,7 +126,7 @@ export const goToLink: Handler = async (req, res, next) => {
|
||||||
const link = await findLink({ address, domain_id: domain && domain.id });
|
const link = await findLink({ address, domain_id: domain && domain.id });
|
||||||
|
|
||||||
if (!link) {
|
if (!link) {
|
||||||
if (host !== process.env.DEFAULT_DOMAIN) {
|
if (host !== env.DEFAULT_DOMAIN) {
|
||||||
if (!domain || !domain.homepage) return next();
|
if (!domain || !domain.homepage) return next();
|
||||||
return res.redirect(301, domain.homepage);
|
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" });
|
return res.status(401).json({ error: "Password is not correct" });
|
||||||
}
|
}
|
||||||
if (link.user_id && !isBot) {
|
if (link.user_id && !isBot) {
|
||||||
visitQueue.add({
|
queue.visit.add({
|
||||||
headers: req.headers,
|
headers: req.headers,
|
||||||
realIP: req.realIP,
|
realIP: req.realIP,
|
||||||
referrer: req.get("Referrer"),
|
referrer: req.get("Referrer"),
|
||||||
|
@ -168,7 +168,7 @@ export const goToLink: Handler = async (req, res, next) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (link.user_id && !isBot) {
|
if (link.user_id && !isBot) {
|
||||||
visitQueue.add({
|
queue.visit.add({
|
||||||
headers: req.headers,
|
headers: req.headers,
|
||||||
realIP: req.realIP,
|
realIP: req.realIP,
|
||||||
referrer: req.get("Referrer"),
|
referrer: req.get("Referrer"),
|
||||||
|
@ -177,8 +177,8 @@ export const goToLink: Handler = async (req, res, next) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.GOOGLE_ANALYTICS_UNIVERSAL && !isBot) {
|
if (env.GOOGLE_ANALYTICS_UNIVERSAL && !isBot) {
|
||||||
const visitor = ua(process.env.GOOGLE_ANALYTICS_UNIVERSAL);
|
const visitor = ua(env.GOOGLE_ANALYTICS_UNIVERSAL);
|
||||||
visitor
|
visitor
|
||||||
.pageview({
|
.pageview({
|
||||||
dp: `/${address}`,
|
dp: `/${address}`,
|
||||||
|
@ -210,7 +210,7 @@ export const setCustomDomain: Handler = async (req, res) => {
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: "Maximum custom domain length is 40." });
|
.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." });
|
return res.status(400).json({ error: "You can't use default domain." });
|
||||||
}
|
}
|
||||||
const isValidHomepage =
|
const isValidHomepage =
|
||||||
|
@ -260,7 +260,7 @@ export const deleteCustomDomain: Handler = async (req, res) => {
|
||||||
export const customDomainRedirection: Handler = async (req, res, next) => {
|
export const customDomainRedirection: Handler = async (req, res, next) => {
|
||||||
const { headers, path } = req;
|
const { headers, path } = req;
|
||||||
if (
|
if (
|
||||||
headers.host !== process.env.DEFAULT_DOMAIN &&
|
headers.host !== env.DEFAULT_DOMAIN &&
|
||||||
(path === "/" ||
|
(path === "/" ||
|
||||||
preservedUrls
|
preservedUrls
|
||||||
.filter(l => l !== "url-password")
|
.filter(l => l !== "url-password")
|
||||||
|
@ -269,8 +269,7 @@ export const customDomainRedirection: Handler = async (req, res, next) => {
|
||||||
const domain = await getDomain({ address: headers.host });
|
const domain = await getDomain({ address: headers.host });
|
||||||
return res.redirect(
|
return res.redirect(
|
||||||
301,
|
301,
|
||||||
(domain && domain.homepage) ||
|
(domain && domain.homepage) || `https://${env.DEFAULT_DOMAIN + path}`
|
||||||
`https://${process.env.DEFAULT_DOMAIN + path}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return next();
|
return next();
|
||||||
|
@ -285,7 +284,7 @@ export const deleteUserLink: Handler = async (req, res) => {
|
||||||
|
|
||||||
const response = await deleteLink({
|
const response = await deleteLink({
|
||||||
address: id,
|
address: id,
|
||||||
domain: !domain || domain === process.env.DEFAULT_DOMAIN ? null : domain,
|
domain: !domain || domain === env.DEFAULT_DOMAIN ? null : domain,
|
||||||
user_id: req.user.id
|
user_id: req.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -302,8 +301,7 @@ export const getLinkStats: Handler = async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { hostname } = URL.parse(req.query.domain);
|
const { hostname } = URL.parse(req.query.domain);
|
||||||
const hasCustomDomain =
|
const hasCustomDomain = req.query.domain && hostname !== env.DEFAULT_DOMAIN;
|
||||||
req.query.domain && hostname !== process.env.DEFAULT_DOMAIN;
|
|
||||||
const customDomain = hasCustomDomain
|
const customDomain = hasCustomDomain
|
||||||
? (await getDomain({ address: req.query.domain })) || ({ id: -1 } as Domain)
|
? (await getDomain({ address: req.query.domain })) || ({ id: -1 } as Domain)
|
||||||
: ({} as Domain);
|
: ({} as Domain);
|
||||||
|
@ -341,15 +339,15 @@ export const reportLink: Handler = async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { hostname } = URL.parse(req.body.link);
|
const { hostname } = URL.parse(req.body.link);
|
||||||
if (hostname !== process.env.DEFAULT_DOMAIN) {
|
if (hostname !== env.DEFAULT_DOMAIN) {
|
||||||
return res.status(400).json({
|
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({
|
const mail = await transporter.sendMail({
|
||||||
from: process.env.MAIL_USER,
|
from: env.MAIL_USER,
|
||||||
to: process.env.REPORT_MAIL,
|
to: env.REPORT_MAIL,
|
||||||
subject: "[REPORT]",
|
subject: "[REPORT]",
|
||||||
text: req.body.link,
|
text: req.body.link,
|
||||||
html: req.body.link
|
html: req.body.link
|
|
@ -1,19 +1,20 @@
|
||||||
import { RequestHandler } from "express";
|
|
||||||
import { promisify } from "util";
|
|
||||||
import dns from "dns";
|
|
||||||
import axios from "axios";
|
|
||||||
import URL from "url";
|
|
||||||
import urlRegex from "url-regex";
|
|
||||||
import { body } from "express-validator";
|
|
||||||
import { differenceInMinutes, subHours, subDays, isAfter } from "date-fns";
|
import { differenceInMinutes, subHours, subDays, isAfter } from "date-fns";
|
||||||
import { validationResult } from "express-validator";
|
import { validationResult } from "express-validator";
|
||||||
|
import { body } from "express-validator";
|
||||||
|
import { RequestHandler } from "express";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import urlRegex from "url-regex";
|
||||||
|
import axios from "axios";
|
||||||
|
import dns from "dns";
|
||||||
|
import URL from "url";
|
||||||
|
|
||||||
|
import { addProtocol, CustomError } from "../../utils";
|
||||||
import { addCooldown, banUser } from "../db/user";
|
import { addCooldown, banUser } from "../db/user";
|
||||||
import { getIP } from "../db/ip";
|
|
||||||
import { getUserLinksCount } from "../db/link";
|
import { getUserLinksCount } from "../db/link";
|
||||||
import { getDomain } from "../db/domain";
|
import { getDomain } from "../db/domain";
|
||||||
import { getHost } from "../db/host";
|
import { getHost } from "../db/host";
|
||||||
import { addProtocol, CustomError } from "../utils";
|
import { getIP } from "../db/ip";
|
||||||
|
import env from "../../env";
|
||||||
|
|
||||||
const dnsLookup = promisify(dns.lookup);
|
const dnsLookup = promisify(dns.lookup);
|
||||||
|
|
||||||
|
@ -59,6 +60,7 @@ export const preservedUrls = [
|
||||||
"banned",
|
"banned",
|
||||||
"terms",
|
"terms",
|
||||||
"privacy",
|
"privacy",
|
||||||
|
"protected",
|
||||||
"report",
|
"report",
|
||||||
"pricing"
|
"pricing"
|
||||||
];
|
];
|
||||||
|
@ -82,10 +84,10 @@ export const validateUrl: RequestHandler = async (req, res, next) => {
|
||||||
|
|
||||||
// If target is the URL shortener itself
|
// If target is the URL shortener itself
|
||||||
const { host } = URL.parse(addProtocol(req.body.target));
|
const { host } = URL.parse(addProtocol(req.body.target));
|
||||||
if (host === process.env.DEFAULT_DOMAIN) {
|
if (host === env.DEFAULT_DOMAIN) {
|
||||||
return res
|
return res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: `${process.env.DEFAULT_DOMAIN} URLs are not allowed.` });
|
.json({ error: `${env.DEFAULT_DOMAIN} URLs are not allowed.` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate password length
|
// Validate password length
|
||||||
|
@ -134,7 +136,7 @@ export const cooldownCheck = async (user: User) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ipCooldownCheck: RequestHandler = async (req, res, next) => {
|
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();
|
if (req.user || !cooldownConfig) return next();
|
||||||
const ip = await getIP(req.realIP);
|
const ip = await getIP(req.realIP);
|
||||||
if (ip) {
|
if (ip) {
|
||||||
|
@ -151,10 +153,10 @@ export const ipCooldownCheck: RequestHandler = async (req, res, next) => {
|
||||||
|
|
||||||
export const malwareCheck = async (user: User, target: string) => {
|
export const malwareCheck = async (user: User, target: string) => {
|
||||||
const isMalware = await axios.post(
|
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: {
|
client: {
|
||||||
clientId: process.env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
|
clientId: env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
|
||||||
clientVersion: "1.0.0"
|
clientVersion: "1.0.0"
|
||||||
},
|
},
|
||||||
threatInfo: {
|
threatInfo: {
|
||||||
|
@ -190,9 +192,9 @@ export const urlCountsCheck = async (user: User) => {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
date: subDays(new Date(), 1)
|
date: subDays(new Date(), 1)
|
||||||
});
|
});
|
||||||
if (count > Number(process.env.USER_LIMIT_PER_DAY)) {
|
if (count > env.USER_LIMIT_PER_DAY) {
|
||||||
throw new CustomError(
|
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.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
|
@ -1,6 +1,6 @@
|
||||||
import knex from "../knex";
|
import knex from "../../knex";
|
||||||
import * as redis from "../redis";
|
import * as redis from "../../redis";
|
||||||
import { getRedisKey } from "../utils";
|
import { getRedisKey } from "../../utils";
|
||||||
|
|
||||||
export const getDomain = async (data: Partial<Domain>): Promise<Domain> => {
|
export const getDomain = async (data: Partial<Domain>): Promise<Domain> => {
|
||||||
const getData = {
|
const getData = {
|
|
@ -1,6 +1,6 @@
|
||||||
import knex from "../knex";
|
import knex from "../../knex";
|
||||||
import * as redis from "../redis";
|
import * as redis from "../../redis";
|
||||||
import { getRedisKey } from "../utils";
|
import { getRedisKey } from "../../utils";
|
||||||
|
|
||||||
export const getHost = async (data: Partial<Host>) => {
|
export const getHost = async (data: Partial<Host>) => {
|
||||||
const getData = {
|
const getData = {
|
|
@ -1,6 +1,7 @@
|
||||||
import { subMinutes } from "date-fns";
|
import { subMinutes } from "date-fns";
|
||||||
|
|
||||||
import knex from "../knex";
|
import knex from "../../knex";
|
||||||
|
import env from "../../env";
|
||||||
|
|
||||||
export const addIP = async (ipToGet: string) => {
|
export const addIP = async (ipToGet: string) => {
|
||||||
const ip = ipToGet.toLowerCase();
|
const ip = ipToGet.toLowerCase();
|
||||||
|
@ -24,7 +25,7 @@ export const addIP = async (ipToGet: string) => {
|
||||||
return ip;
|
return ip;
|
||||||
};
|
};
|
||||||
export const getIP = async (ip: string) => {
|
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")
|
const matchedIp = await knex<IP>("ips")
|
||||||
.where({ ip: ip.toLowerCase() })
|
.where({ ip: ip.toLowerCase() })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
|
@ -41,9 +42,6 @@ export const clearIPs = async () =>
|
||||||
.where(
|
.where(
|
||||||
"created_at",
|
"created_at",
|
||||||
"<",
|
"<",
|
||||||
subMinutes(
|
subMinutes(new Date(), env.NON_USER_COOLDOWN).toISOString()
|
||||||
new Date(),
|
|
||||||
Number(process.env.NON_USER_COOLDOWN)
|
|
||||||
).toISOString()
|
|
||||||
)
|
)
|
||||||
.delete();
|
.delete();
|
|
@ -1,14 +1,14 @@
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { isAfter, subDays, set } from "date-fns";
|
import { isAfter, subDays, set } from "date-fns";
|
||||||
import knex from "../knex";
|
import knex from "../../knex";
|
||||||
import * as redis from "../redis";
|
import * as redis from "../../redis";
|
||||||
import {
|
import {
|
||||||
generateShortLink,
|
generateShortLink,
|
||||||
getRedisKey,
|
getRedisKey,
|
||||||
getUTCDate,
|
getUTCDate,
|
||||||
getDifferenceFunction,
|
getDifferenceFunction,
|
||||||
statsObjectToArray
|
statsObjectToArray
|
||||||
} from "../utils";
|
} from "../../utils";
|
||||||
import { banDomain } from "./domain";
|
import { banDomain } from "./domain";
|
||||||
import { banHost } from "./host";
|
import { banHost } from "./host";
|
||||||
import { banUser } from "./user";
|
import { banUser } from "./user";
|
|
@ -3,9 +3,9 @@ import nanoid from "nanoid";
|
||||||
import uuid from "uuid/v4";
|
import uuid from "uuid/v4";
|
||||||
import { addMinutes } from "date-fns";
|
import { addMinutes } from "date-fns";
|
||||||
|
|
||||||
import knex from "../knex";
|
import knex from "../../knex";
|
||||||
import * as redis from "../redis";
|
import * as redis from "../../redis";
|
||||||
import { getRedisKey } from "../utils";
|
import { getRedisKey } from "../../utils";
|
||||||
|
|
||||||
export const getUser = async (emailOrKey = ""): Promise<User> => {
|
export const getUser = async (emailOrKey = ""): Promise<User> => {
|
||||||
const redisKey = getRedisKey.user(emailOrKey);
|
const redisKey = getRedisKey.user(emailOrKey);
|
|
@ -0,0 +1,63 @@
|
||||||
|
import asyncHandler from "express-async-handler";
|
||||||
|
import { Router } from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
|
||||||
|
import {
|
||||||
|
validateUrl,
|
||||||
|
ipCooldownCheck
|
||||||
|
} from "./controllers/validateBodyController";
|
||||||
|
import * as auth from "../handlers/auth";
|
||||||
|
import * as link from "./controllers/linkController";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/* URL shortener */
|
||||||
|
router.post(
|
||||||
|
"url/submit",
|
||||||
|
cors(),
|
||||||
|
asyncHandler(auth.apikey),
|
||||||
|
asyncHandler(auth.jwtLoose),
|
||||||
|
asyncHandler(auth.recaptcha),
|
||||||
|
asyncHandler(validateUrl),
|
||||||
|
asyncHandler(ipCooldownCheck),
|
||||||
|
asyncHandler(link.shortener)
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"url/deleteurl",
|
||||||
|
asyncHandler(auth.apikey),
|
||||||
|
asyncHandler(auth.jwt),
|
||||||
|
asyncHandler(link.deleteUserLink)
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"url/geturls",
|
||||||
|
asyncHandler(auth.apikey),
|
||||||
|
asyncHandler(auth.jwt),
|
||||||
|
asyncHandler(link.getUserLinks)
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"url/customdomain",
|
||||||
|
asyncHandler(auth.jwt),
|
||||||
|
asyncHandler(link.setCustomDomain)
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"url/customdomain",
|
||||||
|
asyncHandler(auth.jwt),
|
||||||
|
asyncHandler(link.deleteCustomDomain)
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"url/stats",
|
||||||
|
asyncHandler(auth.apikey),
|
||||||
|
asyncHandler(auth.jwt),
|
||||||
|
asyncHandler(link.getLinkStats)
|
||||||
|
);
|
||||||
|
router.post("url/requesturl", asyncHandler(link.goToLink));
|
||||||
|
router.post("url/report", asyncHandler(link.reportLink));
|
||||||
|
router.post(
|
||||||
|
"url/admin/ban",
|
||||||
|
asyncHandler(auth.apikey),
|
||||||
|
asyncHandler(auth.jwt),
|
||||||
|
asyncHandler(auth.admin),
|
||||||
|
asyncHandler(link.ban)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
|
@ -1,62 +0,0 @@
|
||||||
/* eslint-disable global-require */
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
const hasServerConfig = fs.existsSync(path.resolve(__dirname, "config.js"));
|
|
||||||
const hasClientConfig = fs.existsSync(
|
|
||||||
path.resolve(__dirname, "../client/config.js")
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasServerConfig && hasClientConfig) {
|
|
||||||
const serverConfig = require("./config.js");
|
|
||||||
const clientConfig = require("../client/config.js");
|
|
||||||
let envTemplate = fs.readFileSync(
|
|
||||||
path.resolve(__dirname, "../.template.env"),
|
|
||||||
"utf-8"
|
|
||||||
);
|
|
||||||
|
|
||||||
const configs = {
|
|
||||||
PORT: serverConfig.PORT || 3000,
|
|
||||||
DEFAULT_DOMAIN: serverConfig.DEFAULT_DOMAIN || "localhost:3000",
|
|
||||||
DB_URI: serverConfig.DB_URI || "bolt://localhost",
|
|
||||||
DB_USERNAME: serverConfig.DB_USERNAME,
|
|
||||||
DB_PASSWORD: serverConfig.DB_PASSWORD,
|
|
||||||
REDIS_DISABLED: serverConfig.REDIS_DISABLED || false,
|
|
||||||
REDIS_HOST: serverConfig.REDIS_HOST || "127.0.0.1",
|
|
||||||
REDIS_PORT: serverConfig.REDIS_PORT || 6379,
|
|
||||||
REDIS_PASSWORD: serverConfig.REDIS_PASSWORD,
|
|
||||||
USER_LIMIT_PER_DAY: serverConfig.USER_LIMIT_PER_DAY || 50,
|
|
||||||
JWT_SECRET: serverConfig.JWT_SECRET || "securekey",
|
|
||||||
ADMIN_EMAILS: serverConfig.ADMIN_EMAILS.join(","),
|
|
||||||
RECAPTCHA_SITE_KEY: clientConfig.RECAPTCHA_SITE_KEY,
|
|
||||||
RECAPTCHA_SECRET_KEY: serverConfig.RECAPTCHA_SECRET_KEY,
|
|
||||||
GOOGLE_SAFE_BROWSING_KEY: serverConfig.GOOGLE_SAFE_BROWSING_KEY,
|
|
||||||
GOOGLE_ANALYTICS: clientConfig.GOOGLE_ANALYTICS_ID,
|
|
||||||
GOOGLE_ANALYTICS_UNIVERSAL: serverConfig.GOOGLE_ANALYTICS,
|
|
||||||
MAIL_HOST: serverConfig.MAIL_HOST,
|
|
||||||
MAIL_PORT: serverConfig.MAIL_PORT,
|
|
||||||
MAIL_SECURE: serverConfig.MAIL_SECURE,
|
|
||||||
MAIL_USER: serverConfig.MAIL_USER,
|
|
||||||
MAIL_FROM: serverConfig.MAIL_FROM,
|
|
||||||
MAIL_PASSWORD: serverConfig.MAIL_PASSWORD,
|
|
||||||
REPORT_MAIL: serverConfig.REPORT_MAIL,
|
|
||||||
CONTACT_EMAIL: clientConfig.CONTACT_EMAIL
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.keys(configs).forEach(c => {
|
|
||||||
envTemplate = envTemplate.replace(
|
|
||||||
new RegExp(`{{${c}}}`, "gm"),
|
|
||||||
configs[c] || ""
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.writeFileSync(path.resolve(__dirname, "../.env"), envTemplate);
|
|
||||||
fs.renameSync(
|
|
||||||
path.resolve(__dirname, "config.js"),
|
|
||||||
path.resolve(__dirname, "old.config.js")
|
|
||||||
);
|
|
||||||
fs.renameSync(
|
|
||||||
path.resolve(__dirname, "../client/config.js"),
|
|
||||||
path.resolve(__dirname, "../client/old.config.js")
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,274 +0,0 @@
|
||||||
import { Handler } from "express";
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import passport from "passport";
|
|
||||||
import JWT from "jsonwebtoken";
|
|
||||||
import axios from "axios";
|
|
||||||
import { addDays } from "date-fns";
|
|
||||||
|
|
||||||
import { isAdmin } from "../utils";
|
|
||||||
import transporter from "../mail/mail";
|
|
||||||
import { resetMailText, verifyMailText } from "../mail/text";
|
|
||||||
import {
|
|
||||||
createUser,
|
|
||||||
changePassword,
|
|
||||||
generateApiKey,
|
|
||||||
getUser,
|
|
||||||
verifyUser,
|
|
||||||
requestPasswordReset,
|
|
||||||
resetPassword
|
|
||||||
} from "../db/user";
|
|
||||||
|
|
||||||
/* Read email template */
|
|
||||||
const resetEmailTemplatePath = path.join(
|
|
||||||
__dirname,
|
|
||||||
"../mail/template-reset.html"
|
|
||||||
);
|
|
||||||
const verifyEmailTemplatePath = path.join(
|
|
||||||
__dirname,
|
|
||||||
"../mail/template-verify.html"
|
|
||||||
);
|
|
||||||
const resetEmailTemplate = fs
|
|
||||||
.readFileSync(resetEmailTemplatePath, { encoding: "utf-8" })
|
|
||||||
.replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN);
|
|
||||||
const verifyEmailTemplate = fs
|
|
||||||
.readFileSync(verifyEmailTemplatePath, { encoding: "utf-8" })
|
|
||||||
.replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN);
|
|
||||||
|
|
||||||
/* Function to generate JWT */
|
|
||||||
const signToken = (user: UserJoined) =>
|
|
||||||
JWT.sign(
|
|
||||||
{
|
|
||||||
iss: "ApiAuth",
|
|
||||||
sub: user.email,
|
|
||||||
domain: user.domain || "",
|
|
||||||
admin: isAdmin(user.email),
|
|
||||||
iat: parseInt((new Date().getTime() / 1000).toFixed(0)),
|
|
||||||
exp: parseInt((addDays(new Date(), 7).getTime() / 1000).toFixed(0))
|
|
||||||
} as Record<string, any>,
|
|
||||||
process.env.JWT_SECRET
|
|
||||||
);
|
|
||||||
|
|
||||||
/* Passport.js authentication controller */
|
|
||||||
const authenticate = (
|
|
||||||
type: "jwt" | "local" | "localapikey",
|
|
||||||
error: string,
|
|
||||||
isStrict = true
|
|
||||||
) =>
|
|
||||||
function auth(req, res, next) {
|
|
||||||
if (req.user) return next();
|
|
||||||
return passport.authenticate(type, (err, user) => {
|
|
||||||
if (err) return res.status(400);
|
|
||||||
if (!user && isStrict) return res.status(401).json({ error });
|
|
||||||
if (user && isStrict && !user.verified) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error:
|
|
||||||
"Your email address is not verified. " +
|
|
||||||
"Click on signup to get the verification link again."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (user && user.banned) {
|
|
||||||
return res
|
|
||||||
.status(403)
|
|
||||||
.json({ error: "Your are banned from using this website." });
|
|
||||||
}
|
|
||||||
if (user) {
|
|
||||||
req.user = {
|
|
||||||
...user,
|
|
||||||
admin: isAdmin(user.email)
|
|
||||||
};
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
return next();
|
|
||||||
})(req, res, next);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const authLocal = authenticate("local", "Login credentials are wrong.");
|
|
||||||
export const authJwt = authenticate("jwt", "Unauthorized.");
|
|
||||||
export const authJwtLoose = authenticate("jwt", "Unauthorized.", false);
|
|
||||||
export const authApikey = authenticate(
|
|
||||||
"localapikey",
|
|
||||||
"API key is not correct.",
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
/* reCaptcha controller */
|
|
||||||
export const recaptcha: Handler = async (req, res, next) => {
|
|
||||||
if (process.env.NODE_ENV === "production" && !req.user) {
|
|
||||||
const isReCaptchaValid = await axios({
|
|
||||||
method: "post",
|
|
||||||
url: "https://www.google.com/recaptcha/api/siteverify",
|
|
||||||
headers: {
|
|
||||||
"Content-type": "application/x-www-form-urlencoded"
|
|
||||||
},
|
|
||||||
params: {
|
|
||||||
secret: process.env.RECAPTCHA_SECRET_KEY,
|
|
||||||
response: req.body.reCaptchaToken,
|
|
||||||
remoteip: req.realIP
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!isReCaptchaValid.data.success) {
|
|
||||||
return res
|
|
||||||
.status(401)
|
|
||||||
.json({ error: "reCAPTCHA is not valid. Try again." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const authAdmin: Handler = async (req, res, next) => {
|
|
||||||
if (!req.user.admin) {
|
|
||||||
return res.status(401).json({ error: "Unauthorized." });
|
|
||||||
}
|
|
||||||
return next();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const signup: Handler = async (req, res) => {
|
|
||||||
const { email, password } = req.body;
|
|
||||||
|
|
||||||
if (password.length > 64) {
|
|
||||||
return res.status(400).json({ error: "Maximum password length is 64." });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (email.length > 255) {
|
|
||||||
return res.status(400).json({ error: "Maximum email length is 255." });
|
|
||||||
}
|
|
||||||
const user = await getUser(email);
|
|
||||||
|
|
||||||
if (user && user.verified) {
|
|
||||||
return res.status(403).json({ error: "Email is already in use." });
|
|
||||||
}
|
|
||||||
|
|
||||||
const newUser = await createUser(email, password, user);
|
|
||||||
|
|
||||||
const mail = await transporter.sendMail({
|
|
||||||
from: process.env.MAIL_FROM || process.env.MAIL_USER,
|
|
||||||
to: newUser.email,
|
|
||||||
subject: "Verify your account",
|
|
||||||
text: verifyMailText.replace(
|
|
||||||
/{{verification}}/gim,
|
|
||||||
newUser.verification_token
|
|
||||||
),
|
|
||||||
html: verifyEmailTemplate.replace(
|
|
||||||
/{{verification}}/gim,
|
|
||||||
newUser.verification_token
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mail.accepted.length) {
|
|
||||||
return res
|
|
||||||
.status(201)
|
|
||||||
.json({ email, message: "Verification email has been sent." });
|
|
||||||
}
|
|
||||||
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "Couldn't send verification email. Try again." });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const login: Handler = (req, res) => {
|
|
||||||
const token = signToken(req.user);
|
|
||||||
return res.status(200).json({ token });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const renew: Handler = (req, res) => {
|
|
||||||
const token = signToken(req.user);
|
|
||||||
return res.status(200).json({ token });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const verify: Handler = async (req, _res, next) => {
|
|
||||||
const { verificationToken } = req.params;
|
|
||||||
if (!verificationToken) return next();
|
|
||||||
|
|
||||||
const user = await verifyUser(req.params.verificationToken);
|
|
||||||
if (user) {
|
|
||||||
const token = signToken(user);
|
|
||||||
req.token = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
return next();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const changeUserPassword: Handler = async (req, res) => {
|
|
||||||
if (req.body.password.length < 8) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "Password must be at least 8 chars long." });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.body.password.length > 64) {
|
|
||||||
return res.status(400).json({ error: "Maximum password length is 64." });
|
|
||||||
}
|
|
||||||
|
|
||||||
const changedUser = await changePassword(req.user.id, req.body.password);
|
|
||||||
|
|
||||||
if (changedUser) {
|
|
||||||
return res
|
|
||||||
.status(200)
|
|
||||||
.json({ message: "Your password has been changed successfully." });
|
|
||||||
}
|
|
||||||
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "Couldn't change the password. Try again later" });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const generateUserApiKey = async (req, res) => {
|
|
||||||
const apikey = await generateApiKey(req.user.id);
|
|
||||||
|
|
||||||
if (apikey) {
|
|
||||||
return res.status(201).json({ apikey });
|
|
||||||
}
|
|
||||||
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "Sorry, an error occured. Please try again later." });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const userSettings: Handler = (req, res) =>
|
|
||||||
res.status(200).json({
|
|
||||||
apikey: req.user.apikey || "",
|
|
||||||
customDomain: req.user.domain || "",
|
|
||||||
homepage: req.user.homepage || ""
|
|
||||||
});
|
|
||||||
|
|
||||||
export const requestUserPasswordReset: Handler = async (req, res) => {
|
|
||||||
const user = await requestPasswordReset(req.body.email);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return res.status(400).json({ error: "Couldn't reset password." });
|
|
||||||
}
|
|
||||||
|
|
||||||
const mail = await transporter.sendMail({
|
|
||||||
from: process.env.MAIL_USER,
|
|
||||||
to: user.email,
|
|
||||||
subject: "Reset your password",
|
|
||||||
text: resetMailText
|
|
||||||
.replace(/{{resetpassword}}/gm, user.reset_password_token)
|
|
||||||
.replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN),
|
|
||||||
html: resetEmailTemplate
|
|
||||||
.replace(/{{resetpassword}}/gm, user.reset_password_token)
|
|
||||||
.replace(/{{domain}}/gm, process.env.DEFAULT_DOMAIN)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mail.accepted.length) {
|
|
||||||
return res.status(200).json({
|
|
||||||
email: user.email,
|
|
||||||
message: "Reset password email has been sent."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(400).json({ error: "Couldn't reset password." });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const resetUserPassword: Handler = async (req, _res, next) => {
|
|
||||||
const { resetPasswordToken } = req.params;
|
|
||||||
if (resetPasswordToken) {
|
|
||||||
const user: UserJoined = await resetPassword(resetPasswordToken);
|
|
||||||
if (user) {
|
|
||||||
const token = signToken(user as UserJoined);
|
|
||||||
req.token = token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next();
|
|
||||||
};
|
|
|
@ -1,9 +1,10 @@
|
||||||
import cron from "node-cron";
|
import cron from "node-cron";
|
||||||
|
|
||||||
import { clearIPs } from "./db/ip";
|
import query from "./queries";
|
||||||
|
import env from "./env";
|
||||||
|
|
||||||
if (Number(process.env.NON_USER_COOLDOWN)) {
|
if (env.NON_USER_COOLDOWN) {
|
||||||
cron.schedule("* */24 * * *", () => {
|
cron.schedule("* */24 * * *", () => {
|
||||||
clearIPs().catch();
|
query.ip.clear().catch();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { cleanEnv, num, str, bool } from "envalid";
|
||||||
|
|
||||||
|
const env = cleanEnv(process.env, {
|
||||||
|
PORT: num({ default: 3000 }),
|
||||||
|
DEFAULT_DOMAIN: str({ example: "kutt.it" }),
|
||||||
|
LINK_LENGTH: num({ default: 6 }),
|
||||||
|
DB_HOST: str({ default: "localhost" }),
|
||||||
|
DB_PORT: num({ default: 5432 }),
|
||||||
|
DB_NAME: str({ default: "postgres" }),
|
||||||
|
DB_USER: str(),
|
||||||
|
DB_PASSWORD: str(),
|
||||||
|
DB_SSL: bool({ default: false }),
|
||||||
|
NEO4J_DB_URI: str({ default: "" }),
|
||||||
|
NEO4J_DB_USERNAME: str({ default: "" }),
|
||||||
|
NEO4J_DB_PASSWORD: str({ default: "" }),
|
||||||
|
REDIS_HOST: str({ default: "127.0.0.1" }),
|
||||||
|
REDIS_PORT: num({ default: 6379 }),
|
||||||
|
REDIS_PASSWORD: str({ default: "" }),
|
||||||
|
USER_LIMIT_PER_DAY: num({ default: 50 }),
|
||||||
|
NON_USER_COOLDOWN: num({ default: 10 }),
|
||||||
|
DEFAULT_MAX_STATS_PER_LINK: num({ default: 5000 }),
|
||||||
|
CUSTOM_DOMAIN_USE_HTTPS: bool({ default: false }),
|
||||||
|
JWT_SECRET: str(),
|
||||||
|
ADMIN_EMAILS: str({ default: "" }),
|
||||||
|
RECAPTCHA_SITE_KEY: str(),
|
||||||
|
RECAPTCHA_SECRET_KEY: str(),
|
||||||
|
GOOGLE_SAFE_BROWSING_KEY: str({ default: "" }),
|
||||||
|
GOOGLE_ANALYTICS: str({ default: "" }),
|
||||||
|
GOOGLE_ANALYTICS_UNIVERSAL: str({ default: "" }),
|
||||||
|
MAIL_HOST: str(),
|
||||||
|
MAIL_PORT: num(),
|
||||||
|
MAIL_SECURE: bool({ default: false }),
|
||||||
|
MAIL_USER: str(),
|
||||||
|
MAIL_FROM: str({ default: "", example: "Kutt <support@kutt.it>" }),
|
||||||
|
MAIL_PASSWORD: str(),
|
||||||
|
REPORT_EMAIL: str({ default: "" }),
|
||||||
|
CONTACT_EMAIL: str({ default: "" }),
|
||||||
|
RAVEN_DSN: str({ default: "" })
|
||||||
|
});
|
||||||
|
|
||||||
|
export default env;
|
|
@ -1,10 +1,17 @@
|
||||||
import { differenceInMinutes, subMinutes } from "date-fns";
|
import { differenceInMinutes, addMinutes, subMinutes } from "date-fns";
|
||||||
import { Handler } from "express";
|
import { Handler } from "express";
|
||||||
import passport from "passport";
|
import passport from "passport";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import nanoid from "nanoid";
|
||||||
|
import uuid from "uuid/v4";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
import { isAdmin, CustomError } from "../utils";
|
import { CustomError } from "../utils";
|
||||||
|
import * as utils from "../utils";
|
||||||
|
import * as mail from "../mail";
|
||||||
|
import query from "../queries";
|
||||||
import knex from "../knex";
|
import knex from "../knex";
|
||||||
|
import env from "../env";
|
||||||
|
|
||||||
const authenticate = (
|
const authenticate = (
|
||||||
type: "jwt" | "local" | "localapikey",
|
type: "jwt" | "local" | "localapikey",
|
||||||
|
@ -14,10 +21,8 @@ const authenticate = (
|
||||||
async function auth(req, res, next) {
|
async function auth(req, res, next) {
|
||||||
if (req.user) return next();
|
if (req.user) return next();
|
||||||
|
|
||||||
return passport.authenticate(type, (err, user) => {
|
passport.authenticate(type, (err, user) => {
|
||||||
if (err) {
|
if (err) return next(err);
|
||||||
throw new CustomError("An error occurred");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user && isStrict) {
|
if (!user && isStrict) {
|
||||||
throw new CustomError(error, 401);
|
throw new CustomError(error, 401);
|
||||||
|
@ -38,7 +43,7 @@ const authenticate = (
|
||||||
if (user) {
|
if (user) {
|
||||||
req.user = {
|
req.user = {
|
||||||
...user,
|
...user,
|
||||||
admin: isAdmin(user.email)
|
admin: utils.isAdmin(user.email)
|
||||||
};
|
};
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
@ -56,7 +61,7 @@ export const apikey = authenticate(
|
||||||
);
|
);
|
||||||
|
|
||||||
export const cooldown: Handler = async (req, res, next) => {
|
export const cooldown: Handler = async (req, res, next) => {
|
||||||
const cooldownConfig = Number(process.env.NON_USER_COOLDOWN);
|
const cooldownConfig = env.NON_USER_COOLDOWN;
|
||||||
if (req.user || !cooldownConfig) return next();
|
if (req.user || !cooldownConfig) return next();
|
||||||
|
|
||||||
const ip = await knex<IP>("ips")
|
const ip = await knex<IP>("ips")
|
||||||
|
@ -80,8 +85,7 @@ export const cooldown: Handler = async (req, res, next) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const recaptcha: Handler = async (req, res, next) => {
|
export const recaptcha: Handler = async (req, res, next) => {
|
||||||
if (process.env.NODE_ENV !== "production") return next();
|
if (env.isDev || req.user) return next();
|
||||||
if (req.user) return next();
|
|
||||||
|
|
||||||
const isReCaptchaValid = await axios({
|
const isReCaptchaValid = await axios({
|
||||||
method: "post",
|
method: "post",
|
||||||
|
@ -90,7 +94,7 @@ export const recaptcha: Handler = async (req, res, next) => {
|
||||||
"Content-type": "application/x-www-form-urlencoded"
|
"Content-type": "application/x-www-form-urlencoded"
|
||||||
},
|
},
|
||||||
params: {
|
params: {
|
||||||
secret: process.env.RECAPTCHA_SECRET_KEY,
|
secret: env.RECAPTCHA_SECRET_KEY,
|
||||||
response: req.body.reCaptchaToken,
|
response: req.body.reCaptchaToken,
|
||||||
remoteip: req.realIP
|
remoteip: req.realIP
|
||||||
}
|
}
|
||||||
|
@ -102,3 +106,115 @@ export const recaptcha: Handler = async (req, res, next) => {
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const admin: Handler = async (req, res, next) => {
|
||||||
|
if (req.user.admin) return next();
|
||||||
|
throw new CustomError("Unauthorized", 401);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const signup: Handler = async (req, res) => {
|
||||||
|
const salt = await bcrypt.genSalt(12);
|
||||||
|
const password = await bcrypt.hash(req.body.password, salt);
|
||||||
|
|
||||||
|
const user = await query.user.add(
|
||||||
|
{ email: req.body.email, password },
|
||||||
|
req.user
|
||||||
|
);
|
||||||
|
|
||||||
|
await mail.verification(user);
|
||||||
|
|
||||||
|
return res.status(201).send({ message: "Verification email has been sent." });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const token: Handler = async (req, res) => {
|
||||||
|
const token = utils.signToken(req.user);
|
||||||
|
return res.status(200).send({ token });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verify: Handler = async (req, res, next) => {
|
||||||
|
if (!req.params.verificationToken) return next();
|
||||||
|
|
||||||
|
const [user] = await query.user.update(
|
||||||
|
{
|
||||||
|
verification_token: req.params.verificationToken,
|
||||||
|
verification_expires: [">", new Date().toISOString()]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
verified: true,
|
||||||
|
verification_token: null,
|
||||||
|
verification_expires: null
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const token = utils.signToken(user);
|
||||||
|
req.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changePassword: Handler = async (req, res) => {
|
||||||
|
const salt = await bcrypt.genSalt(12);
|
||||||
|
const password = await bcrypt.hash(req.body.password, salt);
|
||||||
|
|
||||||
|
const [user] = await query.user.update({ id: req.user.id }, { password });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new CustomError("Couldn't change the password. Try again later.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
.status(200)
|
||||||
|
.send({ message: "Your password has been changed successfully." });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateApiKey = async (req, res) => {
|
||||||
|
const apikey = nanoid(40);
|
||||||
|
|
||||||
|
const [user] = await query.user.update({ id: req.user.id }, { apikey });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new CustomError("Couldn't generate API key. Please try again later.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(201).send({ apikey });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetPasswordRequest = async (req, res) => {
|
||||||
|
const [user] = await query.user.update(
|
||||||
|
{ email: req.body.email },
|
||||||
|
{
|
||||||
|
reset_password_token: uuid(),
|
||||||
|
reset_password_expires: addMinutes(new Date(), 30).toISOString()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
await mail.resetPasswordToken(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
error: "If email address exists, a reset password email has been sent."
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetPassword = async (req, res, next) => {
|
||||||
|
const { resetPasswordToken } = req.params;
|
||||||
|
|
||||||
|
if (resetPasswordToken) {
|
||||||
|
const [user] = await query.user.update(
|
||||||
|
{
|
||||||
|
reset_password_token: resetPasswordToken,
|
||||||
|
reset_password_expires: [">", new Date().toISOString()]
|
||||||
|
},
|
||||||
|
{ reset_password_expires: null, reset_password_token: null }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const token = utils.signToken(user as UserJoined);
|
||||||
|
req.token = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Handler } from "express";
|
||||||
|
import query from "../queries";
|
||||||
|
import { CustomError, sanitize } from "../utils";
|
||||||
|
|
||||||
|
export const add: Handler = async (req, res) => {
|
||||||
|
const { address, homepage } = req.body;
|
||||||
|
|
||||||
|
const domain = await query.domain.add({
|
||||||
|
address,
|
||||||
|
homepage,
|
||||||
|
user_id: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).send(sanitize.domain(domain));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const remove: Handler = async (req, res) => {
|
||||||
|
const [domain] = await query.domain.update(
|
||||||
|
{
|
||||||
|
uuid: req.params.id,
|
||||||
|
user_id: req.user.id
|
||||||
|
},
|
||||||
|
{ user_id: null }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
throw new CustomError("Could not delete the domain.", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send({ message: "Domain deleted successfully" });
|
||||||
|
};
|
|
@ -1,4 +1,43 @@
|
||||||
import { Handler } from "express";
|
import { Handler, ErrorRequestHandler } from "express";
|
||||||
|
import { validationResult } from "express-validator";
|
||||||
|
import Raven from "raven";
|
||||||
|
import signale from "signale";
|
||||||
|
|
||||||
|
import { CustomError } from "../utils";
|
||||||
|
import env from "../env";
|
||||||
|
|
||||||
|
export const ip: Handler = (req, res, next) => {
|
||||||
|
req.realIP =
|
||||||
|
(req.headers["x-real-ip"] as string) || req.connection.remoteAddress || "";
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const error: ErrorRequestHandler = (error, req, res, next) => {
|
||||||
|
if (env.isDev) {
|
||||||
|
signale.fatal(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof CustomError) {
|
||||||
|
return res.status(error.statusCode || 500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.RAVEN_DSN) {
|
||||||
|
Raven.captureException(error, {
|
||||||
|
user: { email: req.user && req.user.email }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).json({ error: "An error occurred." });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verify = (req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
const message = errors.array()[0].msg;
|
||||||
|
throw new CustomError(message, 400);
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
export const query: Handler = (req, res, next) => {
|
export const query: Handler = (req, res, next) => {
|
||||||
const { limit, skip, all } = req.query;
|
const { limit, skip, all } = req.query;
|
||||||
|
|
|
@ -1,37 +1,37 @@
|
||||||
import { Handler, Request } from "express";
|
import ua from "universal-analytics";
|
||||||
|
import { Handler } from "express";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import isbot from "isbot";
|
||||||
|
import next from "next";
|
||||||
import URL from "url";
|
import URL from "url";
|
||||||
|
import dns from "dns";
|
||||||
|
|
||||||
import { generateShortLink, generateId, CustomError } from "../utils";
|
import * as validators from "./validators";
|
||||||
import {
|
import { CreateLinkReq } from "./types";
|
||||||
getLinksQuery,
|
import { CustomError } from "../utils";
|
||||||
getTotalQuery,
|
import transporter from "../mail/mail";
|
||||||
findLinkQuery,
|
import * as utils from "../utils";
|
||||||
createLinkQuery
|
import query from "../queries";
|
||||||
} from "../queries/link";
|
import queue from "../queues";
|
||||||
import {
|
import env from "../env";
|
||||||
cooldownCheck,
|
|
||||||
malwareCheck,
|
|
||||||
urlCountsCheck,
|
|
||||||
checkBannedDomain,
|
|
||||||
checkBannedHost
|
|
||||||
} from "../controllers/validateBodyController";
|
|
||||||
import { addIP } from "../db/ip";
|
|
||||||
|
|
||||||
export const getLinks: Handler = async (req, res) => {
|
const dnsLookup = promisify(dns.lookup);
|
||||||
|
|
||||||
|
export const get: Handler = async (req, res) => {
|
||||||
const { limit, skip, search, all } = req.query;
|
const { limit, skip, search, all } = req.query;
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
const match = {
|
||||||
|
...(!all && { user_id: userId })
|
||||||
|
};
|
||||||
|
|
||||||
const [links, total] = await Promise.all([
|
const [links, total] = await Promise.all([
|
||||||
getLinksQuery({ all, limit, search, skip, userId }),
|
query.link.get(match, { limit, search, skip }),
|
||||||
getTotalQuery({ all, search, userId })
|
query.link.total(match, { search })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const data = links.map(link => ({
|
const data = links.map(utils.sanitize.link);
|
||||||
...link,
|
|
||||||
id: link.uuid,
|
|
||||||
password: !!link.password,
|
|
||||||
link: generateShortLink(link.address, link.domain)
|
|
||||||
}));
|
|
||||||
|
|
||||||
return res.send({
|
return res.send({
|
||||||
total,
|
total,
|
||||||
|
@ -41,80 +41,319 @@ export const getLinks: Handler = async (req, res) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CreateLinkReq extends Request {
|
export const create: Handler = async (req: CreateLinkReq, res) => {
|
||||||
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 { reuse, password, customurl, target, domain } = req.body;
|
||||||
const domainId = domain ? domain.id : null;
|
const domain_id = domain ? domain.id : null;
|
||||||
const domainAddress = domain ? domain.address : null;
|
|
||||||
|
|
||||||
try {
|
const targetDomain = URL.parse(target).hostname;
|
||||||
const targetDomain = URL.parse(target).hostname;
|
|
||||||
|
|
||||||
const queries = await Promise.all([
|
const queries = await Promise.all([
|
||||||
process.env.GOOGLE_SAFE_BROWSING_KEY && cooldownCheck(req.user),
|
validators.cooldown(req.user),
|
||||||
process.env.GOOGLE_SAFE_BROWSING_KEY && malwareCheck(req.user, target),
|
validators.malware(req.user, target),
|
||||||
req.user && urlCountsCheck(req.user),
|
validators.linksCount(req.user),
|
||||||
reuse &&
|
reuse &&
|
||||||
findLinkQuery({
|
query.link.find({
|
||||||
target,
|
target,
|
||||||
userId: req.user.id,
|
user_id: req.user.id,
|
||||||
domainId
|
domain_id
|
||||||
}),
|
}),
|
||||||
customurl &&
|
customurl &&
|
||||||
findLinkQuery({
|
query.link.find({
|
||||||
address: customurl,
|
address: customurl,
|
||||||
domainId
|
user_id: req.user.id,
|
||||||
}),
|
domain_id
|
||||||
!customurl && generateId(domainId),
|
}),
|
||||||
checkBannedDomain(targetDomain),
|
!customurl && utils.generateId(domain_id),
|
||||||
checkBannedHost(targetDomain)
|
validators.bannedDomain(targetDomain),
|
||||||
]);
|
validators.bannedHost(targetDomain)
|
||||||
|
]);
|
||||||
|
|
||||||
// if "reuse" is true, try to return
|
// if "reuse" is true, try to return
|
||||||
// the existent URL without creating one
|
// the existent URL without creating one
|
||||||
if (queries[3]) {
|
if (queries[3]) {
|
||||||
const { domain_id: d, user_id: u, ...currentLink } = queries[3];
|
return res.json(utils.sanitize.link(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
|
// Check if custom link already exists
|
||||||
if (queries[4]) {
|
if (queries[4]) {
|
||||||
throw new CustomError("Custom URL is already in use.");
|
throw new CustomError("Custom URL is already in use.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new link
|
// Create new link
|
||||||
const address = customurl || queries[5];
|
const address = customurl || queries[5];
|
||||||
const link = await createLinkQuery({
|
const link = await query.link.create({
|
||||||
password,
|
password,
|
||||||
address,
|
address,
|
||||||
domainAddress,
|
domain_id,
|
||||||
domainId,
|
target,
|
||||||
target,
|
user_id: req.user && req.user.id
|
||||||
userId: req.user && req.user.id
|
});
|
||||||
|
|
||||||
|
if (!req.user && env.NON_USER_COOLDOWN) {
|
||||||
|
query.ip.add(req.realIP);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
.status(201)
|
||||||
|
.send(utils.sanitize.link({ ...link, domain: domain?.address }));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const remove: Handler = async (req, res) => {
|
||||||
|
const link = await query.link.remove({
|
||||||
|
uuid: req.params.id,
|
||||||
|
...(!req.user.admin && { user_id: req.user.id })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
throw new CustomError("Could not delete the link");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
.status(200)
|
||||||
|
.send({ message: "Link has been deleted successfully." });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const report: Handler = async (req, res) => {
|
||||||
|
const { link } = req.body;
|
||||||
|
|
||||||
|
const mail = await transporter.sendMail({
|
||||||
|
from: env.MAIL_USER,
|
||||||
|
to: env.REPORT_MAIL,
|
||||||
|
subject: "[REPORT]",
|
||||||
|
text: link,
|
||||||
|
html: link
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mail.accepted.length) {
|
||||||
|
throw new CustomError("Couldn't submit the report. Try again later.");
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
.status(200)
|
||||||
|
.send({ message: "Thanks for the report, we'll take actions shortly." });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ban: Handler = async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const update = {
|
||||||
|
banned_by_id: req.user.id,
|
||||||
|
banned: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Check if link exists
|
||||||
|
const link = await query.link.find({ uuid: id });
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
throw new CustomError("No link has been found.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link.banned) {
|
||||||
|
return res.status(200).send({ message: "Link has been banned already." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = [];
|
||||||
|
|
||||||
|
// 2. Ban link
|
||||||
|
tasks.push(query.link.update({ uuid: id }, update));
|
||||||
|
|
||||||
|
const domain = URL.parse(link.target).hostname;
|
||||||
|
|
||||||
|
// 3. Ban target's domain
|
||||||
|
if (req.body.domain) {
|
||||||
|
tasks.push(query.domain.add({ ...update, address: domain }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Ban target's host
|
||||||
|
if (req.body.host) {
|
||||||
|
const dnsRes = await dnsLookup(domain).catch(() => {
|
||||||
|
throw new CustomError("Couldn't fetch DNS info.");
|
||||||
});
|
});
|
||||||
|
const host = dnsRes?.address;
|
||||||
|
tasks.push(query.host.add({ ...update, address: host }));
|
||||||
|
}
|
||||||
|
|
||||||
if (!req.user && Number(process.env.NON_USER_COOLDOWN)) {
|
// 5. Ban link owner
|
||||||
addIP(req.realIP);
|
if (req.body.user) {
|
||||||
}
|
tasks.push(query.user.update({ id: link.user_id }, update));
|
||||||
|
}
|
||||||
|
|
||||||
return res.json({ ...link, id: link.uuid });
|
// 6. Ban all of owner's links
|
||||||
} catch (error) {
|
if (req.body.userLinks) {
|
||||||
return res.status(400).json({ error: error.message });
|
tasks.push(query.link.update({ user_id: link.user_id }, update));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Wait for all tasks to finish
|
||||||
|
await Promise.all(tasks).catch(() => {
|
||||||
|
throw new CustomError("Couldn't ban entries.");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8. Send response
|
||||||
|
return res.status(200).send({ message: "Banned link successfully." });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const redirect = (app: ReturnType<typeof next>): Handler => async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
next
|
||||||
|
) => {
|
||||||
|
const isBot = isbot(req.headers["user-agent"]);
|
||||||
|
const isPreservedUrl = validators.preservedUrls.some(
|
||||||
|
item => item === req.path.replace("/", "")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPreservedUrl) return next();
|
||||||
|
|
||||||
|
// 1. If custom domain, get domain info
|
||||||
|
const { host } = req.headers;
|
||||||
|
const domain =
|
||||||
|
host !== env.DEFAULT_DOMAIN
|
||||||
|
? await query.domain.find({ address: host })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 2. Get link
|
||||||
|
const address = req.params.id.replace("+", "");
|
||||||
|
const link = await query.link.find({
|
||||||
|
address,
|
||||||
|
domain_id: domain && domain.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. When no link, if has domain redirect to domain's homepage
|
||||||
|
// otherwise rediredt to 404
|
||||||
|
if (!link) {
|
||||||
|
return res.redirect(301, domain ? domain.homepage : "/404");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. If link is banned, redirect to banned page.
|
||||||
|
if (link.banned) {
|
||||||
|
return res.redirect("/banned");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. If wants to see link info, then redirect
|
||||||
|
const doesRequestInfo = /.*\+$/gi.test(req.params.id);
|
||||||
|
if (doesRequestInfo && !link.password) {
|
||||||
|
return app.render(req, res, "/url-info", { target: link.target });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. If link is protected, redirect to password page
|
||||||
|
if (link.password) {
|
||||||
|
return res.redirect(`/protected/${link.uuid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Create link visit
|
||||||
|
if (link.user_id && !isBot) {
|
||||||
|
queue.visit.add({
|
||||||
|
headers: req.headers,
|
||||||
|
realIP: req.realIP,
|
||||||
|
referrer: req.get("Referrer"),
|
||||||
|
link
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Create Google Analytics visit
|
||||||
|
if (env.GOOGLE_ANALYTICS_UNIVERSAL && !isBot) {
|
||||||
|
ua(env.GOOGLE_ANALYTICS_UNIVERSAL)
|
||||||
|
.pageview({
|
||||||
|
dp: `/${address}`,
|
||||||
|
ua: req.headers["user-agent"],
|
||||||
|
uip: req.realIP,
|
||||||
|
aip: 1
|
||||||
|
})
|
||||||
|
.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Redirect to target
|
||||||
|
return res.redirect(link.target);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const redirectProtected: Handler = async (req, res) => {
|
||||||
|
// 1. Get link
|
||||||
|
const uuid = req.params.id;
|
||||||
|
const link = await query.link.find({ uuid });
|
||||||
|
|
||||||
|
// 2. Throw error if no link
|
||||||
|
if (!link || !link.password) {
|
||||||
|
throw new CustomError("Couldn't find the link.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check if password matches
|
||||||
|
const matches = await bcrypt.compare(req.body.password, link.password);
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
throw new CustomError("Password is not correct.", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create visit
|
||||||
|
if (link.user_id) {
|
||||||
|
queue.visit.add({
|
||||||
|
headers: req.headers,
|
||||||
|
realIP: req.realIP,
|
||||||
|
referrer: req.get("Referrer"),
|
||||||
|
link
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Create Google Analytics visit
|
||||||
|
if (env.GOOGLE_ANALYTICS_UNIVERSAL) {
|
||||||
|
ua(env.GOOGLE_ANALYTICS_UNIVERSAL)
|
||||||
|
.pageview({
|
||||||
|
dp: `/${link.address}`,
|
||||||
|
ua: req.headers["user-agent"],
|
||||||
|
uip: req.realIP,
|
||||||
|
aip: 1
|
||||||
|
})
|
||||||
|
.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Send target
|
||||||
|
return res.status(200).send({ target: link.target });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const redirectCustomDomain: Handler = async (req, res, next) => {
|
||||||
|
const {
|
||||||
|
headers: { host },
|
||||||
|
path
|
||||||
|
} = req;
|
||||||
|
|
||||||
|
if (host === env.DEFAULT_DOMAIN) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
path === "/" ||
|
||||||
|
validators.preservedUrls
|
||||||
|
.filter(l => l !== "url-password")
|
||||||
|
.some(item => item === path.replace("/", ""))
|
||||||
|
) {
|
||||||
|
const domain = await query.domain.find({ address: host });
|
||||||
|
const redirectURL = domain
|
||||||
|
? domain.homepage
|
||||||
|
: `https://${env.DEFAULT_DOMAIN + path}`;
|
||||||
|
|
||||||
|
return res.redirect(301, redirectURL);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const stats: Handler = async (req, res) => {
|
||||||
|
const { user } = req;
|
||||||
|
const uuid = req.params.id;
|
||||||
|
|
||||||
|
const link = await query.link.find({
|
||||||
|
...(!user.admin && { user_id: user.id }),
|
||||||
|
uuid
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
throw new CustomError("Link could not be found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await query.visit.find({ link_id: link.id }, link.visit_count);
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
throw new CustomError("Could not get the short link stats.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send({
|
||||||
|
...stats,
|
||||||
|
...utils.sanitize.link(link)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
import { sanitizeBody, CustomSanitizer } from "express-validator";
|
|
||||||
import { addProtocol } from "../utils";
|
|
||||||
|
|
||||||
const passIfUser: CustomSanitizer = (value, { req }) =>
|
|
||||||
req.user ? value : undefined;
|
|
||||||
|
|
||||||
export const createLink = [
|
|
||||||
sanitizeBody("target")
|
|
||||||
.trim()
|
|
||||||
.customSanitizer(value => value && addProtocol(value)),
|
|
||||||
sanitizeBody("domain")
|
|
||||||
.customSanitizer(value =>
|
|
||||||
typeof value === "string" ? value.toLowerCase() : undefined
|
|
||||||
)
|
|
||||||
.customSanitizer(passIfUser),
|
|
||||||
sanitizeBody("password").customSanitizer(passIfUser),
|
|
||||||
sanitizeBody("customurl")
|
|
||||||
.customSanitizer(passIfUser)
|
|
||||||
.customSanitizer(value => value && value.trim()),
|
|
||||||
sanitizeBody("reuse").customSanitizer(passIfUser)
|
|
||||||
];
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
export interface CreateLinkReq extends Request {
|
||||||
|
body: {
|
||||||
|
reuse?: boolean;
|
||||||
|
password?: string;
|
||||||
|
customurl?: string;
|
||||||
|
domain?: Domain;
|
||||||
|
target: string;
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import query from "../queries";
|
||||||
|
import * as utils from "../utils";
|
||||||
|
|
||||||
|
export const get = async (req, res) => {
|
||||||
|
const domains = await query.domain.get({ user_id: req.user.id });
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
apikey: req.user.apikey,
|
||||||
|
email: req.user.email,
|
||||||
|
domains: domains.map(utils.sanitize.domain)
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.status(200).send(data);
|
||||||
|
};
|
|
@ -1,18 +1,17 @@
|
||||||
import { body, validationResult } from "express-validator";
|
import { body, param } from "express-validator";
|
||||||
|
import { isAfter, subDays, subHours } from "date-fns";
|
||||||
import urlRegex from "url-regex";
|
import urlRegex from "url-regex";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import axios from "axios";
|
||||||
|
import dns from "dns";
|
||||||
import URL from "url";
|
import URL from "url";
|
||||||
|
|
||||||
import { findDomain } from "../queries/domain";
|
import { CustomError, addProtocol } from "../utils";
|
||||||
import { CustomError } from "../utils";
|
import query from "../queries";
|
||||||
|
import knex from "../knex";
|
||||||
|
import env from "../env";
|
||||||
|
|
||||||
export const verify = (req, res, next) => {
|
const dnsLookup = promisify(dns.lookup);
|
||||||
const errors = validationResult(req);
|
|
||||||
if (!errors.isEmpty()) {
|
|
||||||
const message = errors.array()[0].msg;
|
|
||||||
throw new CustomError(message, 400);
|
|
||||||
}
|
|
||||||
return next();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const preservedUrls = [
|
export const preservedUrls = [
|
||||||
"login",
|
"login",
|
||||||
|
@ -32,53 +31,341 @@ export const preservedUrls = [
|
||||||
"banned",
|
"banned",
|
||||||
"terms",
|
"terms",
|
||||||
"privacy",
|
"privacy",
|
||||||
|
"protected",
|
||||||
"report",
|
"report",
|
||||||
"pricing"
|
"pricing"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const checkUser = (value, { req }) => !!req.user;
|
||||||
|
|
||||||
export const createLink = [
|
export const createLink = [
|
||||||
body("target")
|
body("target")
|
||||||
.exists({ checkNull: true, checkFalsy: true })
|
.exists({ checkNull: true, checkFalsy: true })
|
||||||
.withMessage("Target is missing.")
|
.withMessage("Target is missing.")
|
||||||
|
.isString()
|
||||||
|
.trim()
|
||||||
.isLength({ min: 1, max: 2040 })
|
.isLength({ min: 1, max: 2040 })
|
||||||
.withMessage("Maximum URL length is 2040.")
|
.withMessage("Maximum URL length is 2040.")
|
||||||
|
.customSanitizer(addProtocol)
|
||||||
.custom(
|
.custom(
|
||||||
value =>
|
value =>
|
||||||
urlRegex({ exact: true, strict: false }).test(value) ||
|
urlRegex({ exact: true, strict: false }).test(value) ||
|
||||||
/^(?!https?)(\w+):\/\//.test(value)
|
/^(?!https?)(\w+):\/\//.test(value)
|
||||||
)
|
)
|
||||||
.withMessage("URL is not valid.")
|
.withMessage("URL is not valid.")
|
||||||
.custom(value => URL.parse(value).host !== process.env.DEFAULT_DOMAIN)
|
.custom(value => URL.parse(value).host !== env.DEFAULT_DOMAIN)
|
||||||
.withMessage(`${process.env.DEFAULT_DOMAIN} URLs are not allowed.`),
|
.withMessage(`${env.DEFAULT_DOMAIN} URLs are not allowed.`),
|
||||||
body("password")
|
body("password")
|
||||||
.optional()
|
.optional()
|
||||||
|
.custom(checkUser)
|
||||||
|
.withMessage("Only users can use this field.")
|
||||||
|
.isString()
|
||||||
.isLength({ min: 3, max: 64 })
|
.isLength({ min: 3, max: 64 })
|
||||||
.withMessage("Password length must be between 3 and 64."),
|
.withMessage("Password length must be between 3 and 64."),
|
||||||
body("customurl")
|
body("customurl")
|
||||||
.optional()
|
.optional()
|
||||||
|
.custom(checkUser)
|
||||||
|
.withMessage("Only users can use this field.")
|
||||||
|
.isString()
|
||||||
|
.trim()
|
||||||
.isLength({ min: 1, max: 64 })
|
.isLength({ min: 1, max: 64 })
|
||||||
.withMessage("Custom URL length must be between 1 and 64.")
|
.withMessage("Custom URL length must be between 1 and 64.")
|
||||||
.custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
|
.custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
|
||||||
.withMessage("Custom URL is not valid")
|
.withMessage("Custom URL is not valid")
|
||||||
.custom(value => preservedUrls.some(url => url.toLowerCase() === value))
|
.custom(value => !preservedUrls.some(url => url.toLowerCase() === value))
|
||||||
.withMessage("You can't use this custom URL."),
|
.withMessage("You can't use this custom URL."),
|
||||||
body("reuse")
|
body("reuse")
|
||||||
.optional()
|
.optional()
|
||||||
|
.custom(checkUser)
|
||||||
|
.withMessage("Only users can use this field.")
|
||||||
.isBoolean()
|
.isBoolean()
|
||||||
.withMessage("Reuse must be boolean."),
|
.withMessage("Reuse must be boolean."),
|
||||||
body("domain")
|
body("domain")
|
||||||
.optional()
|
.optional()
|
||||||
|
.custom(checkUser)
|
||||||
|
.withMessage("Only users can use this field.")
|
||||||
.isString()
|
.isString()
|
||||||
.withMessage("Domain should be string.")
|
.withMessage("Domain should be string.")
|
||||||
|
.customSanitizer(value => value.toLowerCase())
|
||||||
.custom(async (address, { req }) => {
|
.custom(async (address, { req }) => {
|
||||||
const domain = await findDomain({
|
const domain = await query.domain.find({
|
||||||
address,
|
address,
|
||||||
userId: req.user && req.user.id
|
user_id: req.user.id
|
||||||
});
|
});
|
||||||
req.body.domain = domain || null;
|
req.body.domain = domain || null;
|
||||||
|
|
||||||
if (domain) return true;
|
return !!domain;
|
||||||
|
|
||||||
throw new CustomError("You can't use this domain.", 400);
|
|
||||||
})
|
})
|
||||||
|
.withMessage("You can't use this domain.")
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const redirectProtected = [
|
||||||
|
body("password", "Password is invalid.")
|
||||||
|
.exists({ checkFalsy: true, checkNull: true })
|
||||||
|
.isString()
|
||||||
|
.isLength({ min: 3, max: 64 })
|
||||||
|
.withMessage("Password length must be between 3 and 64."),
|
||||||
|
param("id", "ID is invalid.")
|
||||||
|
.exists({ checkFalsy: true, checkNull: true })
|
||||||
|
.isLength({ min: 36, max: 36 })
|
||||||
|
];
|
||||||
|
|
||||||
|
export const addDomain = [
|
||||||
|
body("address", "Domain is not valid")
|
||||||
|
.exists({ checkFalsy: true, checkNull: true })
|
||||||
|
.isLength({ min: 3, max: 64 })
|
||||||
|
.withMessage("Domain length must be between 3 and 64.")
|
||||||
|
.trim()
|
||||||
|
.customSanitizer(value => {
|
||||||
|
const parsed = URL.parse(value);
|
||||||
|
return parsed.hostname || parsed.href;
|
||||||
|
})
|
||||||
|
.custom(value => urlRegex({ exact: true, strict: false }).test(value))
|
||||||
|
.custom(value => value !== env.DEFAULT_DOMAIN)
|
||||||
|
.withMessage("You can't use the default domain.")
|
||||||
|
.custom(async (value, { req }) => {
|
||||||
|
const domains = await query.domain.get({ user_id: req.user.id });
|
||||||
|
return domains.length === 0;
|
||||||
|
})
|
||||||
|
.withMessage("You already own a domain. Contact support if you need more.")
|
||||||
|
.custom(async value => {
|
||||||
|
const domain = await query.domain.find({ address: value });
|
||||||
|
return !domain || !domain.user_id || !domain.banned;
|
||||||
|
})
|
||||||
|
.withMessage("You can't add this domain."),
|
||||||
|
body("homepage")
|
||||||
|
.optional({ checkFalsy: true, nullable: true })
|
||||||
|
.customSanitizer(addProtocol)
|
||||||
|
.custom(value => urlRegex({ exact: true, strict: false }).test(value))
|
||||||
|
.withMessage("Homepage is not valid.")
|
||||||
|
];
|
||||||
|
|
||||||
|
export const removeDomain = [
|
||||||
|
param("id", "ID is invalid.")
|
||||||
|
.exists({
|
||||||
|
checkFalsy: true,
|
||||||
|
checkNull: true
|
||||||
|
})
|
||||||
|
.isLength({ min: 36, max: 36 })
|
||||||
|
];
|
||||||
|
|
||||||
|
export const deleteLink = [
|
||||||
|
param("id", "ID is invalid.")
|
||||||
|
.exists({
|
||||||
|
checkFalsy: true,
|
||||||
|
checkNull: true
|
||||||
|
})
|
||||||
|
.isLength({ min: 36, max: 36 })
|
||||||
|
];
|
||||||
|
|
||||||
|
export const reportLink = [
|
||||||
|
body("link", "No link has been provided.")
|
||||||
|
.exists({
|
||||||
|
checkFalsy: true,
|
||||||
|
checkNull: true
|
||||||
|
})
|
||||||
|
.custom(value => URL.parse(value).hostname === env.DEFAULT_DOMAIN)
|
||||||
|
.withMessage(`You can only report a ${env.DEFAULT_DOMAIN} link.`)
|
||||||
|
];
|
||||||
|
|
||||||
|
export const banLink = [
|
||||||
|
param("id", "ID is invalid.")
|
||||||
|
.exists({
|
||||||
|
checkFalsy: true,
|
||||||
|
checkNull: true
|
||||||
|
})
|
||||||
|
.isLength({ min: 36, max: 36 }),
|
||||||
|
body("host", '"host" should be a boolean.')
|
||||||
|
.optional({
|
||||||
|
nullable: true
|
||||||
|
})
|
||||||
|
.isBoolean(),
|
||||||
|
body("user", '"user" should be a boolean.')
|
||||||
|
.optional({
|
||||||
|
nullable: true
|
||||||
|
})
|
||||||
|
.isBoolean(),
|
||||||
|
body("userlinks", '"userlinks" should be a boolean.')
|
||||||
|
.optional({
|
||||||
|
nullable: true
|
||||||
|
})
|
||||||
|
.isBoolean(),
|
||||||
|
body("domain", '"domain" should be a boolean.')
|
||||||
|
.optional({
|
||||||
|
nullable: true
|
||||||
|
})
|
||||||
|
.isBoolean()
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getStats = [
|
||||||
|
param("id", "ID is invalid.")
|
||||||
|
.exists({
|
||||||
|
checkFalsy: true,
|
||||||
|
checkNull: true
|
||||||
|
})
|
||||||
|
.isLength({ min: 36, max: 36 })
|
||||||
|
];
|
||||||
|
|
||||||
|
export const signup = [
|
||||||
|
body("password", "Password is not valid.")
|
||||||
|
.exists({ checkFalsy: true, checkNull: true })
|
||||||
|
.isLength({ min: 8, max: 64 })
|
||||||
|
.withMessage("Password length must be between 8 and 64."),
|
||||||
|
body("email", "Email is not valid.")
|
||||||
|
.exists({ checkFalsy: true, checkNull: true })
|
||||||
|
.trim()
|
||||||
|
.isEmail()
|
||||||
|
.isLength({ min: 0, max: 255 })
|
||||||
|
.withMessage("Email length must be max 255.")
|
||||||
|
.custom(async (value, { req }) => {
|
||||||
|
const user = await query.user.find({ email: value });
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
req.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !user || !user.verified;
|
||||||
|
})
|
||||||
|
.withMessage("You can't use this email address.")
|
||||||
|
];
|
||||||
|
|
||||||
|
export const login = [
|
||||||
|
body("password", "Password is not valid.")
|
||||||
|
.exists({ checkFalsy: true, checkNull: true })
|
||||||
|
.isLength({ min: 8, max: 64 })
|
||||||
|
.withMessage("Password length must be between 8 and 64."),
|
||||||
|
body("email", "Email is not valid.")
|
||||||
|
.exists({ checkFalsy: true, checkNull: true })
|
||||||
|
.trim()
|
||||||
|
.isEmail()
|
||||||
|
.isLength({ min: 0, max: 255 })
|
||||||
|
.withMessage("Email length must be max 255.")
|
||||||
|
];
|
||||||
|
|
||||||
|
export const changePassword = [
|
||||||
|
body("password", "Password is not valid.")
|
||||||
|
.exists({ checkFalsy: true, checkNull: true })
|
||||||
|
.isLength({ min: 8, max: 64 })
|
||||||
|
.withMessage("Password length must be between 8 and 64.")
|
||||||
|
];
|
||||||
|
|
||||||
|
export const resetPasswordRequest = [
|
||||||
|
body("email", "Email is not valid.")
|
||||||
|
.exists({ checkFalsy: true, checkNull: true })
|
||||||
|
.trim()
|
||||||
|
.isEmail()
|
||||||
|
.isLength({ min: 0, max: 255 })
|
||||||
|
.withMessage("Email length must be max 255.")
|
||||||
|
];
|
||||||
|
|
||||||
|
export const cooldown = (user: User) => {
|
||||||
|
if (!env.GOOGLE_SAFE_BROWSING_KEY || !user || !user.cooldowns) return;
|
||||||
|
|
||||||
|
// If has active cooldown then throw error
|
||||||
|
const hasCooldownNow = user.cooldowns.some(cooldown =>
|
||||||
|
isAfter(subHours(new Date(), 12), new Date(cooldown))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasCooldownNow) {
|
||||||
|
throw new CustomError("Cooldown because of a malware URL. Wait 12h");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const malware = async (user: User, target: string) => {
|
||||||
|
if (!env.GOOGLE_SAFE_BROWSING_KEY) return;
|
||||||
|
|
||||||
|
const isMalware = await axios.post(
|
||||||
|
`https://safebrowsing.googleapis.com/v4/threatMatches:find?key=${env.GOOGLE_SAFE_BROWSING_KEY}`,
|
||||||
|
{
|
||||||
|
client: {
|
||||||
|
clientId: env.DEFAULT_DOMAIN.toLowerCase().replace(".", ""),
|
||||||
|
clientVersion: "1.0.0"
|
||||||
|
},
|
||||||
|
threatInfo: {
|
||||||
|
threatTypes: [
|
||||||
|
"THREAT_TYPE_UNSPECIFIED",
|
||||||
|
"MALWARE",
|
||||||
|
"SOCIAL_ENGINEERING",
|
||||||
|
"UNWANTED_SOFTWARE",
|
||||||
|
"POTENTIALLY_HARMFUL_APPLICATION"
|
||||||
|
],
|
||||||
|
platformTypes: ["ANY_PLATFORM", "PLATFORM_TYPE_UNSPECIFIED"],
|
||||||
|
threatEntryTypes: [
|
||||||
|
"EXECUTABLE",
|
||||||
|
"URL",
|
||||||
|
"THREAT_ENTRY_TYPE_UNSPECIFIED"
|
||||||
|
],
|
||||||
|
threatEntries: [{ url: target }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!isMalware.data || !isMalware.data.matches) return;
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const [updatedUser] = await query.user.update(
|
||||||
|
{ id: user.id },
|
||||||
|
{
|
||||||
|
cooldowns: knex.raw("array_append(cooldowns, ?)", [
|
||||||
|
new Date().toISOString()
|
||||||
|
]) as any
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ban if too many cooldowns
|
||||||
|
if (updatedUser.cooldowns.length > 2) {
|
||||||
|
await query.user.update({ id: user.id }, { banned: true });
|
||||||
|
throw new CustomError("Too much malware requests. You are now banned.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new CustomError(
|
||||||
|
user ? "Malware detected! Cooldown for 12h." : "Malware detected!"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const linksCount = async (user?: User) => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const count = await query.link.total({
|
||||||
|
user_id: user.id,
|
||||||
|
created_at: [">", subDays(new Date(), 1).toISOString()]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (count > env.USER_LIMIT_PER_DAY) {
|
||||||
|
throw new CustomError(
|
||||||
|
`You have reached your daily limit (${env.USER_LIMIT_PER_DAY}). Please wait 24h.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bannedDomain = async (domain: string) => {
|
||||||
|
const isBanned = await query.domain.find({
|
||||||
|
address: domain,
|
||||||
|
banned: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isBanned) {
|
||||||
|
throw new CustomError("URL is containing malware/scam.", 400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bannedHost = async (domain: string) => {
|
||||||
|
let isBanned;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dnsRes = await dnsLookup(domain);
|
||||||
|
|
||||||
|
if (!dnsRes || !dnsRes.address) return;
|
||||||
|
|
||||||
|
isBanned = await query.host.find({
|
||||||
|
address: dnsRes.address,
|
||||||
|
banned: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
isBanned = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBanned) {
|
||||||
|
throw new CustomError("URL is containing malware/scam.", 400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -1,20 +1,22 @@
|
||||||
import knex from "knex";
|
import knex from "knex";
|
||||||
|
|
||||||
import { createUserTable } from "./models/user";
|
import { createUserTable } from "./models/user";
|
||||||
import { createDomainTable } from "./models/domain";
|
import { createDomainTable } from "./models/domain";
|
||||||
import { createLinkTable } from "./models/link";
|
import { createLinkTable } from "./models/link";
|
||||||
import { createVisitTable } from "./models/visit";
|
import { createVisitTable } from "./models/visit";
|
||||||
import { createIPTable } from "./models/ip";
|
import { createIPTable } from "./models/ip";
|
||||||
import { createHostTable } from "./models/host";
|
import { createHostTable } from "./models/host";
|
||||||
|
import env from "./env";
|
||||||
|
|
||||||
const db = knex({
|
const db = knex({
|
||||||
client: "postgres",
|
client: "postgres",
|
||||||
connection: {
|
connection: {
|
||||||
host: process.env.DB_HOST,
|
host: env.DB_HOST,
|
||||||
port: Number(process.env.DB_PORT) || 5432,
|
port: env.DB_PORT,
|
||||||
database: process.env.DB_NAME,
|
database: env.DB_NAME,
|
||||||
user: process.env.DB_USER,
|
user: env.DB_USER,
|
||||||
password: process.env.DB_PASSWORD,
|
password: env.DB_PASSWORD,
|
||||||
ssl: process.env.DB_SSL === "true"
|
ssl: env.DB_SSL
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./mail";
|
|
@ -1,15 +1,71 @@
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
import { resetMailText, verifyMailText } from "./text";
|
||||||
|
import { CustomError } from "../utils";
|
||||||
|
import env from "../env";
|
||||||
|
|
||||||
const mailConfig = {
|
const mailConfig = {
|
||||||
host: process.env.MAIL_HOST,
|
host: env.MAIL_HOST,
|
||||||
port: Number(process.env.MAIL_PORT),
|
port: env.MAIL_PORT,
|
||||||
secure: process.env.MAIL_SECURE === "true",
|
secure: env.MAIL_SECURE,
|
||||||
auth: {
|
auth: {
|
||||||
user: process.env.MAIL_USER,
|
user: env.MAIL_USER,
|
||||||
pass: process.env.MAIL_PASSWORD
|
pass: env.MAIL_PASSWORD
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport(mailConfig);
|
const transporter = nodemailer.createTransport(mailConfig);
|
||||||
|
|
||||||
export default transporter;
|
export default transporter;
|
||||||
|
|
||||||
|
// Read email templates
|
||||||
|
const resetEmailTemplatePath = path.join(__dirname, "template-reset.html");
|
||||||
|
const verifyEmailTemplatePath = path.join(__dirname, "template-verify.html");
|
||||||
|
const resetEmailTemplate = fs
|
||||||
|
.readFileSync(resetEmailTemplatePath, { encoding: "utf-8" })
|
||||||
|
.replace(/{{domain}}/gm, env.DEFAULT_DOMAIN);
|
||||||
|
const verifyEmailTemplate = fs
|
||||||
|
.readFileSync(verifyEmailTemplatePath, { encoding: "utf-8" })
|
||||||
|
.replace(/{{domain}}/gm, env.DEFAULT_DOMAIN);
|
||||||
|
|
||||||
|
export const verification = async (user: User) => {
|
||||||
|
const mail = await transporter.sendMail({
|
||||||
|
from: env.MAIL_FROM || env.MAIL_USER,
|
||||||
|
to: user.email,
|
||||||
|
subject: "Verify your account",
|
||||||
|
text: verifyMailText.replace(
|
||||||
|
/{{verification}}/gim,
|
||||||
|
user.verification_token
|
||||||
|
),
|
||||||
|
html: verifyEmailTemplate.replace(
|
||||||
|
/{{verification}}/gim,
|
||||||
|
user.verification_token
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mail.accepted.length) {
|
||||||
|
throw new CustomError("Couldn't send verification email. Try again later.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetPasswordToken = async (user: User) => {
|
||||||
|
const mail = await transporter.sendMail({
|
||||||
|
from: env.MAIL_USER,
|
||||||
|
to: user.email,
|
||||||
|
subject: "Reset your password",
|
||||||
|
text: resetMailText
|
||||||
|
.replace(/{{resetpassword}}/gm, user.reset_password_token)
|
||||||
|
.replace(/{{domain}}/gm, env.DEFAULT_DOMAIN),
|
||||||
|
html: resetEmailTemplate
|
||||||
|
.replace(/{{resetpassword}}/gm, user.reset_password_token)
|
||||||
|
.replace(/{{domain}}/gm, env.DEFAULT_DOMAIN)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mail.accepted.length) {
|
||||||
|
throw new CustomError(
|
||||||
|
"Couldn't send reset password email. Try again later."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
require("dotenv").config();
|
import env from "../env";
|
||||||
|
|
||||||
import { v1 as NEO4J } from "neo4j-driver";
|
import { v1 as NEO4J } from "neo4j-driver";
|
||||||
import knex from "knex";
|
import knex from "knex";
|
||||||
import PQueue from "p-queue";
|
import PQueue from "p-queue";
|
||||||
|
@ -7,17 +8,17 @@ const queue = new PQueue({ concurrency: 10 });
|
||||||
|
|
||||||
// 1. Connect to Neo4j database
|
// 1. Connect to Neo4j database
|
||||||
const neo4j = NEO4J.driver(
|
const neo4j = NEO4J.driver(
|
||||||
process.env.NEO4J_DB_URI,
|
env.NEO4J_DB_URI,
|
||||||
NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
|
NEO4J.auth.basic(env.NEO4J_DB_USERNAME, env.NEO4J_DB_PASSWORD)
|
||||||
);
|
);
|
||||||
// 2. Connect to Postgres database
|
// 2. Connect to Postgres database
|
||||||
const postgres = knex({
|
const postgres = knex({
|
||||||
client: "postgres",
|
client: "postgres",
|
||||||
connection: {
|
connection: {
|
||||||
host: process.env.DB_HOST,
|
host: env.DB_HOST,
|
||||||
database: process.env.DB_NAME,
|
database: env.DB_NAME,
|
||||||
user: process.env.DB_USER,
|
user: env.DB_USER,
|
||||||
password: process.env.DB_PASSWORD
|
password: env.DB_PASSWORD
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,23 +1,24 @@
|
||||||
require("dotenv").config();
|
import env from "../env";
|
||||||
|
|
||||||
import { v1 as NEO4J } from "neo4j-driver";
|
import { v1 as NEO4J } from "neo4j-driver";
|
||||||
import knex from "knex";
|
|
||||||
import PQuque from "p-queue";
|
import PQuque from "p-queue";
|
||||||
|
import knex from "knex";
|
||||||
|
|
||||||
const queue = new PQuque({ concurrency: 10 });
|
const queue = new PQuque({ concurrency: 10 });
|
||||||
|
|
||||||
// 1. Connect to Neo4j database
|
// 1. Connect to Neo4j database
|
||||||
const neo4j = NEO4J.driver(
|
const neo4j = NEO4J.driver(
|
||||||
process.env.NEO4J_DB_URI,
|
env.NEO4J_DB_URI,
|
||||||
NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
|
NEO4J.auth.basic(env.NEO4J_DB_USERNAME, env.NEO4J_DB_PASSWORD)
|
||||||
);
|
);
|
||||||
// 2. Connect to Postgres database
|
// 2. Connect to Postgres database
|
||||||
const postgres = knex({
|
const postgres = knex({
|
||||||
client: "postgres",
|
client: "postgres",
|
||||||
connection: {
|
connection: {
|
||||||
host: process.env.DB_HOST,
|
host: env.DB_HOST,
|
||||||
database: process.env.DB_NAME,
|
database: env.DB_NAME,
|
||||||
user: process.env.DB_USER,
|
user: env.DB_USER,
|
||||||
password: process.env.DB_PASSWORD
|
password: env.DB_PASSWORD
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,23 +1,24 @@
|
||||||
require("dotenv").config();
|
import env from "../env";
|
||||||
|
|
||||||
import { v1 as NEO4J } from "neo4j-driver";
|
import { v1 as NEO4J } from "neo4j-driver";
|
||||||
import knex from "knex";
|
|
||||||
import PQueue from "p-queue";
|
import PQueue from "p-queue";
|
||||||
|
import knex from "knex";
|
||||||
|
|
||||||
const queue = new PQueue({ concurrency: 1 });
|
const queue = new PQueue({ concurrency: 1 });
|
||||||
|
|
||||||
// 1. Connect to Neo4j database
|
// 1. Connect to Neo4j database
|
||||||
const neo4j = NEO4J.driver(
|
const neo4j = NEO4J.driver(
|
||||||
process.env.NEO4J_DB_URI,
|
env.NEO4J_DB_URI,
|
||||||
NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
|
NEO4J.auth.basic(env.NEO4J_DB_USERNAME, env.NEO4J_DB_PASSWORD)
|
||||||
);
|
);
|
||||||
// 2. Connect to Postgres database
|
// 2. Connect to Postgres database
|
||||||
const postgres = knex({
|
const postgres = knex({
|
||||||
client: "postgres",
|
client: "postgres",
|
||||||
connection: {
|
connection: {
|
||||||
host: process.env.DB_HOST,
|
host: env.DB_HOST,
|
||||||
database: process.env.DB_NAME,
|
database: env.DB_NAME,
|
||||||
user: process.env.DB_USER,
|
user: env.DB_USER,
|
||||||
password: process.env.DB_PASSWORD
|
password: env.DB_PASSWORD
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
require("dotenv").config();
|
import env from "../env";
|
||||||
|
|
||||||
import { v1 as NEO4J } from "neo4j-driver";
|
import { v1 as NEO4J } from "neo4j-driver";
|
||||||
import knex from "knex";
|
|
||||||
import PQueue from "p-queue";
|
|
||||||
import { startOfHour } from "date-fns";
|
import { startOfHour } from "date-fns";
|
||||||
|
import PQueue from "p-queue";
|
||||||
|
import knex from "knex";
|
||||||
|
|
||||||
let count = 0;
|
let count = 0;
|
||||||
const queue = new PQueue({ concurrency: 5 });
|
const queue = new PQueue({ concurrency: 5 });
|
||||||
|
@ -11,18 +12,18 @@ queue.on("active", () => (count % 1000 === 0 ? console.log(count++) : count++));
|
||||||
|
|
||||||
// 1. Connect to Neo4j database
|
// 1. Connect to Neo4j database
|
||||||
const neo4j = NEO4J.driver(
|
const neo4j = NEO4J.driver(
|
||||||
process.env.NEO4J_DB_URI,
|
env.NEO4J_DB_URI,
|
||||||
NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
|
NEO4J.auth.basic(env.NEO4J_DB_USERNAME, env.NEO4J_DB_PASSWORD)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. Connect to Postgres database
|
// 2. Connect to Postgres database
|
||||||
const postgres = knex({
|
const postgres = knex({
|
||||||
client: "postgres",
|
client: "postgres",
|
||||||
connection: {
|
connection: {
|
||||||
host: process.env.DB_HOST,
|
host: env.DB_HOST,
|
||||||
database: process.env.DB_NAME,
|
database: env.DB_NAME,
|
||||||
user: process.env.DB_USER,
|
user: env.DB_USER,
|
||||||
password: process.env.DB_PASSWORD
|
password: env.DB_PASSWORD
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
require("dotenv").config();
|
import env from "../env";
|
||||||
|
|
||||||
import { v1 as NEO4J } from "neo4j-driver";
|
import { v1 as NEO4J } from "neo4j-driver";
|
||||||
import PQueue from "p-queue";
|
import PQueue from "p-queue";
|
||||||
|
|
||||||
|
@ -8,8 +9,8 @@ queue.on("active", () => console.log(count++));
|
||||||
|
|
||||||
// 1. Connect to Neo4j database
|
// 1. Connect to Neo4j database
|
||||||
const neo4j = NEO4J.driver(
|
const neo4j = NEO4J.driver(
|
||||||
process.env.NEO4J_DB_URI,
|
env.NEO4J_DB_URI,
|
||||||
NEO4J.auth.basic(process.env.NEO4J_DB_USERNAME, process.env.NEO4J_DB_PASSWORD)
|
NEO4J.auth.basic(env.NEO4J_DB_USERNAME, env.NEO4J_DB_PASSWORD)
|
||||||
);
|
);
|
||||||
|
|
||||||
(async function() {
|
(async function() {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import * as Knex from "knex";
|
||||||
export async function createDomainTable(knex: Knex) {
|
export async function createDomainTable(knex: Knex) {
|
||||||
const hasTable = await knex.schema.hasTable("domains");
|
const hasTable = await knex.schema.hasTable("domains");
|
||||||
if (!hasTable) {
|
if (!hasTable) {
|
||||||
|
await knex.schema.raw('create extension if not exists "uuid-ossp"');
|
||||||
await knex.schema.createTable("domains", table => {
|
await knex.schema.createTable("domains", table => {
|
||||||
table.increments("id").primary();
|
table.increments("id").primary();
|
||||||
table
|
table
|
||||||
|
@ -26,4 +27,15 @@ export async function createDomainTable(knex: Knex) {
|
||||||
table.timestamps(false, true);
|
table.timestamps(false, true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasUUID = await knex.schema.hasColumn("domains", "uuid");
|
||||||
|
if (!hasUUID) {
|
||||||
|
await knex.schema.raw('create extension if not exists "uuid-ossp"');
|
||||||
|
await knex.schema.alterTable("domains", table => {
|
||||||
|
table
|
||||||
|
.uuid("uuid")
|
||||||
|
.notNullable()
|
||||||
|
.defaultTo(knex.raw("uuid_generate_v4()"));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,21 @@
|
||||||
import passport from "passport";
|
import { Strategy as LocalAPIKeyStrategy } from "passport-localapikey-update";
|
||||||
import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt";
|
import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt";
|
||||||
import { Strategy as LocalStratergy } from "passport-local";
|
import { Strategy as LocalStratergy } from "passport-local";
|
||||||
import { Strategy as LocalAPIKeyStrategy } from "passport-localapikey-update";
|
import passport from "passport";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
import { getUser } from "./db/user";
|
import query from "./queries";
|
||||||
|
import env from "./env";
|
||||||
|
|
||||||
const jwtOptions = {
|
const jwtOptions = {
|
||||||
jwtFromRequest: ExtractJwt.fromHeader("authorization"),
|
jwtFromRequest: ExtractJwt.fromHeader("authorization"),
|
||||||
secretOrKey: process.env.JWT_SECRET
|
secretOrKey: env.JWT_SECRET
|
||||||
};
|
};
|
||||||
|
|
||||||
passport.use(
|
passport.use(
|
||||||
new JwtStrategy(jwtOptions, async (payload, done) => {
|
new JwtStrategy(jwtOptions, async (payload, done) => {
|
||||||
try {
|
try {
|
||||||
const user = await getUser(payload.sub);
|
const user = await query.user.find({ email: payload.sub });
|
||||||
if (!user) return done(null, false);
|
if (!user) return done(null, false);
|
||||||
return done(null, user);
|
return done(null, user);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -30,7 +31,7 @@ const localOptions = {
|
||||||
passport.use(
|
passport.use(
|
||||||
new LocalStratergy(localOptions, async (email, password, done) => {
|
new LocalStratergy(localOptions, async (email, password, done) => {
|
||||||
try {
|
try {
|
||||||
const user = await getUser(email);
|
const user = await query.user.find({ email });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return done(null, false);
|
return done(null, false);
|
||||||
}
|
}
|
||||||
|
@ -53,7 +54,7 @@ const localAPIKeyOptions = {
|
||||||
passport.use(
|
passport.use(
|
||||||
new LocalAPIKeyStrategy(localAPIKeyOptions, async (apikey, done) => {
|
new LocalAPIKeyStrategy(localAPIKeyOptions, async (apikey, done) => {
|
||||||
try {
|
try {
|
||||||
const user = await getUser(apikey);
|
const user = await query.user.find({ apikey });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return done(null, false);
|
return done(null, false);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,34 +1,84 @@
|
||||||
import { getRedisKey } from "../utils";
|
|
||||||
import * as redis from "../redis";
|
import * as redis from "../redis";
|
||||||
import knex from "../knex";
|
import knex from "../knex";
|
||||||
|
|
||||||
interface FindDomain {
|
export const find = async (match: Partial<Domain>): Promise<Domain> => {
|
||||||
address?: string;
|
if (match.address) {
|
||||||
homepage?: string;
|
const cachedDomain = await redis.get(redis.key.domain(match.address));
|
||||||
uuid?: string;
|
if (cachedDomain) return JSON.parse(cachedDomain);
|
||||||
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();
|
const domain = await knex<Domain>("domains")
|
||||||
|
.where(match)
|
||||||
|
.first();
|
||||||
|
|
||||||
if (domain) {
|
if (domain) {
|
||||||
redis.set(redisKey, JSON.stringify(domain), "EX", 60 * 60 * 6);
|
redis.set(
|
||||||
|
redis.key.domain(domain.address),
|
||||||
|
JSON.stringify(domain),
|
||||||
|
"EX",
|
||||||
|
60 * 60 * 6
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return domain;
|
return domain;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const get = async (match: Partial<Domain>): Promise<Domain[]> => {
|
||||||
|
return knex<Domain>("domains").where(match);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Add extends Partial<Domain> {
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const add = async (params: Add) => {
|
||||||
|
params.address = params.address.toLowerCase();
|
||||||
|
|
||||||
|
const exists = await knex<Domain>("domains")
|
||||||
|
.where("address", params.address)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const newDomain = {
|
||||||
|
address: params.address,
|
||||||
|
homepage: params.homepage || null,
|
||||||
|
user_id: params.user_id || null,
|
||||||
|
banned: !!params.banned
|
||||||
|
};
|
||||||
|
|
||||||
|
let domain: Domain;
|
||||||
|
if (exists) {
|
||||||
|
const [response]: Domain[] = await knex<Domain>("domains")
|
||||||
|
.where("id", exists.id)
|
||||||
|
.update(
|
||||||
|
{
|
||||||
|
...newDomain,
|
||||||
|
updated_at: params.updated_at || new Date().toISOString()
|
||||||
|
},
|
||||||
|
"*"
|
||||||
|
);
|
||||||
|
domain = response;
|
||||||
|
} else {
|
||||||
|
const [response]: Domain[] = await knex<Domain>("domains").insert(
|
||||||
|
newDomain,
|
||||||
|
"*"
|
||||||
|
);
|
||||||
|
domain = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
redis.remove.domain(domain);
|
||||||
|
|
||||||
|
return domain;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const update = async (
|
||||||
|
match: Partial<Domain>,
|
||||||
|
update: Partial<Domain>
|
||||||
|
) => {
|
||||||
|
const domains = await knex<Domain>("domains")
|
||||||
|
.where(match)
|
||||||
|
.update({ ...update, updated_at: new Date().toISOString() }, "*");
|
||||||
|
|
||||||
|
domains.forEach(redis.remove.domain);
|
||||||
|
|
||||||
|
return domains;
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,62 @@
|
||||||
|
import * as redis from "../redis";
|
||||||
|
import knex from "../knex";
|
||||||
|
|
||||||
|
interface Add extends Partial<Host> {
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const find = async (match: Partial<Host>): Promise<Host> => {
|
||||||
|
if (match.address) {
|
||||||
|
const cachedHost = await redis.get(redis.key.host(match.address));
|
||||||
|
if (cachedHost) return JSON.parse(cachedHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = await knex<Domain>("hosts")
|
||||||
|
.where(match)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (host) {
|
||||||
|
redis.set(
|
||||||
|
redis.key.host(host.address),
|
||||||
|
JSON.stringify(host),
|
||||||
|
"EX",
|
||||||
|
60 * 60 * 6
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return host;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const add = async (params: Add) => {
|
||||||
|
params.address = params.address.toLowerCase();
|
||||||
|
|
||||||
|
const exists = await knex<Domain>("domains")
|
||||||
|
.where("address", params.address)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const newHost = {
|
||||||
|
address: params.address,
|
||||||
|
banned: !!params.banned
|
||||||
|
};
|
||||||
|
|
||||||
|
let host: Host;
|
||||||
|
if (exists) {
|
||||||
|
const [response] = await knex<Host>("hosts")
|
||||||
|
.where("id", exists.id)
|
||||||
|
.update(
|
||||||
|
{
|
||||||
|
...newHost,
|
||||||
|
updated_at: params.updated_at || new Date().toISOString()
|
||||||
|
},
|
||||||
|
"*"
|
||||||
|
);
|
||||||
|
host = response;
|
||||||
|
} else {
|
||||||
|
const [response] = await knex<Host>("hosts").insert(newHost, "*");
|
||||||
|
host = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
redis.remove.host(host);
|
||||||
|
|
||||||
|
return host;
|
||||||
|
};
|
|
@ -0,0 +1,15 @@
|
||||||
|
import * as domain from "./domain";
|
||||||
|
import * as visit from "./visit";
|
||||||
|
import * as link from "./link";
|
||||||
|
import * as user from "./user";
|
||||||
|
import * as host from "./host";
|
||||||
|
import * as ip from "./ip";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
domain,
|
||||||
|
host,
|
||||||
|
ip,
|
||||||
|
link,
|
||||||
|
user,
|
||||||
|
visit
|
||||||
|
};
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { subMinutes } from "date-fns";
|
||||||
|
|
||||||
|
import knex from "../knex";
|
||||||
|
import env from "../env";
|
||||||
|
|
||||||
|
export const add = async (ipToAdd: string) => {
|
||||||
|
const ip = ipToAdd.toLowerCase();
|
||||||
|
|
||||||
|
const currentIP = await knex<IP>("ips")
|
||||||
|
.where("ip", ip)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (currentIP) {
|
||||||
|
const currentDate = new Date().toISOString();
|
||||||
|
await knex<IP>("ips")
|
||||||
|
.where({ ip })
|
||||||
|
.update({
|
||||||
|
created_at: currentDate,
|
||||||
|
updated_at: currentDate
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await knex<IP>("ips").insert({ ip });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ip;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clear = async () =>
|
||||||
|
knex<IP>("ips")
|
||||||
|
.where(
|
||||||
|
"created_at",
|
||||||
|
"<",
|
||||||
|
subMinutes(new Date(), env.NON_USER_COOLDOWN).toISOString()
|
||||||
|
)
|
||||||
|
.delete();
|
|
@ -1,74 +1,84 @@
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
import { getRedisKey, generateShortLink } from "../utils";
|
import { CustomError } from "../utils";
|
||||||
import * as redis from "../redis";
|
import * as redis from "../redis";
|
||||||
import knex from "../knex";
|
import knex from "../knex";
|
||||||
|
|
||||||
interface GetTotal {
|
const selectable = [
|
||||||
all: boolean;
|
"links.id",
|
||||||
userId: number;
|
"links.address",
|
||||||
|
"links.banned",
|
||||||
|
"links.created_at",
|
||||||
|
"links.domain_id",
|
||||||
|
"links.updated_at",
|
||||||
|
"links.password",
|
||||||
|
"links.target",
|
||||||
|
"links.visit_count",
|
||||||
|
"links.user_id",
|
||||||
|
"links.uuid",
|
||||||
|
"domains.address as domain"
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalizeMatch = (match: Partial<Link>): Partial<Link> => {
|
||||||
|
const newMatch = { ...match };
|
||||||
|
|
||||||
|
if (newMatch.address) {
|
||||||
|
newMatch["links.address"] = newMatch.address;
|
||||||
|
delete newMatch.address;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newMatch.user_id) {
|
||||||
|
newMatch["links.user_id"] = newMatch.user_id;
|
||||||
|
delete newMatch.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newMatch.uuid) {
|
||||||
|
newMatch["links.uuid"] = newMatch.uuid;
|
||||||
|
delete newMatch.uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newMatch;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TotalParams {
|
||||||
search?: string;
|
search?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTotalQuery = async ({ all, search, userId }: GetTotal) => {
|
export const total = async (match: Match<Link>, params: TotalParams = {}) => {
|
||||||
const query = knex<Link>("links").count("id");
|
const query = knex<Link>("links");
|
||||||
|
|
||||||
if (!all) {
|
Object.entries(match).forEach(([key, value]) => {
|
||||||
query.where("user_id", userId);
|
query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
|
||||||
}
|
});
|
||||||
|
|
||||||
if (search) {
|
if (params.search) {
|
||||||
query.andWhereRaw("links.address || ' ' || target ILIKE '%' || ? || '%'", [
|
query.andWhereRaw("links.address || ' ' || target ILIKE '%' || ? || '%'", [
|
||||||
search
|
params.search
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [{ count }] = await query;
|
const [{ count }] = await query.count("id");
|
||||||
|
|
||||||
return typeof count === "number" ? count : parseInt(count);
|
return typeof count === "number" ? count : parseInt(count);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface GetLinks {
|
interface GetParams {
|
||||||
all: boolean;
|
|
||||||
limit: number;
|
limit: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
skip: number;
|
skip: number;
|
||||||
userId: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getLinksQuery = async ({
|
export const get = async (match: Partial<Link>, params: GetParams) => {
|
||||||
all,
|
|
||||||
limit,
|
|
||||||
search,
|
|
||||||
skip,
|
|
||||||
userId
|
|
||||||
}: GetLinks) => {
|
|
||||||
const query = knex<LinkJoinedDomain>("links")
|
const query = knex<LinkJoinedDomain>("links")
|
||||||
.select(
|
.select(...selectable)
|
||||||
"links.id",
|
.where(normalizeMatch(match))
|
||||||
"links.address",
|
.offset(params.skip)
|
||||||
"links.banned",
|
.limit(params.limit)
|
||||||
"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");
|
.orderBy("created_at", "desc");
|
||||||
|
|
||||||
if (!all) {
|
if (params.search) {
|
||||||
query.where("links.user_id", userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
query.andWhereRaw("links.address || ' ' || target ILIKE '%' || ? || '%'", [
|
query.andWhereRaw("links.address || ' ' || target ILIKE '%' || ? || '%'", [
|
||||||
search
|
params.search
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,79 +89,90 @@ export const getLinksQuery = async ({
|
||||||
return links;
|
return links;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FindLink {
|
export const find = async (match: Partial<Link>): Promise<Link> => {
|
||||||
address?: string;
|
if (match.address && match.domain_id) {
|
||||||
domainId?: number;
|
const key = redis.key.link(match.address, match.domain_id);
|
||||||
userId?: number;
|
const cachedLink = await redis.get(key);
|
||||||
target?: string;
|
if (cachedLink) return JSON.parse(cachedLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
const link = await knex<Link>("links")
|
||||||
.where({
|
.select(...selectable)
|
||||||
...(address && { address }),
|
.where(normalizeMatch(match))
|
||||||
...(domainId && { domain_id: domainId }),
|
.leftJoin("domains", "links.domain_id", "domains.id")
|
||||||
...(userId && { user_id: userId }),
|
|
||||||
...(target && { target })
|
|
||||||
})
|
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (link) {
|
if (link) {
|
||||||
redis.set(redisKey, JSON.stringify(link), "EX", 60 * 60 * 2);
|
const key = redis.key.link(link.address, link.domain_id);
|
||||||
|
redis.set(key, JSON.stringify(link), "EX", 60 * 60 * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
return link;
|
return link;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CreateLink {
|
interface Create extends Partial<Link> {
|
||||||
userId?: number;
|
|
||||||
domainAddress?: string;
|
|
||||||
domainId?: number;
|
|
||||||
password?: string;
|
|
||||||
address: string;
|
address: string;
|
||||||
target: string;
|
target: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createLinkQuery = async ({
|
export const create = async (params: Create) => {
|
||||||
password,
|
let encryptedPassword: string = null;
|
||||||
address,
|
|
||||||
target,
|
|
||||||
domainAddress,
|
|
||||||
domainId = null,
|
|
||||||
userId = null
|
|
||||||
}: CreateLink) => {
|
|
||||||
let encryptedPassword;
|
|
||||||
|
|
||||||
if (password) {
|
if (params.password) {
|
||||||
const salt = await bcrypt.genSalt(12);
|
const salt = await bcrypt.genSalt(12);
|
||||||
encryptedPassword = await bcrypt.hash(password, salt);
|
encryptedPassword = await bcrypt.hash(params.password, salt);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [link]: Link[] = await knex<Link>("links").insert(
|
const [link]: LinkJoinedDomain[] = await knex<LinkJoinedDomain>(
|
||||||
|
"links"
|
||||||
|
).insert(
|
||||||
{
|
{
|
||||||
password: encryptedPassword,
|
password: encryptedPassword,
|
||||||
domain_id: domainId,
|
domain_id: params.domain_id || null,
|
||||||
user_id: userId,
|
user_id: params.user_id || null,
|
||||||
address,
|
address: params.address,
|
||||||
target
|
target: params.target
|
||||||
},
|
},
|
||||||
"*"
|
"*"
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return link;
|
||||||
...link,
|
};
|
||||||
id: link.uuid,
|
|
||||||
password: !!password,
|
export const remove = async (match: Partial<Link>) => {
|
||||||
link: generateShortLink(address, domainAddress)
|
const link = await knex<Link>("links")
|
||||||
};
|
.where(match)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
throw new CustomError("Link was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await knex<Visit>("visits")
|
||||||
|
.where("link_id", link.id)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
const deletedLink = await knex<Link>("links")
|
||||||
|
.where("id", link.id)
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
redis.remove.link(link);
|
||||||
|
|
||||||
|
return !!deletedLink;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const update = async (match: Partial<Link>, update: Partial<Link>) => {
|
||||||
|
const links = await knex<Link>("links")
|
||||||
|
.where(match)
|
||||||
|
.update({ ...update, updated_at: new Date().toISOString() }, "*");
|
||||||
|
|
||||||
|
links.forEach(redis.remove.link);
|
||||||
|
|
||||||
|
return links;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const increamentVisit = async (match: Partial<Link>) => {
|
||||||
|
return knex<Link>("links")
|
||||||
|
.where(match)
|
||||||
|
.increment("visit_count", 1);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
import uuid from "uuid/v4";
|
||||||
|
import { addMinutes } from "date-fns";
|
||||||
|
|
||||||
|
import * as redis from "../redis";
|
||||||
|
import knex from "../knex";
|
||||||
|
|
||||||
|
export const find = async (match: Partial<User>) => {
|
||||||
|
if (match.email || match.apikey) {
|
||||||
|
const key = redis.key.user(match.email || match.apikey);
|
||||||
|
const cachedUser = await redis.get(key);
|
||||||
|
if (cachedUser) return JSON.parse(cachedUser) as User;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await knex<User>("users")
|
||||||
|
.where(match)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const emailKey = redis.key.user(user.email);
|
||||||
|
redis.set(emailKey, JSON.stringify(user), "EX", 60 * 60 * 1);
|
||||||
|
|
||||||
|
if (user.apikey) {
|
||||||
|
const apikeyKey = redis.key.user(user.apikey);
|
||||||
|
redis.set(apikeyKey, JSON.stringify(user), "EX", 60 * 60 * 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Add {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const add = async (params: Add, user?: User) => {
|
||||||
|
const data = {
|
||||||
|
email: params.email,
|
||||||
|
password: params.password,
|
||||||
|
verification_token: uuid(),
|
||||||
|
verification_expires: addMinutes(new Date(), 60).toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
await knex<User>("users")
|
||||||
|
.where("id", user.id)
|
||||||
|
.update({ ...data, updated_at: new Date().toISOString() });
|
||||||
|
} else {
|
||||||
|
await knex<User>("users").insert(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
redis.remove.user(user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
...data
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const update = async (match: Match<User>, update: Partial<User>) => {
|
||||||
|
const query = knex<User>("users");
|
||||||
|
|
||||||
|
Object.entries(match).forEach(([key, value]) => {
|
||||||
|
query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = await query.update(
|
||||||
|
{ ...update, updated_at: new Date().toISOString() },
|
||||||
|
"*"
|
||||||
|
);
|
||||||
|
|
||||||
|
users.forEach(redis.remove.user);
|
||||||
|
|
||||||
|
return users;
|
||||||
|
};
|
|
@ -0,0 +1,245 @@
|
||||||
|
import { isAfter, subDays, set } from "date-fns";
|
||||||
|
|
||||||
|
import * as utils from "../utils";
|
||||||
|
import * as redis from "../redis";
|
||||||
|
import knex from "../knex";
|
||||||
|
|
||||||
|
interface Add {
|
||||||
|
browser: string;
|
||||||
|
country: string;
|
||||||
|
domain?: string;
|
||||||
|
id: number;
|
||||||
|
os: string;
|
||||||
|
referrer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const add = async (params: Add) => {
|
||||||
|
const data = {
|
||||||
|
...params,
|
||||||
|
country: params.country.toLowerCase(),
|
||||||
|
referrer: params.referrer.toLowerCase()
|
||||||
|
};
|
||||||
|
|
||||||
|
const visit = await knex<Visit>("visits")
|
||||||
|
.where({ link_id: params.id })
|
||||||
|
.andWhere(
|
||||||
|
knex.raw("date_trunc('hour', created_at) = date_trunc('hour', ?)", [
|
||||||
|
knex.fn.now()
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (visit) {
|
||||||
|
await knex("visits")
|
||||||
|
.where({ id: visit.id })
|
||||||
|
.increment(`br_${data.browser}`, 1)
|
||||||
|
.increment(`os_${data.os}`, 1)
|
||||||
|
.increment("total", 1)
|
||||||
|
.update({
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
countries: knex.raw(
|
||||||
|
"jsonb_set(countries, '{??}', (COALESCE(countries->>?,'0')::int + 1)::text::jsonb)",
|
||||||
|
[data.country, data.country]
|
||||||
|
),
|
||||||
|
referrers: knex.raw(
|
||||||
|
"jsonb_set(referrers, '{??}', (COALESCE(referrers->>?,'0')::int + 1)::text::jsonb)",
|
||||||
|
[data.referrer, data.referrer]
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await knex<Visit>("visits").insert({
|
||||||
|
[`br_${data.browser}`]: 1,
|
||||||
|
countries: { [data.country]: 1 },
|
||||||
|
referrers: { [data.referrer]: 1 },
|
||||||
|
[`os_${data.os}`]: 1,
|
||||||
|
total: 1,
|
||||||
|
link_id: data.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return visit;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatsResult {
|
||||||
|
stats: {
|
||||||
|
browser: { name: string; value: number }[];
|
||||||
|
os: { name: string; value: number }[];
|
||||||
|
country: { name: string; value: number }[];
|
||||||
|
referrer: { name: string; value: number }[];
|
||||||
|
};
|
||||||
|
views: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IGetStatsResponse {
|
||||||
|
allTime: StatsResult;
|
||||||
|
lastDay: StatsResult;
|
||||||
|
lastMonth: StatsResult;
|
||||||
|
lastWeek: StatsResult;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const find = async (match: Partial<Visit>, total: number) => {
|
||||||
|
if (match.link_id) {
|
||||||
|
const key = redis.key.stats(match.link_id);
|
||||||
|
const cached = await redis.get(key);
|
||||||
|
if (cached) return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
lastDay: {
|
||||||
|
stats: utils.getInitStats(),
|
||||||
|
views: new Array(24).fill(0)
|
||||||
|
},
|
||||||
|
lastWeek: {
|
||||||
|
stats: utils.getInitStats(),
|
||||||
|
views: new Array(7).fill(0)
|
||||||
|
},
|
||||||
|
lastMonth: {
|
||||||
|
stats: utils.getInitStats(),
|
||||||
|
views: new Array(30).fill(0)
|
||||||
|
},
|
||||||
|
allTime: {
|
||||||
|
stats: utils.getInitStats(),
|
||||||
|
views: new Array(18).fill(0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const visitsStream: any = knex<Visit>("visits")
|
||||||
|
.where(match)
|
||||||
|
.stream();
|
||||||
|
const nowUTC = utils.getUTCDate();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for await (const visit of visitsStream as Visit[]) {
|
||||||
|
utils.STATS_PERIODS.forEach(([days, type]) => {
|
||||||
|
const isIncluded = isAfter(
|
||||||
|
new Date(visit.created_at),
|
||||||
|
subDays(nowUTC, days)
|
||||||
|
);
|
||||||
|
if (isIncluded) {
|
||||||
|
const diffFunction = utils.getDifferenceFunction(type);
|
||||||
|
const diff = diffFunction(now, visit.created_at);
|
||||||
|
const index = stats[type].views.length - diff - 1;
|
||||||
|
const view = stats[type].views[index];
|
||||||
|
const period = stats[type].stats;
|
||||||
|
stats[type].stats = {
|
||||||
|
browser: {
|
||||||
|
chrome: period.browser.chrome + visit.br_chrome,
|
||||||
|
edge: period.browser.edge + visit.br_edge,
|
||||||
|
firefox: period.browser.firefox + visit.br_firefox,
|
||||||
|
ie: period.browser.ie + visit.br_ie,
|
||||||
|
opera: period.browser.opera + visit.br_opera,
|
||||||
|
other: period.browser.other + visit.br_other,
|
||||||
|
safari: period.browser.safari + visit.br_safari
|
||||||
|
},
|
||||||
|
os: {
|
||||||
|
android: period.os.android + visit.os_android,
|
||||||
|
ios: period.os.ios + visit.os_ios,
|
||||||
|
linux: period.os.linux + visit.os_linux,
|
||||||
|
macos: period.os.macos + visit.os_macos,
|
||||||
|
other: period.os.other + visit.os_other,
|
||||||
|
windows: period.os.windows + visit.os_windows
|
||||||
|
},
|
||||||
|
country: {
|
||||||
|
...period.country,
|
||||||
|
...Object.entries(visit.countries).reduce(
|
||||||
|
(obj, [country, count]) => ({
|
||||||
|
...obj,
|
||||||
|
[country]: (period.country[country] || 0) + count
|
||||||
|
}),
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
referrer: {
|
||||||
|
...period.referrer,
|
||||||
|
...Object.entries(visit.referrers).reduce(
|
||||||
|
(obj, [referrer, count]) => ({
|
||||||
|
...obj,
|
||||||
|
[referrer]: (period.referrer[referrer] || 0) + count
|
||||||
|
}),
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
stats[type].views[index] = view + visit.total;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const allTime = stats.allTime.stats;
|
||||||
|
const diffFunction = utils.getDifferenceFunction("allTime");
|
||||||
|
const diff = diffFunction(
|
||||||
|
set(new Date(), { date: 1 }),
|
||||||
|
set(new Date(visit.created_at), { date: 1 })
|
||||||
|
);
|
||||||
|
const index = stats.allTime.views.length - diff - 1;
|
||||||
|
const view = stats.allTime.views[index];
|
||||||
|
stats.allTime.stats = {
|
||||||
|
browser: {
|
||||||
|
chrome: allTime.browser.chrome + visit.br_chrome,
|
||||||
|
edge: allTime.browser.edge + visit.br_edge,
|
||||||
|
firefox: allTime.browser.firefox + visit.br_firefox,
|
||||||
|
ie: allTime.browser.ie + visit.br_ie,
|
||||||
|
opera: allTime.browser.opera + visit.br_opera,
|
||||||
|
other: allTime.browser.other + visit.br_other,
|
||||||
|
safari: allTime.browser.safari + visit.br_safari
|
||||||
|
},
|
||||||
|
os: {
|
||||||
|
android: allTime.os.android + visit.os_android,
|
||||||
|
ios: allTime.os.ios + visit.os_ios,
|
||||||
|
linux: allTime.os.linux + visit.os_linux,
|
||||||
|
macos: allTime.os.macos + visit.os_macos,
|
||||||
|
other: allTime.os.other + visit.os_other,
|
||||||
|
windows: allTime.os.windows + visit.os_windows
|
||||||
|
},
|
||||||
|
country: {
|
||||||
|
...allTime.country,
|
||||||
|
...Object.entries(visit.countries).reduce(
|
||||||
|
(obj, [country, count]) => ({
|
||||||
|
...obj,
|
||||||
|
[country]: (allTime.country[country] || 0) + count
|
||||||
|
}),
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
referrer: {
|
||||||
|
...allTime.referrer,
|
||||||
|
...Object.entries(visit.referrers).reduce(
|
||||||
|
(obj, [referrer, count]) => ({
|
||||||
|
...obj,
|
||||||
|
[referrer]: (allTime.referrer[referrer] || 0) + count
|
||||||
|
}),
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
stats.allTime.views[index] = view + visit.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: IGetStatsResponse = {
|
||||||
|
allTime: {
|
||||||
|
stats: utils.statsObjectToArray(stats.allTime.stats),
|
||||||
|
views: stats.allTime.views
|
||||||
|
},
|
||||||
|
lastDay: {
|
||||||
|
stats: utils.statsObjectToArray(stats.lastDay.stats),
|
||||||
|
views: stats.lastDay.views
|
||||||
|
},
|
||||||
|
lastMonth: {
|
||||||
|
stats: utils.statsObjectToArray(stats.lastMonth.stats),
|
||||||
|
views: stats.lastMonth.views
|
||||||
|
},
|
||||||
|
lastWeek: {
|
||||||
|
stats: utils.statsObjectToArray(stats.lastWeek.stats),
|
||||||
|
views: stats.lastWeek.views
|
||||||
|
},
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (match.link_id) {
|
||||||
|
const cacheTime = utils.getStatsCacheTime(total);
|
||||||
|
const key = redis.key.stats(match.link_id);
|
||||||
|
redis.set(key, JSON.stringify(response), "EX", cacheTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
|
@ -1 +1,5 @@
|
||||||
export * from "./queues";
|
import { visit } from "./queues";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
visit
|
||||||
|
};
|
||||||
|
|
|
@ -1,18 +1,20 @@
|
||||||
import Queue from "bull";
|
import Queue from "bull";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
import env from "../env";
|
||||||
|
|
||||||
const redis = {
|
const redis = {
|
||||||
port: Number(process.env.REDIS_PORT) || 6379,
|
port: env.REDIS_PORT,
|
||||||
host: process.env.REDIS_HOST || "127.0.0.1",
|
host: env.REDIS_HOST,
|
||||||
...(process.env.REDIS_PASSWORD && { password: process.env.REDIS_PASSWORD })
|
...(env.REDIS_PASSWORD && { password: env.REDIS_PASSWORD })
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeJob = job => job.remove();
|
const removeJob = job => job.remove();
|
||||||
|
|
||||||
export const visitQueue = new Queue("visit", { redis });
|
export const visit = new Queue("visit", { redis });
|
||||||
|
|
||||||
visitQueue.clean(5000, "completed");
|
visit.clean(5000, "completed");
|
||||||
|
|
||||||
visitQueue.process(4, path.resolve(__dirname, "visitQueue.js"));
|
visit.process(4, path.resolve(__dirname, "visit.js"));
|
||||||
|
|
||||||
visitQueue.on("completed", removeJob);
|
visit.on("completed", removeJob);
|
||||||
|
|
|
@ -2,7 +2,7 @@ import useragent from "useragent";
|
||||||
import geoip from "geoip-lite";
|
import geoip from "geoip-lite";
|
||||||
import URL from "url";
|
import URL from "url";
|
||||||
|
|
||||||
import { createVisit, addLinkCount } from "../db/link";
|
import query from "../queries";
|
||||||
import { getStatsLimit } from "../utils";
|
import { getStatsLimit } from "../utils";
|
||||||
|
|
||||||
const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"];
|
const browsersList = ["IE", "Firefox", "Chrome", "Opera", "Safari", "Edge"];
|
||||||
|
@ -15,7 +15,7 @@ const filterInOs = agent => item =>
|
||||||
export default function({ data }) {
|
export default function({ data }) {
|
||||||
const tasks = [];
|
const tasks = [];
|
||||||
|
|
||||||
tasks.push(addLinkCount(data.link.id));
|
tasks.push(query.link.increamentVisit(data.link.id));
|
||||||
|
|
||||||
if (data.link.visit_count < getStatsLimit()) {
|
if (data.link.visit_count < getStatsLimit()) {
|
||||||
const agent = useragent.parse(data.headers["user-agent"]);
|
const agent = useragent.parse(data.headers["user-agent"]);
|
||||||
|
@ -25,10 +25,9 @@ export default function({ data }) {
|
||||||
const location = geoip.lookup(data.realIP);
|
const location = geoip.lookup(data.realIP);
|
||||||
const country = location && location.country;
|
const country = location && location.country;
|
||||||
tasks.push(
|
tasks.push(
|
||||||
createVisit({
|
query.visit.add({
|
||||||
browser: browser.toLowerCase(),
|
browser: browser.toLowerCase(),
|
||||||
country: country || "Unknown",
|
country: country || "Unknown",
|
||||||
domain: data.customDomain,
|
|
||||||
id: data.link.id,
|
id: data.link.id,
|
||||||
os: os.toLowerCase().replace(/\s/gi, ""),
|
os: os.toLowerCase().replace(/\s/gi, ""),
|
||||||
referrer: (referrer && referrer.replace(/\./gi, "[dot]")) || "Direct"
|
referrer: (referrer && referrer.replace(/\./gi, "[dot]")) || "Direct"
|
|
@ -1,10 +1,12 @@
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import redis from "redis";
|
import redis from "redis";
|
||||||
|
|
||||||
|
import env from "./env";
|
||||||
|
|
||||||
const client = redis.createClient({
|
const client = redis.createClient({
|
||||||
host: process.env.REDIS_HOST || "127.0.0.1",
|
host: env.REDIS_HOST,
|
||||||
port: Number(process.env.REDIS_PORT) || 6379,
|
port: env.REDIS_PORT,
|
||||||
...(process.env.REDIS_PASSWORD && { password: process.env.REDIS_PASSWORD })
|
...(env.REDIS_PASSWORD && { password: env.REDIS_PASSWORD })
|
||||||
});
|
});
|
||||||
|
|
||||||
export const get: (key: string) => Promise<any> = promisify(client.get).bind(
|
export const get: (key: string) => Promise<any> = promisify(client.get).bind(
|
||||||
|
@ -21,3 +23,32 @@ export const set: (
|
||||||
export const del: (key: string) => Promise<any> = promisify(client.del).bind(
|
export const del: (key: string) => Promise<any> = promisify(client.del).bind(
|
||||||
client
|
client
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const key = {
|
||||||
|
link: (address: string, domain_id?: number, user_id?: number) =>
|
||||||
|
`${address}-${domain_id || ""}-${user_id || ""}`,
|
||||||
|
domain: (address: string) => `d-${address}`,
|
||||||
|
stats: (link_id: number) => `s-${link_id}`,
|
||||||
|
host: (address: string) => `h-${address}`,
|
||||||
|
user: (emailOrKey: string) => `u-${emailOrKey}`
|
||||||
|
};
|
||||||
|
|
||||||
|
export const remove = {
|
||||||
|
domain: (domain?: Domain) => {
|
||||||
|
if (!domain) return;
|
||||||
|
del(key.domain(domain.address));
|
||||||
|
},
|
||||||
|
host: (host?: Host) => {
|
||||||
|
if (!host) return;
|
||||||
|
del(key.host(host.address));
|
||||||
|
},
|
||||||
|
link: (link?: Link) => {
|
||||||
|
if (!link) return;
|
||||||
|
del(key.link(link.address, link.domain_id));
|
||||||
|
},
|
||||||
|
user: (user?: User) => {
|
||||||
|
if (!user) return;
|
||||||
|
del(key.user(user.email));
|
||||||
|
del(key.user(user.apikey));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
import asyncHandler from "express-async-handler";
|
||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
import * as validators from "../handlers/validators";
|
||||||
|
import * as helpers from "../handlers/helpers";
|
||||||
|
import * as auth from "../handlers/auth";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/login",
|
||||||
|
validators.login,
|
||||||
|
asyncHandler(helpers.verify),
|
||||||
|
asyncHandler(auth.local),
|
||||||
|
asyncHandler(auth.token)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/signup",
|
||||||
|
validators.signup,
|
||||||
|
asyncHandler(helpers.verify),
|
||||||
|
asyncHandler(auth.signup)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post("/renew", asyncHandler(auth.jwt), asyncHandler(auth.token));
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/change-password",
|
||||||
|
asyncHandler(auth.jwt),
|
||||||
|
validators.changePassword,
|
||||||
|
asyncHandler(helpers.verify),
|
||||||
|
asyncHandler(auth.changePassword)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/apikey",
|
||||||
|
asyncHandler(auth.jwt),
|
||||||
|
asyncHandler(auth.generateApiKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post("/reset-password", asyncHandler(auth.resetPasswordRequest));
|
||||||
|
|
||||||
|
export default router;
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import asyncHandler from "express-async-handler";
|
||||||
|
|
||||||
|
import * as validators from "../handlers/validators";
|
||||||
|
import * as helpers from "../handlers/helpers";
|
||||||
|
import * as domains from "../handlers/domains";
|
||||||
|
import * as auth from "../handlers/auth";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
asyncHandler(auth.apikey),
|
||||||
|
asyncHandler(auth.jwt),
|
||||||
|
validators.addDomain,
|
||||||
|
asyncHandler(helpers.verify),
|
||||||
|
asyncHandler(domains.add)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/:id",
|
||||||
|
asyncHandler(auth.apikey),
|
||||||
|
asyncHandler(auth.jwt),
|
||||||
|
validators.removeDomain,
|
||||||
|
asyncHandler(helpers.verify),
|
||||||
|
asyncHandler(domains.remove)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
|
@ -1,11 +1 @@
|
||||||
import { Router } from "express";
|
export { default } from "./routes";
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
|
@ -2,11 +2,10 @@ import { Router } from "express";
|
||||||
import asyncHandler from "express-async-handler";
|
import asyncHandler from "express-async-handler";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
|
|
||||||
import * as auth from "../handlers/auth";
|
|
||||||
import * as validators from "../handlers/validators";
|
import * as validators from "../handlers/validators";
|
||||||
import * as sanitizers from "../handlers/sanitizers";
|
|
||||||
import * as helpers from "../handlers/helpers";
|
import * as helpers from "../handlers/helpers";
|
||||||
import { getLinks, createLink } from "../handlers/links";
|
import * as link from "../handlers/links";
|
||||||
|
import * as auth from "../handlers/auth";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
@ -15,7 +14,7 @@ router.get(
|
||||||
asyncHandler(auth.apikey),
|
asyncHandler(auth.apikey),
|
||||||
asyncHandler(auth.jwt),
|
asyncHandler(auth.jwt),
|
||||||
helpers.query,
|
helpers.query,
|
||||||
getLinks
|
asyncHandler(link.get)
|
||||||
);
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
|
@ -24,10 +23,50 @@ router.post(
|
||||||
asyncHandler(auth.apikey),
|
asyncHandler(auth.apikey),
|
||||||
asyncHandler(auth.jwtLoose),
|
asyncHandler(auth.jwtLoose),
|
||||||
asyncHandler(auth.recaptcha),
|
asyncHandler(auth.recaptcha),
|
||||||
sanitizers.createLink,
|
|
||||||
validators.createLink,
|
validators.createLink,
|
||||||
asyncHandler(validators.verify),
|
asyncHandler(helpers.verify),
|
||||||
createLink
|
asyncHandler(link.create)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/:id",
|
||||||
|
asyncHandler(auth.apikey),
|
||||||
|
asyncHandler(auth.jwt),
|
||||||
|
validators.deleteLink,
|
||||||
|
asyncHandler(helpers.verify),
|
||||||
|
asyncHandler(link.remove)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:id/stats",
|
||||||
|
asyncHandler(auth.apikey),
|
||||||
|
asyncHandler(auth.jwt),
|
||||||
|
validators.getStats,
|
||||||
|
asyncHandler(link.stats)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/:id/protected",
|
||||||
|
validators.redirectProtected,
|
||||||
|
asyncHandler(helpers.verify),
|
||||||
|
asyncHandler(link.redirectProtected)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/report",
|
||||||
|
validators.reportLink,
|
||||||
|
asyncHandler(helpers.verify),
|
||||||
|
asyncHandler(link.report)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/admin/ban/:id",
|
||||||
|
asyncHandler(auth.apikey),
|
||||||
|
asyncHandler(auth.jwt),
|
||||||
|
asyncHandler(auth.admin),
|
||||||
|
validators.banLink,
|
||||||
|
asyncHandler(helpers.verify),
|
||||||
|
asyncHandler(link.ban)
|
||||||
);
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
import domains from "./domains";
|
||||||
|
import health from "./health";
|
||||||
|
import links from "./links";
|
||||||
|
import user from "./users";
|
||||||
|
import auth from "./auth";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use("/domains", domains);
|
||||||
|
router.use("/health", health);
|
||||||
|
router.use("/links", links);
|
||||||
|
router.use("/users", user);
|
||||||
|
router.use("/auth", auth);
|
||||||
|
|
||||||
|
export default router;
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import asyncHandler from "express-async-handler";
|
||||||
|
|
||||||
|
import * as auth from "../handlers/auth";
|
||||||
|
import * as user from "../handlers/users";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
asyncHandler(auth.jwt),
|
||||||
|
asyncHandler(auth.apikey),
|
||||||
|
asyncHandler(user.get)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
195
server/server.ts
195
server/server.ts
|
@ -1,52 +1,39 @@
|
||||||
import "./configToEnv";
|
import env from "./env";
|
||||||
|
|
||||||
import dotenv from "dotenv";
|
import asyncHandler from "express-async-handler";
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
import nextApp from "next";
|
|
||||||
import express, { Request, Response } from "express";
|
|
||||||
import helmet from "helmet";
|
|
||||||
import morgan from "morgan";
|
|
||||||
import Raven from "raven";
|
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import passport from "passport";
|
import passport from "passport";
|
||||||
import cors from "cors";
|
import express from "express";
|
||||||
|
import helmet from "helmet";
|
||||||
|
import morgan from "morgan";
|
||||||
|
import nextApp from "next";
|
||||||
|
import Raven from "raven";
|
||||||
|
|
||||||
import {
|
import * as helpers from "./handlers/helpers";
|
||||||
validateBody,
|
import * as links from "./handlers/links";
|
||||||
validationCriterias,
|
import * as auth from "./handlers/auth";
|
||||||
validateUrl,
|
|
||||||
ipCooldownCheck
|
|
||||||
} from "./controllers/validateBodyController";
|
|
||||||
import * as auth from "./controllers/authController";
|
|
||||||
import * as link from "./controllers/linkController";
|
|
||||||
import { initializeDb } from "./knex";
|
import { initializeDb } from "./knex";
|
||||||
|
import __v1Routes from "./__v1";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
|
|
||||||
import "./cron";
|
import "./cron";
|
||||||
import "./passport";
|
import "./passport";
|
||||||
import { CustomError } from "./utils";
|
|
||||||
|
|
||||||
if (process.env.RAVEN_DSN) {
|
if (env.RAVEN_DSN) {
|
||||||
Raven.config(process.env.RAVEN_DSN).install();
|
Raven.config(env.RAVEN_DSN).install();
|
||||||
}
|
}
|
||||||
|
|
||||||
const catchErrors = fn => (req, res, next) =>
|
const port = env.PORT;
|
||||||
Promise.resolve(fn(req, res, next)).catch(next);
|
const app = nextApp({ dir: "./client", dev: env.isDev });
|
||||||
|
|
||||||
const port = Number(process.env.PORT) || 3000;
|
|
||||||
const dev = process.env.NODE_ENV !== "production";
|
|
||||||
const app = nextApp({ dir: "./client", dev });
|
|
||||||
const handle = app.getRequestHandler();
|
const handle = app.getRequestHandler();
|
||||||
|
|
||||||
app.prepare().then(async () => {
|
app.prepare().then(async () => {
|
||||||
await initializeDb();
|
await initializeDb();
|
||||||
|
|
||||||
const server = express();
|
const server = express();
|
||||||
|
|
||||||
server.set("trust proxy", true);
|
server.set("trust proxy", true);
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (env.isDev) {
|
||||||
server.use(morgan("dev"));
|
server.use(morgan("dev"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,161 +43,33 @@ app.prepare().then(async () => {
|
||||||
server.use(express.urlencoded({ extended: true }));
|
server.use(express.urlencoded({ extended: true }));
|
||||||
server.use(passport.initialize());
|
server.use(passport.initialize());
|
||||||
server.use(express.static("static"));
|
server.use(express.static("static"));
|
||||||
|
server.use(helpers.ip);
|
||||||
|
|
||||||
server.use((req, _res, next) => {
|
server.use(asyncHandler(links.redirectCustomDomain));
|
||||||
req.realIP =
|
|
||||||
(req.headers["x-real-ip"] as string) ||
|
|
||||||
req.connection.remoteAddress ||
|
|
||||||
"";
|
|
||||||
return next();
|
|
||||||
});
|
|
||||||
|
|
||||||
server.use(link.customDomainRedirection);
|
server.use("/api/v2", routes);
|
||||||
|
server.use("/api", __v1Routes);
|
||||||
|
|
||||||
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"));
|
|
||||||
server.get("/settings", (req, res) => app.render(req, res, "/settings"));
|
|
||||||
server.get("/stats", (req, res) => app.render(req, res, "/stats", req.query));
|
|
||||||
server.get("/terms", (req, res) => app.render(req, res, "/terms"));
|
|
||||||
server.get("/report", (req, res) => app.render(req, res, "/report"));
|
|
||||||
server.get("/banned", (req, res) => app.render(req, res, "/banned"));
|
|
||||||
|
|
||||||
/* View routes */
|
|
||||||
server.get(
|
server.get(
|
||||||
"/reset-password/:resetPasswordToken?",
|
"/reset-password/:resetPasswordToken?",
|
||||||
catchErrors(auth.resetUserPassword),
|
asyncHandler(auth.resetPassword),
|
||||||
(req, res) => app.render(req, res, "/reset-password", { token: req.token })
|
(req, res) => app.render(req, res, "/reset-password", { token: req.token })
|
||||||
);
|
);
|
||||||
|
|
||||||
server.get(
|
server.get(
|
||||||
"/verify/:verificationToken?",
|
"/verify/:verificationToken?",
|
||||||
catchErrors(auth.verify),
|
asyncHandler(auth.verify),
|
||||||
(req, res) => app.render(req, res, "/verify", { token: req.token })
|
(req, res) => app.render(req, res, "/verify", { token: req.token })
|
||||||
);
|
);
|
||||||
|
|
||||||
/* User and authentication */
|
server.get("/:id", asyncHandler(links.redirect(app)));
|
||||||
server.post(
|
|
||||||
"/api/auth/signup",
|
|
||||||
validationCriterias,
|
|
||||||
catchErrors(validateBody),
|
|
||||||
catchErrors(auth.signup)
|
|
||||||
);
|
|
||||||
server.post(
|
|
||||||
"/api/auth/login",
|
|
||||||
validationCriterias,
|
|
||||||
catchErrors(validateBody),
|
|
||||||
catchErrors(auth.authLocal),
|
|
||||||
catchErrors(auth.login)
|
|
||||||
);
|
|
||||||
server.post(
|
|
||||||
"/api/auth/renew",
|
|
||||||
catchErrors(auth.authJwt),
|
|
||||||
catchErrors(auth.renew)
|
|
||||||
);
|
|
||||||
server.post(
|
|
||||||
"/api/auth/changepassword",
|
|
||||||
catchErrors(auth.authJwt),
|
|
||||||
catchErrors(auth.changeUserPassword)
|
|
||||||
);
|
|
||||||
server.post(
|
|
||||||
"/api/auth/generateapikey",
|
|
||||||
catchErrors(auth.authJwt),
|
|
||||||
catchErrors(auth.generateUserApiKey)
|
|
||||||
);
|
|
||||||
server.post(
|
|
||||||
"/api/auth/resetpassword",
|
|
||||||
catchErrors(auth.requestUserPasswordReset)
|
|
||||||
);
|
|
||||||
server.get(
|
|
||||||
"/api/auth/usersettings",
|
|
||||||
catchErrors(auth.authJwt),
|
|
||||||
catchErrors(auth.userSettings)
|
|
||||||
);
|
|
||||||
|
|
||||||
/* URL shortener */
|
// Error handler
|
||||||
server.post(
|
server.use(helpers.error);
|
||||||
"/api/url/submit",
|
|
||||||
cors(),
|
|
||||||
catchErrors(auth.authApikey),
|
|
||||||
catchErrors(auth.authJwtLoose),
|
|
||||||
catchErrors(auth.recaptcha),
|
|
||||||
catchErrors(validateUrl),
|
|
||||||
catchErrors(ipCooldownCheck),
|
|
||||||
catchErrors(link.shortener)
|
|
||||||
);
|
|
||||||
server.post(
|
|
||||||
"/api/url/deleteurl",
|
|
||||||
catchErrors(auth.authApikey),
|
|
||||||
catchErrors(auth.authJwt),
|
|
||||||
catchErrors(link.deleteUserLink)
|
|
||||||
);
|
|
||||||
server.get(
|
|
||||||
"/api/url/geturls",
|
|
||||||
catchErrors(auth.authApikey),
|
|
||||||
catchErrors(auth.authJwt),
|
|
||||||
catchErrors(link.getUserLinks)
|
|
||||||
);
|
|
||||||
server.post(
|
|
||||||
"/api/url/customdomain",
|
|
||||||
catchErrors(auth.authJwt),
|
|
||||||
catchErrors(link.setCustomDomain)
|
|
||||||
);
|
|
||||||
server.delete(
|
|
||||||
"/api/url/customdomain",
|
|
||||||
catchErrors(auth.authJwt),
|
|
||||||
catchErrors(link.deleteCustomDomain)
|
|
||||||
);
|
|
||||||
server.get(
|
|
||||||
"/api/url/stats",
|
|
||||||
catchErrors(auth.authApikey),
|
|
||||||
catchErrors(auth.authJwt),
|
|
||||||
catchErrors(link.getLinkStats)
|
|
||||||
);
|
|
||||||
server.post("/api/url/requesturl", catchErrors(link.goToLink));
|
|
||||||
server.post("/api/url/report", catchErrors(link.reportLink));
|
|
||||||
server.post(
|
|
||||||
"/api/url/admin/ban",
|
|
||||||
catchErrors(auth.authApikey),
|
|
||||||
catchErrors(auth.authJwt),
|
|
||||||
catchErrors(auth.authAdmin),
|
|
||||||
catchErrors(link.ban)
|
|
||||||
);
|
|
||||||
server.get(
|
|
||||||
"/:id",
|
|
||||||
catchErrors(link.goToLink),
|
|
||||||
(req: Request, res: Response) => {
|
|
||||||
switch (req.pageType) {
|
|
||||||
case "password":
|
|
||||||
return app.render(req, res, "/url-password", {
|
|
||||||
protectedLink: req.protectedLink
|
|
||||||
});
|
|
||||||
case "info":
|
|
||||||
default:
|
|
||||||
return app.render(req, res, "/url-info", {
|
|
||||||
linkTarget: req.linkTarget
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// Handler everything else by Next.js
|
||||||
server.get("*", (req, res) => handle(req, res));
|
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 => {
|
server.listen(port, err => {
|
||||||
if (err) throw err;
|
if (err) throw err;
|
||||||
console.log(`> Ready on http://localhost:${port}`);
|
console.log(`> Ready on http://localhost:${port}`);
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
import ms from "ms";
|
import ms from "ms";
|
||||||
|
import generate from "nanoid/generate";
|
||||||
|
import JWT from "jsonwebtoken";
|
||||||
import {
|
import {
|
||||||
differenceInDays,
|
differenceInDays,
|
||||||
differenceInHours,
|
differenceInHours,
|
||||||
differenceInMonths
|
differenceInMonths,
|
||||||
|
addDays
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import generate from "nanoid/generate";
|
|
||||||
import { findLinkQuery } from "../queries/link";
|
import query from "../queries";
|
||||||
|
import env from "../env";
|
||||||
|
|
||||||
export class CustomError extends Error {
|
export class CustomError extends Error {
|
||||||
public statusCode?: number;
|
public statusCode?: number;
|
||||||
|
@ -18,14 +22,32 @@ export class CustomError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generateId = async (domainId: number = null) => {
|
export const isAdmin = (email: string): boolean =>
|
||||||
|
env.ADMIN_EMAILS.split(",")
|
||||||
|
.map(e => e.trim())
|
||||||
|
.includes(email);
|
||||||
|
|
||||||
|
export const signToken = (user: UserJoined) =>
|
||||||
|
JWT.sign(
|
||||||
|
{
|
||||||
|
iss: "ApiAuth",
|
||||||
|
sub: user.email,
|
||||||
|
domain: user.domain || "",
|
||||||
|
admin: isAdmin(user.email),
|
||||||
|
iat: parseInt((new Date().getTime() / 1000).toFixed(0)),
|
||||||
|
exp: parseInt((addDays(new Date(), 7).getTime() / 1000).toFixed(0))
|
||||||
|
} as Record<string, any>,
|
||||||
|
env.JWT_SECRET
|
||||||
|
);
|
||||||
|
|
||||||
|
export const generateId = async (domain_id: number = null) => {
|
||||||
const address = generate(
|
const address = generate(
|
||||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890",
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890",
|
||||||
Number(process.env.LINK_LENGTH) || 6
|
env.LINK_LENGTH
|
||||||
);
|
);
|
||||||
const link = await findLinkQuery({ address, domainId });
|
const link = await query.link.find({ address, domain_id });
|
||||||
if (!link) return address;
|
if (!link) return address;
|
||||||
return generateId(domainId);
|
return generateId(domain_id);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const addProtocol = (url: string): string => {
|
export const addProtocol = (url: string): string => {
|
||||||
|
@ -35,18 +57,12 @@ export const addProtocol = (url: string): string => {
|
||||||
|
|
||||||
export const generateShortLink = (id: string, domain?: string): string => {
|
export const generateShortLink = (id: string, domain?: string): string => {
|
||||||
const protocol =
|
const protocol =
|
||||||
process.env.CUSTOM_DOMAIN_USE_HTTPS === "true" || !domain
|
env.CUSTOM_DOMAIN_USE_HTTPS || !domain ? "https://" : "http://";
|
||||||
? "https://"
|
return `${protocol}${domain || env.DEFAULT_DOMAIN}/${id}`;
|
||||||
: "http://";
|
|
||||||
return `${protocol}${domain || process.env.DEFAULT_DOMAIN}/${id}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isAdmin = (email: string): boolean =>
|
|
||||||
process.env.ADMIN_EMAILS.split(",")
|
|
||||||
.map(e => e.trim())
|
|
||||||
.includes(email);
|
|
||||||
|
|
||||||
export const getRedisKey = {
|
export const getRedisKey = {
|
||||||
|
// TODO: remove user id and make domain id required
|
||||||
link: (address: string, domain_id?: number, user_id?: number) =>
|
link: (address: string, domain_id?: number, user_id?: number) =>
|
||||||
`${address}-${domain_id || ""}-${user_id || ""}`,
|
`${address}-${domain_id || ""}-${user_id || ""}`,
|
||||||
domain: (address: string) => `d-${address}`,
|
domain: (address: string) => `d-${address}`,
|
||||||
|
@ -56,27 +72,10 @@ export const getRedisKey = {
|
||||||
|
|
||||||
// TODO: Add statsLimit
|
// TODO: Add statsLimit
|
||||||
export const getStatsLimit = (): number =>
|
export const getStatsLimit = (): number =>
|
||||||
Number(process.env.DEFAULT_MAX_STATS_PER_LINK) || 100000000;
|
env.DEFAULT_MAX_STATS_PER_LINK || 100000000;
|
||||||
|
|
||||||
export const getStatsCacheTime = (total?: number): number => {
|
export const getStatsCacheTime = (total?: number): number => {
|
||||||
let durationInMs;
|
return (total > 50000 ? ms("5 minutes") : ms("1 minutes")) / 1000;
|
||||||
switch (true) {
|
|
||||||
case total <= 5000:
|
|
||||||
durationInMs = ms("5 minutes");
|
|
||||||
break;
|
|
||||||
case total > 5000 && total < 20000:
|
|
||||||
durationInMs = ms("10 minutes");
|
|
||||||
break;
|
|
||||||
case total < 40000:
|
|
||||||
durationInMs = ms("15 minutes");
|
|
||||||
break;
|
|
||||||
case total > 40000:
|
|
||||||
durationInMs = ms("30 minutes");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
durationInMs = ms("5 minutes");
|
|
||||||
}
|
|
||||||
return durationInMs / 1000;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const statsObjectToArray = (obj: Stats) => {
|
export const statsObjectToArray = (obj: Stats) => {
|
||||||
|
@ -115,3 +114,53 @@ export const getUTCDate = (dateString?: Date) => {
|
||||||
date.getUTCHours()
|
date.getUTCHours()
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const STATS_PERIODS: [number, "lastDay" | "lastWeek" | "lastMonth"][] = [
|
||||||
|
[1, "lastDay"],
|
||||||
|
[7, "lastWeek"],
|
||||||
|
[30, "lastMonth"]
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getInitStats = (): Stats => {
|
||||||
|
return Object.create({
|
||||||
|
browser: {
|
||||||
|
chrome: 0,
|
||||||
|
edge: 0,
|
||||||
|
firefox: 0,
|
||||||
|
ie: 0,
|
||||||
|
opera: 0,
|
||||||
|
other: 0,
|
||||||
|
safari: 0
|
||||||
|
},
|
||||||
|
os: {
|
||||||
|
android: 0,
|
||||||
|
ios: 0,
|
||||||
|
linux: 0,
|
||||||
|
macos: 0,
|
||||||
|
other: 0,
|
||||||
|
windows: 0
|
||||||
|
},
|
||||||
|
country: {},
|
||||||
|
referrer: {}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sanitize = {
|
||||||
|
domain: (domain: Domain): DomainSanitized => ({
|
||||||
|
...domain,
|
||||||
|
id: domain.uuid,
|
||||||
|
uuid: undefined,
|
||||||
|
user_id: undefined,
|
||||||
|
banned_by_id: undefined
|
||||||
|
}),
|
||||||
|
link: (link: LinkJoinedDomain): LinkSanitized => ({
|
||||||
|
...link,
|
||||||
|
banned_by_id: undefined,
|
||||||
|
domain_id: undefined,
|
||||||
|
user_id: undefined,
|
||||||
|
uuid: undefined,
|
||||||
|
id: link.uuid,
|
||||||
|
password: !!link.password,
|
||||||
|
link: generateShortLink(link.address, link.domain)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in New Issue