mirror of
https://github.com/kremalicious/umami.git
synced 2025-02-14 21:10:34 +01:00
Enable public website sharing.
This commit is contained in:
parent
48a524e09c
commit
560f1316c1
1
assets/ellipsis-h.svg
Normal file
1
assets/ellipsis-h.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M304 256c0 26.5-21.5 48-48 48s-48-21.5-48-48 21.5-48 48-48 48 21.5 48 48zm120-48c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48zm-336 0c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48z"/></svg>
|
After Width: | Height: | Size: 297 B |
1
assets/link.svg
Normal file
1
assets/link.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M314.222 197.78c51.091 51.091 54.377 132.287 9.75 187.16-6.242 7.73-2.784 3.865-84.94 86.02-54.696 54.696-143.266 54.745-197.99 0-54.711-54.69-54.734-143.255 0-197.99 32.773-32.773 51.835-51.899 63.409-63.457 7.463-7.452 20.331-2.354 20.486 8.192a173.31 173.31 0 0 0 4.746 37.828c.966 4.029-.272 8.269-3.202 11.198L80.632 312.57c-32.755 32.775-32.887 85.892 0 118.8 32.775 32.755 85.892 32.887 118.8 0l75.19-75.2c32.718-32.725 32.777-86.013 0-118.79a83.722 83.722 0 0 0-22.814-16.229c-4.623-2.233-7.182-7.25-6.561-12.346 1.356-11.122 6.296-21.885 14.815-30.405l4.375-4.375c3.625-3.626 9.177-4.594 13.76-2.294 12.999 6.524 25.187 15.211 36.025 26.049zM470.958 41.04c-54.724-54.745-143.294-54.696-197.99 0-82.156 82.156-78.698 78.29-84.94 86.02-44.627 54.873-41.341 136.069 9.75 187.16 10.838 10.838 23.026 19.525 36.025 26.049 4.582 2.3 10.134 1.331 13.76-2.294l4.375-4.375c8.52-8.519 13.459-19.283 14.815-30.405.621-5.096-1.938-10.113-6.561-12.346a83.706 83.706 0 0 1-22.814-16.229c-32.777-32.777-32.718-86.065 0-118.79l75.19-75.2c32.908-32.887 86.025-32.755 118.8 0 32.887 32.908 32.755 86.025 0 118.8l-45.848 45.84c-2.93 2.929-4.168 7.169-3.202 11.198a173.31 173.31 0 0 1 4.746 37.828c.155 10.546 13.023 15.644 20.486 8.192 11.574-11.558 30.636-30.684 63.409-63.457 54.733-54.735 54.71-143.3-.001-197.991z"/></svg>
|
After Width: | Height: | Size: 1.4 KiB |
@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 389.11"><defs><style>.cls-1{fill:#fff;stroke:#000;stroke-miterlimit:10;stroke-width:20px;}</style></defs><title>Asset 2</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_3" data-name="Layer 3"><circle class="cls-1" cx="214.15" cy="181" r="171"/><path d="M0,175.11c0,118.19,95.81,214,214,214s214-95.81,214-214a215.65,215.65,0,0,0-3-36H3A215.65,215.65,0,0,0,0,175.11Z"/><rect x="0.29" y="134.11" width="427.71" height="60" rx="15"/></g></g></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 389.11"><defs><style>.cls-1{fill:#fff;stroke:#000;stroke-miterlimit:10;stroke-width:20px;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_4" data-name="Layer 4"><circle class="cls-1" cx="214.15" cy="181" r="171"/><path d="M413,134.11H15.29a15,15,0,0,0-15,15v15.3C.12,168,0,171.52,0,175.11c0,118.19,95.81,214,214,214,116.4,0,211.1-92.94,213.93-208.67,0-.44.07-.88.07-1.33v-30A15,15,0,0,0,413,134.11Z"/></g></g></svg>
|
Before Width: | Height: | Size: 507 B After Width: | Height: | Size: 488 B |
@ -4,6 +4,7 @@ import PageHeader from 'components/layout/PageHeader';
|
|||||||
import Button from 'components/common/Button';
|
import Button from 'components/common/Button';
|
||||||
import ChangePasswordForm from './forms/ChangePasswordForm';
|
import ChangePasswordForm from './forms/ChangePasswordForm';
|
||||||
import Modal from 'components/common/Modal';
|
import Modal from 'components/common/Modal';
|
||||||
|
import Dots from 'assets/ellipsis-h.svg';
|
||||||
|
|
||||||
export default function ProfileSettings() {
|
export default function ProfileSettings() {
|
||||||
const user = useSelector(state => state.user);
|
const user = useSelector(state => state.user);
|
||||||
@ -14,8 +15,8 @@ export default function ProfileSettings() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<div>Profile</div>
|
<div>Profile</div>
|
||||||
<Button size="small" onClick={() => setChangePassword(true)}>
|
<Button icon={<Dots />} size="small" onClick={() => setChangePassword(true)}>
|
||||||
Change password
|
<div>Change password</div>
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<dl>
|
<dl>
|
||||||
|
@ -5,12 +5,14 @@ import PageHeader from 'components/layout/PageHeader';
|
|||||||
import Modal from 'components/common/Modal';
|
import Modal from 'components/common/Modal';
|
||||||
import WebsiteEditForm from './forms/WebsiteEditForm';
|
import WebsiteEditForm from './forms/WebsiteEditForm';
|
||||||
import DeleteForm from './forms/DeleteForm';
|
import DeleteForm from './forms/DeleteForm';
|
||||||
import WebsiteCodeForm from './forms/WebsiteCodeForm';
|
import TrackingCodeForm from './forms/TrackingCodeForm';
|
||||||
|
import ShareUrlForm from './forms/ShareUrlForm';
|
||||||
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
|
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
|
||||||
import Pen from 'assets/pen.svg';
|
import Pen from 'assets/pen.svg';
|
||||||
import Trash from 'assets/trash.svg';
|
import Trash from 'assets/trash.svg';
|
||||||
import Plus from 'assets/plus.svg';
|
import Plus from 'assets/plus.svg';
|
||||||
import Code from 'assets/code.svg';
|
import Code from 'assets/code.svg';
|
||||||
|
import Link from 'assets/link.svg';
|
||||||
import { get } from 'lib/web';
|
import { get } from 'lib/web';
|
||||||
import styles from './WebsiteSettings.module.css';
|
import styles from './WebsiteSettings.module.css';
|
||||||
|
|
||||||
@ -20,13 +22,27 @@ export default function WebsiteSettings() {
|
|||||||
const [deleteWebsite, setDeleteWebsite] = useState();
|
const [deleteWebsite, setDeleteWebsite] = useState();
|
||||||
const [addWebsite, setAddWebsite] = useState();
|
const [addWebsite, setAddWebsite] = useState();
|
||||||
const [showCode, setShowCode] = useState();
|
const [showCode, setShowCode] = useState();
|
||||||
|
const [showUrl, setShowUrl] = useState();
|
||||||
const [saved, setSaved] = useState(0);
|
const [saved, setSaved] = useState(0);
|
||||||
|
|
||||||
const Buttons = row => (
|
const Buttons = row => (
|
||||||
<>
|
<>
|
||||||
<Button icon={<Code />} size="small" onClick={() => setShowCode(row)}>
|
{row.share_id && (
|
||||||
<div>Get Code</div>
|
<Button
|
||||||
</Button>
|
icon={<Link />}
|
||||||
|
size="small"
|
||||||
|
tooltip="Share URL"
|
||||||
|
tooltipId={`button-share-${row.website_id}`}
|
||||||
|
onClick={() => setShowUrl(row)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
icon={<Code />}
|
||||||
|
size="small"
|
||||||
|
tooltip="Get tracking code"
|
||||||
|
tooltipId={`button-code-${row.website_id}`}
|
||||||
|
onClick={() => setShowCode(row)}
|
||||||
|
/>
|
||||||
<Button icon={<Pen />} size="small" onClick={() => setEditWebsite(row)}>
|
<Button icon={<Pen />} size="small" onClick={() => setEditWebsite(row)}>
|
||||||
<div>Edit</div>
|
<div>Edit</div>
|
||||||
</Button>
|
</Button>
|
||||||
@ -56,6 +72,7 @@ export default function WebsiteSettings() {
|
|||||||
setEditWebsite(null);
|
setEditWebsite(null);
|
||||||
setDeleteWebsite(null);
|
setDeleteWebsite(null);
|
||||||
setShowCode(null);
|
setShowCode(null);
|
||||||
|
setShowUrl(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
@ -108,7 +125,12 @@ export default function WebsiteSettings() {
|
|||||||
)}
|
)}
|
||||||
{showCode && (
|
{showCode && (
|
||||||
<Modal title="Tracking code">
|
<Modal title="Tracking code">
|
||||||
<WebsiteCodeForm values={showCode} onClose={handleClose} />
|
<TrackingCodeForm values={showCode} onClose={handleClose} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
{showUrl && (
|
||||||
|
<Modal title="Share URL">
|
||||||
|
<ShareUrlForm values={showUrl} onClose={handleClose} />
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import ReactTooltip from 'react-tooltip';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Icon from './Icon';
|
import Icon from './Icon';
|
||||||
import styles from './Button.module.css';
|
import styles from './Button.module.css';
|
||||||
@ -10,10 +11,15 @@ export default function Button({
|
|||||||
variant,
|
variant,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
tooltip,
|
||||||
|
tooltipId,
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
data-tip={tooltip}
|
||||||
|
data-effect="solid"
|
||||||
|
data-for={tooltipId}
|
||||||
type={type}
|
type={type}
|
||||||
className={classNames(styles.button, className, {
|
className={classNames(styles.button, className, {
|
||||||
[styles.large]: size === 'large',
|
[styles.large]: size === 'large',
|
||||||
@ -26,6 +32,7 @@ export default function Button({
|
|||||||
>
|
>
|
||||||
{icon && <Icon icon={icon} size={size} />}
|
{icon && <Icon icon={icon} size={size} />}
|
||||||
{children}
|
{children}
|
||||||
|
{tooltip && <ReactTooltip id={tooltipId}>{tooltip}</ReactTooltip>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:hover {
|
.button:hover {
|
||||||
|
27
components/common/Checkbox.js
Normal file
27
components/common/Checkbox.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import Icon from 'components/common/Icon';
|
||||||
|
import Check from 'assets/check.svg';
|
||||||
|
import styles from './Checkbox.module.css';
|
||||||
|
|
||||||
|
export default function Checkbox({ name, value, label, onChange }) {
|
||||||
|
const ref = useRef();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.checkbox} onClick={() => ref.current.click()}>
|
||||||
|
{value && <Icon icon={<Check />} size="small" />}
|
||||||
|
</div>
|
||||||
|
<label className={styles.label} htmlFor={name}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={styles.input}
|
||||||
|
type="checkbox"
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
27
components/common/Checkbox.module.css
Normal file
27
components/common/Checkbox.module.css
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 1px solid var(--gray500);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
position: absolute;
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
bottom: -1px;
|
||||||
|
right: -1px;
|
||||||
|
}
|
@ -49,7 +49,7 @@ export default function DeleteForm({ values, onSave, onClose }) {
|
|||||||
Type <b>DELETE</b> in the box below to confirm.
|
Type <b>DELETE</b> in the box below to confirm.
|
||||||
</p>
|
</p>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<Field name="confirmation" />
|
<Field name="confirmation" type="text" />
|
||||||
<FormError name="confirmation" />
|
<FormError name="confirmation" />
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormButtons>
|
<FormButtons>
|
||||||
|
30
components/forms/ShareUrlForm.js
Normal file
30
components/forms/ShareUrlForm.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import Button from 'components/common/Button';
|
||||||
|
import FormLayout, { FormButtons, FormRow } from 'components/layout/FormLayout';
|
||||||
|
import CopyButton from '../common/CopyButton';
|
||||||
|
|
||||||
|
export default function TrackingCodeForm({ values, onClose }) {
|
||||||
|
const ref = useRef();
|
||||||
|
const { name, share_id } = values;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormLayout>
|
||||||
|
<p>
|
||||||
|
This is the public URL for <b>{values.name}</b>.
|
||||||
|
</p>
|
||||||
|
<FormRow>
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
rows={3}
|
||||||
|
cols={60}
|
||||||
|
defaultValue={`${document.location.origin}/share/${share_id}/${name}`}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormButtons>
|
||||||
|
<CopyButton type="submit" variant="action" element={ref} />
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
</FormButtons>
|
||||||
|
</FormLayout>
|
||||||
|
);
|
||||||
|
}
|
@ -3,7 +3,7 @@ import Button from 'components/common/Button';
|
|||||||
import FormLayout, { FormButtons, FormRow } from 'components/layout/FormLayout';
|
import FormLayout, { FormButtons, FormRow } from 'components/layout/FormLayout';
|
||||||
import CopyButton from '../common/CopyButton';
|
import CopyButton from '../common/CopyButton';
|
||||||
|
|
||||||
export default function WebsiteCodeForm({ values, onClose }) {
|
export default function TrackingCodeForm({ values, onClose }) {
|
||||||
const ref = useRef();
|
const ref = useRef();
|
||||||
|
|
||||||
return (
|
return (
|
@ -8,10 +8,12 @@ import FormLayout, {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
FormRow,
|
FormRow,
|
||||||
} from 'components/layout/FormLayout';
|
} from 'components/layout/FormLayout';
|
||||||
|
import Checkbox from '../common/Checkbox';
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
name: '',
|
name: '',
|
||||||
domain: '',
|
domain: '',
|
||||||
|
public: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const validate = ({ name, domain }) => {
|
const validate = ({ name, domain }) => {
|
||||||
@ -43,7 +45,7 @@ export default function WebsiteEditForm({ values, onSave, onClose }) {
|
|||||||
return (
|
return (
|
||||||
<FormLayout>
|
<FormLayout>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{ ...initialValues, ...values }}
|
initialValues={{ ...initialValues, ...values, make_public: !!values?.share_id }}
|
||||||
validate={validate}
|
validate={validate}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
@ -59,6 +61,12 @@ export default function WebsiteEditForm({ values, onSave, onClose }) {
|
|||||||
<Field name="domain" type="text" />
|
<Field name="domain" type="text" />
|
||||||
<FormError name="domain" />
|
<FormError name="domain" />
|
||||||
</FormRow>
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<label></label>
|
||||||
|
<Field name="make_public">
|
||||||
|
{({ field }) => <Checkbox {...field} label="Make public" />}
|
||||||
|
</Field>
|
||||||
|
</FormRow>
|
||||||
<FormButtons>
|
<FormButtons>
|
||||||
<Button type="submit" variant="action">
|
<Button type="submit" variant="action">
|
||||||
Save
|
Save
|
||||||
|
@ -1,11 +1,23 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import Button from 'components/common/Button';
|
||||||
|
import Logo from 'assets/logo.svg';
|
||||||
import styles from './Footer.module.css';
|
import styles from './Footer.module.css';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className={classNames(styles.footer, 'container mt-5 mb-5')}>
|
<footer className="container">
|
||||||
umami - deliciously simple web stats
|
<div className={classNames(styles.footer, 'row justify-content-center')}>
|
||||||
|
<div>powered by</div>
|
||||||
|
<Link href="https://umami.is">
|
||||||
|
<a>
|
||||||
|
<Button icon={<Logo />} size="small">
|
||||||
|
<b>umami</b>
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,14 @@
|
|||||||
.footer {
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer button {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
@ -11,8 +11,8 @@ export default function Header() {
|
|||||||
const user = useSelector(state => state.user);
|
const user = useSelector(state => state.user);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={classNames(styles.header, 'container')}>
|
<header className="container">
|
||||||
<div className="row align-items-center">
|
<div className={classNames(styles.header, 'row align-items-center')}>
|
||||||
<div className="col">
|
<div className="col">
|
||||||
<div className={styles.title}>
|
<div className={styles.title}>
|
||||||
<Icon icon={<Logo />} size="large" className={styles.logo} />
|
<Icon icon={<Logo />} size="large" className={styles.logo} />
|
||||||
|
@ -5,6 +5,7 @@ import { JWT, JWE, JWK } from 'jose';
|
|||||||
|
|
||||||
const SALT_ROUNDS = 10;
|
const SALT_ROUNDS = 10;
|
||||||
const KEY = JWK.asKey(Buffer.from(secret()));
|
const KEY = JWK.asKey(Buffer.from(secret()));
|
||||||
|
const CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
|
||||||
export function hash(...args) {
|
export function hash(...args) {
|
||||||
return crypto.createHash('sha512').update(args.join('')).digest('hex');
|
return crypto.createHash('sha512').update(args.join('')).digest('hex');
|
||||||
@ -24,6 +25,14 @@ export function isValidId(s) {
|
|||||||
return validate(s);
|
return validate(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRandomChars(n) {
|
||||||
|
let s = '';
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
s += CHARS[Math.floor(Math.random() * CHARS.length)];
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
export async function hashPassword(password) {
|
export async function hashPassword(password) {
|
||||||
return bcrypt.hash(password, SALT_ROUNDS);
|
return bcrypt.hash(password, SALT_ROUNDS);
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,16 @@ export async function getWebsiteByUuid(website_uuid) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWebsiteByShareId(share_id) {
|
||||||
|
return runQuery(
|
||||||
|
prisma.website.findOne({
|
||||||
|
where: {
|
||||||
|
share_id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserWebsites(user_id) {
|
export async function getUserWebsites(user_id) {
|
||||||
return runQuery(
|
return runQuery(
|
||||||
prisma.website.findMany({
|
prisma.website.findMany({
|
||||||
|
@ -8,22 +8,26 @@ export function redirect(res, url) {
|
|||||||
return res.status(303).end();
|
return res.status(303).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function badRequest(res, msg) {
|
export function badRequest(res, msg = '400 Bad Request') {
|
||||||
return res.status(400).end(msg);
|
return res.status(400).end(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unauthorized(res, msg) {
|
export function unauthorized(res, msg = '401 Unauthorized') {
|
||||||
return res.status(401).end(msg);
|
return res.status(401).end(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function forbidden(res, msg) {
|
export function forbidden(res, msg = '403 Forbidden') {
|
||||||
return res.status(403).end(msg);
|
return res.status(403).end(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function methodNotAllowed(res, msg) {
|
export function notFound(res, msg = '404 Not Found') {
|
||||||
|
return res.status(404).end(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function methodNotAllowed(res, msg = '405 Method Not Allowed') {
|
||||||
res.status(405).end(msg);
|
res.status(405).end(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serverError(res, msg) {
|
export function serverError(res, msg = '500 Internal Server Error') {
|
||||||
res.status(500).end(msg);
|
res.status(500).end(msg);
|
||||||
}
|
}
|
||||||
|
@ -53,10 +53,10 @@
|
|||||||
"jose": "^1.28.0",
|
"jose": "^1.28.0",
|
||||||
"maxmind": "^4.1.4",
|
"maxmind": "^4.1.4",
|
||||||
"moment-timezone": "^0.5.31",
|
"moment-timezone": "^0.5.31",
|
||||||
"next": "9.5.2",
|
"next": "^9.5.2",
|
||||||
"promise-polyfill": "^8.1.3",
|
"promise-polyfill": "^8.1.3",
|
||||||
"react": "16.13.1",
|
"react": "^16.13.1",
|
||||||
"react-dom": "16.13.1",
|
"react-dom": "^16.13.1",
|
||||||
"react-redux": "^7.2.1",
|
"react-redux": "^7.2.1",
|
||||||
"react-simple-maps": "^2.1.2",
|
"react-simple-maps": "^2.1.2",
|
||||||
"react-spring": "^8.0.27",
|
"react-spring": "^8.0.27",
|
||||||
|
@ -4,7 +4,9 @@ import Layout from 'components/layout/Layout';
|
|||||||
export default function Custom404() {
|
export default function Custom404() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<h1>oops! not found</h1>
|
<div className="row justify-content-center">
|
||||||
|
<h1>oops! page not found</h1>
|
||||||
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
18
pages/api/share/[id].js
Normal file
18
pages/api/share/[id].js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { getWebsiteByShareId } from 'lib/queries';
|
||||||
|
import { ok, notFound, methodNotAllowed } from 'lib/response';
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
const { id } = req.query;
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
const website = await getWebsiteByShareId(id);
|
||||||
|
|
||||||
|
if (website) {
|
||||||
|
return ok(res, website);
|
||||||
|
}
|
||||||
|
|
||||||
|
return notFound(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
return methodNotAllowed(res);
|
||||||
|
};
|
@ -1,13 +0,0 @@
|
|||||||
import { parseSecureToken } from 'lib/crypto';
|
|
||||||
import { ok, badRequest } from 'lib/response';
|
|
||||||
|
|
||||||
export default async (req, res) => {
|
|
||||||
const { token } = req.body;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = await parseSecureToken(token);
|
|
||||||
return ok(res, payload);
|
|
||||||
} catch {
|
|
||||||
return badRequest(res);
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,22 +1,31 @@
|
|||||||
import { updateWebsite, createWebsite, getWebsiteById } from 'lib/queries';
|
import { updateWebsite, createWebsite, getWebsiteById } from 'lib/queries';
|
||||||
import { useAuth } from 'lib/middleware';
|
import { useAuth } from 'lib/middleware';
|
||||||
import { uuid } from 'lib/crypto';
|
import { uuid, getRandomChars } from 'lib/crypto';
|
||||||
import { ok, unauthorized, methodNotAllowed } from 'lib/response';
|
import { ok, unauthorized, methodNotAllowed } from 'lib/response';
|
||||||
|
|
||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
await useAuth(req, res);
|
await useAuth(req, res);
|
||||||
|
|
||||||
const { user_id, is_admin } = req.auth;
|
const { user_id, is_admin } = req.auth;
|
||||||
const { website_id } = req.body;
|
const { website_id, make_public } = req.body;
|
||||||
|
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
const { name, domain } = req.body;
|
const { name, domain } = req.body;
|
||||||
|
|
||||||
if (website_id) {
|
if (website_id) {
|
||||||
const website = getWebsiteById(website_id);
|
const website = await getWebsiteById(website_id);
|
||||||
|
|
||||||
if (website.user_id === user_id || is_admin) {
|
if (website.user_id === user_id || is_admin) {
|
||||||
await updateWebsite(website_id, { name, domain });
|
let { share_id } = website;
|
||||||
|
console.log('exising id', share_id, website);
|
||||||
|
|
||||||
|
if (make_public) {
|
||||||
|
share_id = share_id ? share_id : getRandomChars(8);
|
||||||
|
} else {
|
||||||
|
share_id = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateWebsite(website_id, { name, domain, share_id });
|
||||||
|
|
||||||
return ok(res);
|
return ok(res);
|
||||||
}
|
}
|
||||||
@ -24,7 +33,8 @@ export default async (req, res) => {
|
|||||||
return unauthorized(res);
|
return unauthorized(res);
|
||||||
} else {
|
} else {
|
||||||
const website_uuid = uuid();
|
const website_uuid = uuid();
|
||||||
const website = await createWebsite(user_id, { website_uuid, name, domain });
|
const share_id = make_public ? getRandomChars(8) : null;
|
||||||
|
const website = await createWebsite(user_id, { website_uuid, name, domain, share_id });
|
||||||
|
|
||||||
return ok(res, website);
|
return ok(res, website);
|
||||||
}
|
}
|
||||||
|
@ -3,9 +3,6 @@ import { useAuth } from 'lib/middleware';
|
|||||||
import { methodNotAllowed, ok, unauthorized } from 'lib/response';
|
import { methodNotAllowed, ok, unauthorized } from 'lib/response';
|
||||||
|
|
||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
await useAuth(req, res);
|
|
||||||
|
|
||||||
const { user_id, is_admin } = req.auth;
|
|
||||||
const { id } = req.query;
|
const { id } = req.query;
|
||||||
const website_id = +id;
|
const website_id = +id;
|
||||||
|
|
||||||
@ -16,6 +13,9 @@ export default async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'DELETE') {
|
if (req.method === 'DELETE') {
|
||||||
|
await useAuth(req, res);
|
||||||
|
const { user_id, is_admin } = req.auth;
|
||||||
|
|
||||||
const website = await getWebsiteById(website_id);
|
const website = await getWebsiteById(website_id);
|
||||||
|
|
||||||
if (website.user_id === user_id || is_admin) {
|
if (website.user_id === user_id || is_admin) {
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
import { getMetrics } from 'lib/queries';
|
import { getMetrics } from 'lib/queries';
|
||||||
import { useAuth } from 'lib/middleware';
|
|
||||||
import { ok } from 'lib/response';
|
import { ok } from 'lib/response';
|
||||||
|
|
||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
await useAuth(req, res);
|
|
||||||
|
|
||||||
const { id, start_at, end_at } = req.query;
|
const { id, start_at, end_at } = req.query;
|
||||||
|
|
||||||
const metrics = await getMetrics(+id, new Date(+start_at), new Date(+end_at));
|
const metrics = await getMetrics(+id, new Date(+start_at), new Date(+end_at));
|
||||||
|
@ -1,13 +1,10 @@
|
|||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
import { getPageviews } from 'lib/queries';
|
import { getPageviews } from 'lib/queries';
|
||||||
import { useAuth } from 'lib/middleware';
|
|
||||||
import { ok, badRequest } from 'lib/response';
|
import { ok, badRequest } from 'lib/response';
|
||||||
|
|
||||||
const unitTypes = ['month', 'hour', 'day'];
|
const unitTypes = ['month', 'hour', 'day'];
|
||||||
|
|
||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
await useAuth(req, res);
|
|
||||||
|
|
||||||
const { id, start_at, end_at, unit, tz } = req.query;
|
const { id, start_at, end_at, unit, tz } = req.query;
|
||||||
|
|
||||||
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) {
|
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) {
|
||||||
|
@ -1,13 +1,10 @@
|
|||||||
import { getRankings } from 'lib/queries';
|
import { getRankings } from 'lib/queries';
|
||||||
import { useAuth } from 'lib/middleware';
|
|
||||||
import { ok, badRequest } from 'lib/response';
|
import { ok, badRequest } from 'lib/response';
|
||||||
|
|
||||||
const sessionColumns = ['browser', 'os', 'device', 'country'];
|
const sessionColumns = ['browser', 'os', 'device', 'country'];
|
||||||
const pageviewColumns = ['url', 'referrer'];
|
const pageviewColumns = ['url', 'referrer'];
|
||||||
|
|
||||||
export default async (req, res) => {
|
export default async (req, res) => {
|
||||||
await useAuth(req, res);
|
|
||||||
|
|
||||||
const { id, type, start_at, end_at } = req.query;
|
const { id, type, start_at, end_at } = req.query;
|
||||||
|
|
||||||
if (!sessionColumns.includes(type) && !pageviewColumns.includes(type)) {
|
if (!sessionColumns.includes(type) && !pageviewColumns.includes(type)) {
|
||||||
|
39
pages/share/[...id].js
Normal file
39
pages/share/[...id].js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import Layout from 'components/layout/Layout';
|
||||||
|
import WebsiteDetails from 'components/WebsiteDetails';
|
||||||
|
import NotFound from 'pages/404';
|
||||||
|
import { get } from 'lib/web';
|
||||||
|
|
||||||
|
export default function SharePage() {
|
||||||
|
const [websiteId, setWebsiteId] = useState();
|
||||||
|
const [notFound, setNotFound] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = router.query;
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
const website = await get(`/api/share/${id?.[0]}`);
|
||||||
|
|
||||||
|
if (website) {
|
||||||
|
setWebsiteId(website.website_id);
|
||||||
|
} else {
|
||||||
|
setNotFound(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (!id || notFound) {
|
||||||
|
return <NotFound />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<WebsiteDetails websiteId={websiteId} />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
@ -13,9 +13,11 @@ export default function DetailsPage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [websiteId] = id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<WebsiteDetails websiteId={+id[0]} />
|
<WebsiteDetails websiteId={websiteId} />
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -75,6 +75,7 @@ model website {
|
|||||||
name String
|
name String
|
||||||
domain String?
|
domain String?
|
||||||
created_at DateTime? @default(now())
|
created_at DateTime? @default(now())
|
||||||
|
share_id String? @unique
|
||||||
account account @relation(fields: [user_id], references: [user_id])
|
account account @relation(fields: [user_id], references: [user_id])
|
||||||
event event[]
|
event event[]
|
||||||
pageview pageview[]
|
pageview pageview[]
|
||||||
|
@ -75,6 +75,7 @@ model website {
|
|||||||
created_at DateTime? @default(now())
|
created_at DateTime? @default(now())
|
||||||
user_id Int
|
user_id Int
|
||||||
domain String?
|
domain String?
|
||||||
|
share_id String? @unique
|
||||||
account account @relation(fields: [user_id], references: [user_id])
|
account account @relation(fields: [user_id], references: [user_id])
|
||||||
event event[]
|
event event[]
|
||||||
pageview pageview[]
|
pageview pageview[]
|
||||||
|
@ -19,6 +19,7 @@ create table website (
|
|||||||
user_id int unsigned not null,
|
user_id int unsigned not null,
|
||||||
name varchar(100) not null,
|
name varchar(100) not null,
|
||||||
domain varchar(500),
|
domain varchar(500),
|
||||||
|
share_id varchar(8) unique,
|
||||||
created_at timestamp default current_timestamp,
|
created_at timestamp default current_timestamp,
|
||||||
foreign key (user_id) references account(user_id) on delete cascade
|
foreign key (user_id) references account(user_id) on delete cascade
|
||||||
) ENGINE=InnoDB;
|
) ENGINE=InnoDB;
|
||||||
@ -99,4 +100,4 @@ begin
|
|||||||
end if;
|
end if;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
insert into account (username, password, is_admin) values ('admin', '$2a$10$BXHPV7APlV1I6WrKJt1igeJAyVsvbhMTaTAi3nHkUJFGPsYmfZq3y', true);
|
insert into account (username, password, is_admin) values ('admin', '$2a$10$jsVC1XMAIIQtL0On8twztOmAr20YTVcsd4.yJncKspEwsBkeq6VFW', true);
|
@ -19,6 +19,7 @@ create table website (
|
|||||||
user_id int not null references account(user_id) on delete cascade,
|
user_id int not null references account(user_id) on delete cascade,
|
||||||
name varchar(100) not null,
|
name varchar(100) not null,
|
||||||
domain varchar(500),
|
domain varchar(500),
|
||||||
|
share_id varchar(8) unique,
|
||||||
created_at timestamp with time zone default current_timestamp
|
created_at timestamp with time zone default current_timestamp
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -68,4 +69,4 @@ create index event_created_at_idx on event(created_at);
|
|||||||
create index event_website_id_idx on event(website_id);
|
create index event_website_id_idx on event(website_id);
|
||||||
create index event_session_id_idx on event(session_id);
|
create index event_session_id_idx on event(session_id);
|
||||||
|
|
||||||
insert into account (username, password, is_admin) values ('admin', '$2a$10$BXHPV7APlV1I6WrKJt1igeJAyVsvbhMTaTAi3nHkUJFGPsYmfZq3y', true);
|
insert into account (username, password, is_admin) values ('admin', '$2a$10$jsVC1XMAIIQtL0On8twztOmAr20YTVcsd4.yJncKspEwsBkeq6VFW', true);
|
@ -47,7 +47,8 @@ a:visited {
|
|||||||
color: var(--primary400);
|
color: var(--primary400);
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input[type='text'],
|
||||||
|
input[type='password'],
|
||||||
textarea {
|
textarea {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: var(--font-size-normal);
|
font-size: var(--font-size-normal);
|
||||||
@ -59,11 +60,19 @@ textarea {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'] + label {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-right: 20px;
|
margin-right: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
label:empty {
|
||||||
|
flex: 0;
|
||||||
|
}
|
||||||
|
|
||||||
dt {
|
dt {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
@ -5609,7 +5609,7 @@ next-tick@~1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
|
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
|
||||||
integrity sha1-yobR/ogoFpsBICCOPchCS524NCw=
|
integrity sha1-yobR/ogoFpsBICCOPchCS524NCw=
|
||||||
|
|
||||||
next@9.5.2:
|
next@^9.5.2:
|
||||||
version "9.5.2"
|
version "9.5.2"
|
||||||
resolved "https://registry.yarnpkg.com/next/-/next-9.5.2.tgz#ef9b77455b32dca0e917c763529de25c11b5c442"
|
resolved "https://registry.yarnpkg.com/next/-/next-9.5.2.tgz#ef9b77455b32dca0e917c763529de25c11b5c442"
|
||||||
integrity sha512-wasNxEE4tXXalPoUc7B5Ph3tpByIo7IqodE9iHhp61B/3/vG2zi2BGnCJjZQwFeuotUEQl93krz/0Tp4vd0DsQ==
|
integrity sha512-wasNxEE4tXXalPoUc7B5Ph3tpByIo7IqodE9iHhp61B/3/vG2zi2BGnCJjZQwFeuotUEQl93krz/0Tp4vd0DsQ==
|
||||||
@ -6873,7 +6873,7 @@ rc@^1.2.7:
|
|||||||
minimist "^1.2.0"
|
minimist "^1.2.0"
|
||||||
strip-json-comments "~2.0.1"
|
strip-json-comments "~2.0.1"
|
||||||
|
|
||||||
react-dom@16.13.1:
|
react-dom@^16.13.1:
|
||||||
version "16.13.1"
|
version "16.13.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f"
|
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f"
|
||||||
integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==
|
integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==
|
||||||
@ -6943,7 +6943,7 @@ react-window@^1.8.5:
|
|||||||
"@babel/runtime" "^7.0.0"
|
"@babel/runtime" "^7.0.0"
|
||||||
memoize-one ">=3.1.1 <6"
|
memoize-one ">=3.1.1 <6"
|
||||||
|
|
||||||
react@16.13.1:
|
react@^16.13.1:
|
||||||
version "16.13.1"
|
version "16.13.1"
|
||||||
resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"
|
resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"
|
||||||
integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==
|
integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==
|
||||||
|
Loading…
x
Reference in New Issue
Block a user