feat: (wip) api v3
This commit is contained in:
parent
9ddc856bfd
commit
d1d335c8b1
|
@ -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
|
||||
|
|
|
@ -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;
|
|
@ -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]
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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 { removeProtocol } from "../utils";
|
||||
import { Link } from "../store/links";
|
||||
import { useMessage, useCopy } from "../hooks";
|
||||
import TextInput from "./TextInput";
|
||||
import { removeProtocol } from "../utils";
|
||||
import Text, { H1, Span } from "./Text";
|
||||
import { Link } from "../store/links";
|
||||
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")}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
|
@ -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%)",
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -83,8 +83,8 @@ const StatsPage: NextPage<Props> = ({ 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">
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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,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,5 +1,5 @@
|
|||
export interface TokenPayload {
|
||||
iss: 'ApiAuth';
|
||||
iss: "ApiAuth";
|
||||
sub: string;
|
||||
domain: string;
|
||||
admin: boolean;
|
||||
|
|
|
@ -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 || "";
|
||||
};
|
||||
|
|
|
@ -60,6 +60,7 @@ interface Link {
|
|||
target: string;
|
||||
updated_at: string;
|
||||
user_id?: number;
|
||||
uuid: string;
|
||||
visit_count: number;
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -189,6 +189,7 @@ export const getLinks = async (
|
|||
"links.target",
|
||||
"links.visit_count",
|
||||
"links.user_id",
|
||||
"links.uuid",
|
||||
"domains.address as domain"
|
||||
)
|
||||
.offset(offset)
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
.first();
|
||||
|
||||
if (ip) {
|
||||
const timeToWait =
|
||||
cooldownConfig - differenceInMinutes(new Date(), new Date(ip.created_at));
|
||||
throw new CustomError(
|
||||
`Non-logged in users are limited. Wait ${timeToWait} minutes or log in.`,
|
||||
400
|
||||
);
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
export const recaptcha: Handler = async (req, res, next) => {
|
||||
if (process.env.NODE_ENV !== "production") return next();
|
||||
if (!req.user) return next();
|
||||
|
||||
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) {
|
||||
throw new CustomError("reCAPTCHA is not valid. Try again.", 401);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
|
@ -0,0 +1,17 @@
|
|||
import { Handler } from "express";
|
||||
|
||||
export const query: Handler = (req, res, next) => {
|
||||
const { limit, skip, all } = req.query;
|
||||
const { admin } = req.user || {};
|
||||
|
||||
req.query.limit = parseInt(limit) || 10;
|
||||
req.query.skip = parseInt(skip) || 0;
|
||||
|
||||
if (req.query.limit > 50) {
|
||||
req.query.limit = 50;
|
||||
}
|
||||
|
||||
req.query.all = admin ? all === "true" : false;
|
||||
|
||||
next();
|
||||
};
|
|
@ -0,0 +1,118 @@
|
|||
import { Handler, Request } from "express";
|
||||
import URL from "url";
|
||||
|
||||
import { generateShortLink, generateId } from "../utils";
|
||||
import {
|
||||
getLinksQuery,
|
||||
getTotalQuery,
|
||||
findLinkQuery,
|
||||
createLinkQuery
|
||||
} from "../queries/link";
|
||||
import {
|
||||
cooldownCheck,
|
||||
malwareCheck,
|
||||
urlCountsCheck,
|
||||
checkBannedDomain,
|
||||
checkBannedHost
|
||||
} from "../controllers/validateBodyController";
|
||||
|
||||
export const getLinks: Handler = async (req, res) => {
|
||||
const { limit, skip, search, all } = req.query;
|
||||
const userId = req.user.id;
|
||||
|
||||
const [links, total] = await Promise.all([
|
||||
getLinksQuery({ all, limit, search, skip, userId }),
|
||||
getTotalQuery({ all, search, userId })
|
||||
]);
|
||||
|
||||
const data = links.map(link => ({
|
||||
...link,
|
||||
id: link.uuid,
|
||||
password: !!link.password,
|
||||
link: generateShortLink(link.address, link.domain)
|
||||
}));
|
||||
|
||||
return res.send({
|
||||
total,
|
||||
limit,
|
||||
skip,
|
||||
data
|
||||
});
|
||||
};
|
||||
|
||||
interface CreateLinkReq extends Request {
|
||||
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 domainId = domain ? domain.id : null;
|
||||
const domainAddress = domain ? domain.address : null;
|
||||
|
||||
try {
|
||||
const targetDomain = URL.parse(target).hostname;
|
||||
|
||||
const queries = await Promise.all([
|
||||
process.env.GOOGLE_SAFE_BROWSING_KEY && cooldownCheck(req.user),
|
||||
process.env.GOOGLE_SAFE_BROWSING_KEY && malwareCheck(req.user, target),
|
||||
req.user && urlCountsCheck(req.user),
|
||||
reuse &&
|
||||
findLinkQuery({
|
||||
target,
|
||||
userId: req.user.id,
|
||||
domainId
|
||||
}),
|
||||
customurl &&
|
||||
findLinkQuery({
|
||||
address: customurl,
|
||||
domainId
|
||||
}),
|
||||
!customurl && generateId(domainId),
|
||||
checkBannedDomain(targetDomain),
|
||||
checkBannedHost(targetDomain)
|
||||
]);
|
||||
|
||||
// if "reuse" is true, try to return
|
||||
// the existent URL without creating one
|
||||
if (queries[3]) {
|
||||
const { domain_id: d, user_id: u, ...currentLink } = 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
|
||||
if (queries[4]) {
|
||||
throw new Error("Custom URL is already in use.");
|
||||
}
|
||||
|
||||
// Create new link
|
||||
const address = customurl || queries[5];
|
||||
const link = await createLinkQuery({
|
||||
password,
|
||||
address,
|
||||
domainAddress,
|
||||
domainId,
|
||||
target
|
||||
});
|
||||
|
||||
if (!req.user && Number(process.env.NON_USER_COOLDOWN)) {
|
||||
// addIP(req.realIP);
|
||||
}
|
||||
|
||||
return res.json({ ...link, id: link.uuid });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
|
@ -0,0 +1,21 @@
|
|||
import { sanitizeBody, CustomSanitizer } from "express-validator";
|
||||
import { addProtocol } from "../utils";
|
||||
|
||||
const passIfUser: CustomSanitizer = (value, { req }) =>
|
||||
req.user ? value : undefined;
|
||||
|
||||
export const createLink = [
|
||||
sanitizeBody("target")
|
||||
.trim()
|
||||
.customSanitizer(value => value && addProtocol(value)),
|
||||
sanitizeBody("domain")
|
||||
.customSanitizer(value =>
|
||||
typeof value === "string" ? value.toLowerCase() : undefined
|
||||
)
|
||||
.customSanitizer(passIfUser),
|
||||
sanitizeBody("password").customSanitizer(passIfUser),
|
||||
sanitizeBody("customurl")
|
||||
.customSanitizer(passIfUser)
|
||||
.customSanitizer(value => value && value.trim()),
|
||||
sanitizeBody("reuse").customSanitizer(passIfUser)
|
||||
];
|
|
@ -0,0 +1,84 @@
|
|||
import { body, validationResult } from "express-validator";
|
||||
import urlRegex from "url-regex";
|
||||
import URL from "url";
|
||||
|
||||
import { findDomain } from "../queries/domain";
|
||||
import { CustomError } from "../utils";
|
||||
|
||||
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 preservedUrls = [
|
||||
"login",
|
||||
"logout",
|
||||
"signup",
|
||||
"reset-password",
|
||||
"resetpassword",
|
||||
"url-password",
|
||||
"url-info",
|
||||
"settings",
|
||||
"stats",
|
||||
"verify",
|
||||
"api",
|
||||
"404",
|
||||
"static",
|
||||
"images",
|
||||
"banned",
|
||||
"terms",
|
||||
"privacy",
|
||||
"report",
|
||||
"pricing"
|
||||
];
|
||||
|
||||
export const createLink = [
|
||||
body("target")
|
||||
.exists({ checkNull: true, checkFalsy: true })
|
||||
.withMessage("Target is missing.")
|
||||
.isLength({ min: 1, max: 2040 })
|
||||
.withMessage("Maximum URL length is 2040.")
|
||||
.custom(
|
||||
value =>
|
||||
urlRegex({ exact: true, strict: false }).test(value) ||
|
||||
/^(?!https?)(\w+):\/\//.test(value)
|
||||
)
|
||||
.withMessage("URL is not valid.")
|
||||
.custom(value => URL.parse(value).host !== process.env.DEFAULT_DOMAIN)
|
||||
.withMessage(`${process.env.DEFAULT_DOMAIN} URLs are not allowed.`),
|
||||
body("password")
|
||||
.optional()
|
||||
.isLength({ min: 3, max: 64 })
|
||||
.withMessage("Password length must be between 3 and 64."),
|
||||
body("customurl")
|
||||
.optional()
|
||||
.isLength({ min: 1, max: 64 })
|
||||
.withMessage("Custom URL length must be between 1 and 64.")
|
||||
.custom(value => /^[a-zA-Z0-9-_]+$/g.test(value))
|
||||
.withMessage("Custom URL is not valid")
|
||||
.custom(value => preservedUrls.some(url => url.toLowerCase() === value))
|
||||
.withMessage("You can't use this custom URL."),
|
||||
body("reuse")
|
||||
.optional()
|
||||
.isBoolean()
|
||||
.withMessage("Reuse must be boolean."),
|
||||
body("domain")
|
||||
.optional()
|
||||
.isString()
|
||||
.withMessage("Domain should be string.")
|
||||
.custom(async (address, { req }) => {
|
||||
const domain = await findDomain({
|
||||
address,
|
||||
userId: req.user && req.user.id
|
||||
});
|
||||
req.body.domain = domain || null;
|
||||
|
||||
if (domain) return true;
|
||||
|
||||
throw new CustomError("You can't use this domain.", 400);
|
||||
})
|
||||
];
|
|
@ -4,7 +4,9 @@ export async function createLinkTable(knex: Knex) {
|
|||
const hasTable = await knex.schema.hasTable("links");
|
||||
|
||||
if (!hasTable) {
|
||||
await knex.schema.raw('create extension if not exists "uuid-ossp"');
|
||||
await knex.schema.createTable("links", table => {
|
||||
knex.raw('create extension if not exists "uuid-ossp"');
|
||||
table.increments("id").primary();
|
||||
table.string("address").notNullable();
|
||||
table
|
||||
|
@ -32,4 +34,15 @@ export async function createLinkTable(knex: Knex) {
|
|||
table.timestamps(false, true);
|
||||
});
|
||||
}
|
||||
|
||||
const hasUUID = await knex.schema.hasColumn("links", "uuid");
|
||||
if (!hasUUID) {
|
||||
await knex.schema.raw('create extension if not exists "uuid-ossp"');
|
||||
await knex.schema.alterTable("links", table => {
|
||||
table
|
||||
.uuid("uuid")
|
||||
.notNullable()
|
||||
.defaultTo(knex.raw("uuid_generate_v4()"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import { getRedisKey } from "../utils";
|
||||
import * as redis from "../redis";
|
||||
import knex from "../knex";
|
||||
|
||||
interface FindDomain {
|
||||
address?: string;
|
||||
homepage?: string;
|
||||
uuid?: string;
|
||||
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();
|
||||
|
||||
if (domain) {
|
||||
redis.set(redisKey, JSON.stringify(domain), "EX", 60 * 60 * 6);
|
||||
}
|
||||
|
||||
return domain;
|
||||
};
|
|
@ -0,0 +1,157 @@
|
|||
import bcrypt from "bcryptjs";
|
||||
|
||||
import { getRedisKey, generateShortLink } from "../utils";
|
||||
import * as redis from "../redis";
|
||||
import knex from "../knex";
|
||||
|
||||
interface GetTotal {
|
||||
all: boolean;
|
||||
userId: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export const getTotalQuery = async ({ all, search, userId }: GetTotal) => {
|
||||
const query = knex<Link>("links").count("id");
|
||||
|
||||
if (!all) {
|
||||
query.where("user_id", userId);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query.andWhereRaw("links.address || ' ' || target ILIKE '%' || ? || '%'", [
|
||||
search
|
||||
]);
|
||||
}
|
||||
|
||||
const [{ count }] = await query;
|
||||
|
||||
return typeof count === "number" ? count : parseInt(count);
|
||||
};
|
||||
|
||||
interface GetLinks {
|
||||
all: boolean;
|
||||
limit: number;
|
||||
search?: string;
|
||||
skip: number;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export const getLinksQuery = async ({
|
||||
all,
|
||||
limit,
|
||||
search,
|
||||
skip,
|
||||
userId
|
||||
}: GetLinks) => {
|
||||
const query = knex<LinkJoinedDomain>("links")
|
||||
.select(
|
||||
"links.id",
|
||||
"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"
|
||||
)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.orderBy("created_at", "desc");
|
||||
|
||||
if (!all) {
|
||||
query.where("links.user_id", userId);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query.andWhereRaw("links.address || ' ' || target ILIKE '%' || ? || '%'", [
|
||||
search
|
||||
]);
|
||||
}
|
||||
|
||||
query.leftJoin("domains", "links.domain_id", "domains.id");
|
||||
|
||||
const links: LinkJoinedDomain[] = await query;
|
||||
|
||||
return links;
|
||||
};
|
||||
|
||||
interface FindLink {
|
||||
address?: string;
|
||||
domainId?: number;
|
||||
userId?: number;
|
||||
target?: string;
|
||||
}
|
||||
|
||||
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")
|
||||
.where({
|
||||
...(address && { address }),
|
||||
...(domainId && { domain_id: domainId }),
|
||||
...(userId && { user_id: userId }),
|
||||
...(target && { target })
|
||||
})
|
||||
.first();
|
||||
|
||||
if (link) {
|
||||
redis.set(redisKey, JSON.stringify(link), "EX", 60 * 60 * 2);
|
||||
}
|
||||
|
||||
return link;
|
||||
};
|
||||
|
||||
interface CreateLink {
|
||||
userId?: number;
|
||||
domainAddress?: string;
|
||||
domainId?: number;
|
||||
password?: string;
|
||||
address: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export const createLinkQuery = async ({
|
||||
password,
|
||||
address,
|
||||
target,
|
||||
domainAddress,
|
||||
domainId = null,
|
||||
userId = null
|
||||
}: CreateLink) => {
|
||||
let encryptedPassword;
|
||||
|
||||
if (password) {
|
||||
const salt = await bcrypt.genSalt(12);
|
||||
encryptedPassword = await bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
const [link]: Link[] = await knex<Link>("links").insert(
|
||||
{
|
||||
password: encryptedPassword,
|
||||
domain_id: domainId,
|
||||
user_id: userId,
|
||||
address,
|
||||
target
|
||||
},
|
||||
"*"
|
||||
);
|
||||
|
||||
return {
|
||||
...link,
|
||||
id: link.uuid,
|
||||
password: !!password,
|
||||
link: generateShortLink(address, domainAddress)
|
||||
};
|
||||
};
|
|
@ -12,9 +12,7 @@ const removeJob = job => job.remove();
|
|||
export const visitQueue = new Queue("visit", { redis });
|
||||
|
||||
visitQueue.clean(5000, "completed");
|
||||
visitQueue.clean(5000, "failed");
|
||||
|
||||
visitQueue.process(4, path.resolve(__dirname, "visitQueue.js"));
|
||||
|
||||
visitQueue.on("completed", removeJob);
|
||||
visitQueue.on("failed", removeJob);
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { Router } from "express";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", (_, res) => res.send("OK"));
|
||||
|
||||
export default router;
|
|
@ -0,0 +1,11 @@
|
|||
import { Router } from "express";
|
||||
|
||||
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;
|
|
@ -0,0 +1,33 @@
|
|||
import { Router } from "express";
|
||||
import asyncHandler from "express-async-handler";
|
||||
import cors from "cors";
|
||||
|
||||
import * as auth from "../handlers/auth";
|
||||
import * as validators from "../handlers/validators";
|
||||
import * as sanitizers from "../handlers/sanitizers";
|
||||
import * as helpers from "../handlers/helpers";
|
||||
import { getLinks, createLink } from "../handlers/links";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
asyncHandler(auth.apikey),
|
||||
asyncHandler(auth.jwt),
|
||||
helpers.query,
|
||||
getLinks
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
cors(),
|
||||
asyncHandler(auth.apikey),
|
||||
asyncHandler(auth.jwtLoose),
|
||||
asyncHandler(auth.recaptcha),
|
||||
sanitizers.createLink,
|
||||
validators.createLink,
|
||||
asyncHandler(validators.verify),
|
||||
createLink
|
||||
);
|
||||
|
||||
export default router;
|
|
@ -21,9 +21,11 @@ import {
|
|||
import * as auth from "./controllers/authController";
|
||||
import * as link from "./controllers/linkController";
|
||||
import { initializeDb } from "./knex";
|
||||
import routes from "./routes";
|
||||
|
||||
import "./cron";
|
||||
import "./passport";
|
||||
import { CustomError } from "./utils";
|
||||
|
||||
if (process.env.RAVEN_DSN) {
|
||||
Raven.config(process.env.RAVEN_DSN).install();
|
||||
|
@ -55,18 +57,6 @@ app.prepare().then(async () => {
|
|||
server.use(passport.initialize());
|
||||
server.use(express.static("static"));
|
||||
|
||||
server.use((error, req, res, next) => {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Sorry an error ocurred. Please try again later." });
|
||||
if (process.env.RAVEN_DSN) {
|
||||
Raven.captureException(error, {
|
||||
user: { email: req.user && req.user.email }
|
||||
});
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
server.use((req, _res, next) => {
|
||||
req.realIP =
|
||||
(req.headers["x-real-ip"] as string) ||
|
||||
|
@ -77,6 +67,8 @@ app.prepare().then(async () => {
|
|||
|
||||
server.use(link.customDomainRedirection);
|
||||
|
||||
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"));
|
||||
|
@ -205,6 +197,20 @@ app.prepare().then(async () => {
|
|||
|
||||
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 => {
|
||||
if (err) throw err;
|
||||
console.log(`> Ready on http://localhost:${port}`);
|
||||
|
|
|
@ -4,6 +4,29 @@ import {
|
|||
differenceInHours,
|
||||
differenceInMonths
|
||||
} from "date-fns";
|
||||
import generate from "nanoid/generate";
|
||||
import { findLinkQuery } from "../queries/link";
|
||||
|
||||
export class CustomError extends Error {
|
||||
public statusCode?: number;
|
||||
public data?: any;
|
||||
public constructor(message: string, statusCode = 500, data?: any) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.statusCode = statusCode;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
export const generateId = async (domainId: number = null) => {
|
||||
const address = generate(
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890",
|
||||
Number(process.env.LINK_LENGTH) || 6
|
||||
);
|
||||
const link = await findLinkQuery({ address, domainId });
|
||||
if (!link) return address;
|
||||
return generateId(domainId);
|
||||
};
|
||||
|
||||
export const addProtocol = (url: string): string => {
|
||||
const hasProtocol = /^\w+:\/\//.test(url);
|
||||
|
|
Loading…
Reference in New Issue