feat: (wip) api v3

This commit is contained in:
poeti8 2020-01-11 17:40:25 +03:30
parent 9ddc856bfd
commit d1d335c8b1
40 changed files with 1110 additions and 340 deletions

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

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
client/components/Input.tsx Normal file
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]
};

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

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

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;

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"

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 }

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 { 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")}

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,

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;

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

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

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

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

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>

View File

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

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

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;

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

View File

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

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
global.d.ts vendored
View File

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

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

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

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
server/handlers/auth.ts Normal file
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()
)
.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();
};

View File

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

118
server/handlers/links.ts Normal file
View File

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

View File

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

View File

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

View File

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

34
server/queries/domain.ts Normal file
View File

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

157
server/queries/link.ts Normal file
View File

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

View File

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

7
server/routes/health.ts Normal file
View File

@ -0,0 +1,7 @@
import { Router } from "express";
const router = Router();
router.get("/", (_, res) => res.send("OK"));
export default router;

11
server/routes/index.ts Normal file
View File

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

33
server/routes/links.ts Normal file
View File

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

View File

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

View File

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