Browse Source

feat: api v2

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

+ 1
- 1
.eslintrc View File

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

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

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

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

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

+ 228
- 123
client/components/LinksTable.tsx View File

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

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

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

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

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

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

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

+ 3
- 3
client/components/Shortener.tsx View File

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

+ 13
- 14
client/components/Text.tsx View File

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

+ 10
- 12
client/consts/consts.ts View File

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

+ 2
- 2
client/pages/login.tsx View File

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

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

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

+ 2
- 2
client/pages/report.tsx View File

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

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

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

+ 5
- 13
client/pages/settings.tsx View File

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

+ 3
- 5
client/pages/stats.tsx View File

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

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

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

+ 3
- 3
client/store/auth.ts View File

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

+ 28
- 4
client/store/links.ts View File

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

+ 35
- 27
client/store/settings.ts View File

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

+ 35
- 0
global.d.ts View File

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

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


+ 57
- 55
package.json View File

@ -32,120 +32,122 @@
},
"homepage": "https://github.com/TheDevs-Network/kutt#readme",
"dependencies": {
"axios": "^0.19.0",
"axios": "^0.19.1",
"babel-plugin-inline-react-svg": "^1.1.0",
"bcryptjs": "^2.4.3",
"bull": "^3.11.0",
"bull": "^3.12.1",
"cookie-parser": "^1.4.4",
"cors": "^2.8.5",
"date-fns": "^2.4.1",
"dotenv": "^8.0.0",
"easy-peasy": "^3.2.3",
"date-fns": "^2.9.0",
"dotenv": "^8.2.0",
"easy-peasy": "^3.3.0",
"email-validator": "^1.2.3",
"envalid": "^6.0.0",
"express": "^4.17.1",
"express-async-handler": "^1.1.4",
"express-validator": "^6.3.1",
"geoip-lite": "^1.3.8",
"helmet": "^3.21.1",
"isbot": "^2.2.1",
"js-cookie": "^2.2.0",
"geoip-lite": "^1.4.0",
"helmet": "^3.21.2",
"isbot": "^2.5.4",
"js-cookie": "^2.2.1",
"jsonwebtoken": "^8.4.0",
"jwt-decode": "^2.2.0",
"knex": "^0.19.5",
"morgan": "^1.9.1",
"ms": "^2.1.1",
"ms": "^2.1.2",
"nanoid": "^1.3.4",
"neo4j-driver": "^1.7.5",
"next": "^9.1.7",
"neo4j-driver": "^1.7.6",
"next": "^9.2.0",
"node-cron": "^2.0.3",
"nodemailer": "^6.3.0",
"p-queue": "^6.1.1",
"passport": "^0.4.0",
"nodemailer": "^6.4.2",
"p-queue": "^6.2.1",
"passport": "^0.4.1",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"passport-localapikey-update": "^0.6.0",
"pg": "^7.12.1",
"pg-query-stream": "^2.0.0",
"pg": "^7.17.1",
"pg-query-stream": "^2.1.2",
"prop-types": "^15.7.2",
"qrcode.react": "^0.8.0",
"query-string": "^6.9.0",
"query-string": "^6.10.1",
"raven": "^2.6.4",
"react": "^16.8.1",
"react-copy-to-clipboard": "^5.0.1",
"react-dom": "^16.8.1",
"react-ga": "^2.5.7",
"react": "^16.12.0",
"react-copy-to-clipboard": "^5.0.2",
"react-dom": "^16.12.0",
"react-ga": "^2.7.0"