Browse Source

feat: (wip) api v3

pull/266/head
poeti8 1 year ago
parent
commit
d1d335c8b1
40 changed files with 1109 additions and 339 deletions
  1. +13
    -2
      client/components/AppWrapper.tsx
  2. +0
    -110
      client/components/Checkbox.tsx
  3. +252
    -0
      client/components/Input.tsx
  4. +64
    -34
      client/components/LinksTable.tsx
  5. +11
    -6
      client/components/Settings/SettingsApi.tsx
  6. +4
    -5
      client/components/Settings/SettingsBan.tsx
  7. +11
    -12
      client/components/Settings/SettingsDomain.tsx
  8. +3
    -3
      client/components/Settings/SettingsPassword.tsx
  9. +43
    -14
      client/components/Shortener.tsx
  10. +1
    -1
      client/components/Table.ts
  11. +0
    -83
      client/components/TextInput.tsx
  12. +4
    -0
      client/consts/consts.ts
  13. +5
    -5
      client/pages/login.tsx
  14. +3
    -3
      client/pages/report.tsx
  15. +1
    -1
      client/pages/reset-password.tsx
  16. +0
    -5
      client/pages/settings.tsx
  17. +2
    -2
      client/pages/stats.tsx
  18. +1
    -1
      client/pages/url-password.tsx
  19. +18
    -12
      client/store/links.ts
  20. +17
    -14
      client/store/settings.ts
  21. +1
    -1
      client/types.ts
  22. +6
    -1
      client/utils.ts
  23. +1
    -0
      global.d.ts
  24. +1
    -1
      server/controllers/authController.ts
  25. +6
    -9
      server/controllers/validateBodyController.ts
  26. +1
    -0
      server/db/link.ts
  27. +104
    -0
      server/handlers/auth.ts
  28. +17
    -0
      server/handlers/helpers.ts
  29. +118
    -0
      server/handlers/links.ts
  30. +21
    -0
      server/handlers/sanitizers.ts
  31. +84
    -0
      server/handlers/validators.ts
  32. +13
    -0
      server/models/link.ts
  33. +34
    -0
      server/queries/domain.ts
  34. +157
    -0
      server/queries/link.ts
  35. +0
    -2
      server/queues/queues.ts
  36. +7
    -0
      server/routes/health.ts
  37. +11
    -0
      server/routes/index.ts
  38. +33
    -0
      server/routes/links.ts
  39. +18
    -12
      server/server.ts
  40. +23
    -0
      server/utils/index.ts

+ 13
- 2
client/components/AppWrapper.tsx View File

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

+ 0
- 110
client/components/Checkbox.tsx View File

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

+ 252
- 0
client/components/Input.tsx View File

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

+ 64
- 34
client/components/LinksTable.tsx View File

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

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

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

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

@ -1,17 +1,16 @@
import React, { FC, useState } from "react";
import { Flex } from "reflexbox/styled-components";
import { useFormState } from "react-use-form-state";
import { Flex } from "reflexbox/styled-components";
import React, { FC, useState } from "react";
import axios from "axios";
import { Checkbox, TextInput } from "../Input";
import { getAxiosConfig } from "../../utils";
import { useMessage } from "../../hooks";
import TextInput from "../TextInput";
import Checkbox from "../Checkbox";
import { API } from "../../consts";
import { Button } from "../Button";
import Icon from "../Icon";
import Text, { H2 } from "../Text";
import { Col } from "../Layout";
import Icon from "../Icon";
interface BanForm {
id: string;

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

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

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

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

+ 43
- 14
client/components/Shortener.tsx View File

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

+ 1
- 1
client/components/Table.ts View File

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

+ 0
- 83
client/components/TextInput.tsx View File

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

+ 4
- 0
client/consts/consts.ts View File

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 18
- 12
client/store/links.ts View File

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

+ 17
- 14
client/store/settings.ts View File

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

+ 1
- 1
client/types.ts View File

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

+ 6
- 1
client/utils.ts View File

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

+ 1
- 0
global.d.ts View File

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

+ 1
- 1
server/controllers/authController.ts View File

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

+ 6
- 9
server/controllers/validateBodyController.ts View File

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

+ 1
- 0
server/db/link.ts View File

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

+ 104
- 0
server/handlers/auth.ts View File

@ -0,0 +1,104 @@
import { differenceInMinutes, subMinutes } from "date-fns";
import { Handler } from "express";
import passport from "passport";
import axios from "axios";
import { isAdmin, CustomError } from "../utils";
import knex from "../knex";
const authenticate = (
type: "jwt" | "local" | "localapikey",
error: string,
isStrict = true
) =>
async function auth(req, res, next) {
if (req.user) return next();
return passport.authenticate(type, (err, user) => {
if (err) {
throw new CustomError("An error occurred");
}
if (!user && isStrict) {
throw new CustomError(error, 401);
}
if (user && isStrict && !user.verified) {
throw new CustomError(
"Your email address is not verified. " +
"Click on signup to get the verification link again.",
400
);
}
if (user && user.banned) {
throw new CustomError("Your are banned from using this website.", 403);
}
if (user) {
req.user = {
...user,
admin: isAdmin(user.email)
};
return next();
}
return next();
})(req, res, next);
};
export const local = authenticate("local", "Login credentials are wrong.");
export const jwt = authenticate("jwt", "Unauthorized.");
export const jwtLoose = authenticate("jwt", "Unauthorized.", false);
export const apikey = authenticate(
"localapikey",
"API key is not correct.",
false
);
export const cooldown: Handler = async (req, res, next) => {
const cooldownConfig = Number(process.env.NON_USER_COOLDOWN);
if (req.user || !cooldownConfig) return next();
const ip = await knex<IP>("ips")
.where({ ip: req.realIP.toLowerCase() })
.andWhere(
"created_at",
">",
subMinutes(new Date(), cooldownConfig).toISOString()