feat: api v2

This commit is contained in:
poeti8 2020-01-30 18:51:52 +03:30
parent 524863c340
commit 25903bf3cd
72 changed files with 6074 additions and 3538 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
})) }))
]} ]}
/> />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

35
global.d.ts vendored
View File

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

5680
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

63
server/__v1/index.ts Normal file
View File

@ -0,0 +1,63 @@
import asyncHandler from "express-async-handler";
import { Router } from "express";
import cors from "cors";
import {
validateUrl,
ipCooldownCheck
} from "./controllers/validateBodyController";
import * as auth from "../handlers/auth";
import * as link from "./controllers/linkController";
const router = Router();
/* URL shortener */
router.post(
"url/submit",
cors(),
asyncHandler(auth.apikey),
asyncHandler(auth.jwtLoose),
asyncHandler(auth.recaptcha),
asyncHandler(validateUrl),
asyncHandler(ipCooldownCheck),
asyncHandler(link.shortener)
);
router.post(
"url/deleteurl",
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
asyncHandler(link.deleteUserLink)
);
router.get(
"url/geturls",
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
asyncHandler(link.getUserLinks)
);
router.post(
"url/customdomain",
asyncHandler(auth.jwt),
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;

View File

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

View File

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

View File

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

41
server/env.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

11
server/handlers/types.d.ts vendored Normal file
View File

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

14
server/handlers/users.ts Normal file
View File

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

View File

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

View File

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

1
server/mail/index.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

62
server/queries/host.ts Normal file
View File

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

15
server/queries/index.ts Normal file
View File

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

35
server/queries/ip.ts Normal file
View File

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

View File

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

75
server/queries/user.ts Normal file
View File

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

245
server/queries/visit.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

43
server/routes/auth.ts Normal file
View File

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

29
server/routes/domains.ts Normal file
View File

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

View File

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

View File

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

17
server/routes/routes.ts Normal file
View File

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

16
server/routes/users.ts Normal file
View File

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

View File

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

View File

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