diff --git a/components/common/RefreshButton.js b/components/common/RefreshButton.js index 0b197bd2..be1a8a1d 100644 --- a/components/common/RefreshButton.js +++ b/components/common/RefreshButton.js @@ -11,7 +11,7 @@ import useDateRange from 'hooks/useDateRange'; function RefreshButton({ websiteId }) { const [dateRange] = useDateRange(websiteId); const [loading, setLoading] = useState(false); - const selector = useCallback(state => state[`/website/${websiteId}/stats`], [websiteId]); + const selector = useCallback(state => state[`/websites/${websiteId}/stats`], [websiteId]); const completed = useStore(selector); function handleClick() { diff --git a/components/forms/AccountEditForm.js b/components/forms/AccountEditForm.js index 86825629..bedc3b82 100644 --- a/components/forms/AccountEditForm.js +++ b/components/forms/AccountEditForm.js @@ -34,7 +34,7 @@ export default function AccountEditForm({ values, onSave, onClose }) { const handleSubmit = async values => { const { user_id } = values; - const { ok, data } = await post(user_id ? `/account/${user_id}` : '/account', values); + const { ok, data } = await post(user_id ? `/accounts/${user_id}` : '/accounts', values); if (ok) { onSave(); diff --git a/components/forms/ChangePasswordForm.js b/components/forms/ChangePasswordForm.js index 29f34521..4870d523 100644 --- a/components/forms/ChangePasswordForm.js +++ b/components/forms/ChangePasswordForm.js @@ -9,6 +9,7 @@ import FormLayout, { FormRow, } from 'components/layout/FormLayout'; import useApi from 'hooks/useApi'; +import useUser from '../../hooks/useUser'; const initialValues = { current_password: '', @@ -39,9 +40,10 @@ const validate = ({ current_password, new_password, confirm_password }) => { export default function ChangePasswordForm({ values, onSave, onClose }) { const { post } = useApi(); const [message, setMessage] = useState(); + const { user } = useUser(); const handleSubmit = async values => { - const { ok, data } = await post('/account/password', values); + const { ok, data } = await post(`/accounts/${user.user_id}/password`, values); if (ok) { onSave(); diff --git a/components/forms/WebsiteEditForm.js b/components/forms/WebsiteEditForm.js index 80a6dfc0..21daeeaf 100644 --- a/components/forms/WebsiteEditForm.js +++ b/components/forms/WebsiteEditForm.js @@ -38,7 +38,6 @@ const validate = ({ name, domain }) => { }; const OwnerDropDown = ({ user, accounts }) => { - console.info(styles); const { setFieldValue, values } = useFormikContext(); useEffect(() => { @@ -79,7 +78,8 @@ export default function WebsiteEditForm({ values, onSave, onClose }) { const [message, setMessage] = useState(); const handleSubmit = async values => { - const { ok, data } = await post('/website', values); + const { website_id } = values; + const { ok, data } = await post(website_id ? `/websites/${website_id}` : '/websites', values); if (ok) { onSave(); @@ -137,6 +137,7 @@ export default function WebsiteEditForm({ values, onSave, onClose }) { defaultMessage="Enable share URL" /> } + value={null} /> )} diff --git a/components/metrics/ActiveUsers.js b/components/metrics/ActiveUsers.js index 653fc783..0ab266d3 100644 --- a/components/metrics/ActiveUsers.js +++ b/components/metrics/ActiveUsers.js @@ -6,7 +6,7 @@ import Dot from 'components/common/Dot'; import styles from './ActiveUsers.module.css'; export default function ActiveUsers({ websiteId, className, value, interval = 60000 }) { - const url = websiteId ? `/website/${websiteId}/active` : null; + const url = websiteId ? `/websites/${websiteId}/active` : null; const { data } = useFetch(url, { interval, }); diff --git a/components/metrics/EventsChart.js b/components/metrics/EventsChart.js index 50dc940d..a8735b5d 100644 --- a/components/metrics/EventsChart.js +++ b/components/metrics/EventsChart.js @@ -16,7 +16,7 @@ export default function EventsChart({ websiteId, className, token }) { } = usePageQuery(); const { data, loading } = useFetch( - `/website/${websiteId}/events`, + `/websites/${websiteId}/events`, { params: { start_at: +startDate, diff --git a/components/metrics/MetricsBar.js b/components/metrics/MetricsBar.js index 21928b61..aa260c8b 100644 --- a/components/metrics/MetricsBar.js +++ b/components/metrics/MetricsBar.js @@ -19,7 +19,7 @@ export default function MetricsBar({ websiteId, className }) { } = usePageQuery(); const { data, error, loading } = useFetch( - `/website/${websiteId}/stats`, + `/websites/${websiteId}/stats`, { params: { start_at: +startDate, diff --git a/components/metrics/MetricsTable.js b/components/metrics/MetricsTable.js index fc62e518..9079d190 100644 --- a/components/metrics/MetricsTable.js +++ b/components/metrics/MetricsTable.js @@ -38,7 +38,7 @@ export default function MetricsTable({ const { formatMessage } = useIntl(); const { data, loading, error } = useFetch( - `/website/${websiteId}/metrics`, + `/websites/${websiteId}/metrics`, { params: { type, diff --git a/components/metrics/WebsiteChart.js b/components/metrics/WebsiteChart.js index fd74538f..48b97f26 100644 --- a/components/metrics/WebsiteChart.js +++ b/components/metrics/WebsiteChart.js @@ -35,7 +35,7 @@ export default function WebsiteChart({ const { get } = useApi(); const { data, loading, error } = useFetch( - `/website/${websiteId}/pageviews`, + `/websites/${websiteId}/pageviews`, { params: { start_at: +startDate, @@ -70,7 +70,7 @@ export default function WebsiteChart({ async function handleDateChange(value) { if (value === 'all') { - const { data, ok } = await get(`/website/${websiteId}`); + const { data, ok } = await get(`/websites/${websiteId}`); if (ok) { setDateRange({ value, ...getDateRangeValues(new Date(data.created_at), Date.now()) }); } diff --git a/components/metrics/WebsiteHeader.js b/components/metrics/WebsiteHeader.js index 15f6d0ff..1a6bdf15 100644 --- a/components/metrics/WebsiteHeader.js +++ b/components/metrics/WebsiteHeader.js @@ -17,8 +17,8 @@ export default function WebsiteHeader({ websiteId, title, domain, showLink = fal {title} @@ -41,8 +41,8 @@ export default function WebsiteHeader({ websiteId, title, domain, showLink = fal {showLink && ( } size="small" diff --git a/components/pages/WebsiteDetails.js b/components/pages/WebsiteDetails.js index 78c5f752..3fc234a4 100644 --- a/components/pages/WebsiteDetails.js +++ b/components/pages/WebsiteDetails.js @@ -52,7 +52,7 @@ const views = { }; export default function WebsiteDetails({ websiteId }) { - const { data } = useFetch(`/website/${websiteId}`); + const { data } = useFetch(`/websites/${websiteId}`); const [chartLoaded, setChartLoaded] = useState(false); const [countryData, setCountryData] = useState(); const [eventsData, setEventsData] = useState(); diff --git a/components/settings/WebsiteSettings.js b/components/settings/WebsiteSettings.js index 451be47f..49bd005f 100644 --- a/components/settings/WebsiteSettings.js +++ b/components/settings/WebsiteSettings.js @@ -86,8 +86,8 @@ export default function WebsiteSettings() { const DetailsLink = ({ website_id, name, domain }) => ( {name} diff --git a/db/mysql/migrations/04_account_uuid/migration.sql b/db/mysql/migrations/04_account_uuid/migration.sql new file mode 100644 index 00000000..7b7b5dea --- /dev/null +++ b/db/mysql/migrations/04_account_uuid/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE `account` ADD COLUMN `account_uuid` VARCHAR(36); + +-- Backfill UUID +UPDATE `account` SET account_uuid=(SELECT uuid()); + +-- AlterTable +ALTER TABLE `account` MODIFY `account_uuid` VARCHAR(36) NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX `account_account_uuid_key` ON `account`(`account_uuid`); diff --git a/db/mysql/schema.prisma b/db/mysql/schema.prisma index 9ad2620c..bfc2c20b 100644 --- a/db/mysql/schema.prisma +++ b/db/mysql/schema.prisma @@ -8,13 +8,14 @@ datasource db { } model account { - user_id Int @id @default(autoincrement()) @db.UnsignedInt - username String @unique() @db.VarChar(255) - password String @db.VarChar(60) - is_admin Boolean @default(false) - created_at DateTime? @default(now()) @db.Timestamp(0) - updated_at DateTime? @default(now()) @db.Timestamp(0) - website website[] + user_id Int @id @default(autoincrement()) @db.UnsignedInt + username String @unique() @db.VarChar(255) + password String @db.VarChar(60) + is_admin Boolean @default(false) + created_at DateTime? @default(now()) @db.Timestamp(0) + updated_at DateTime? @default(now()) @db.Timestamp(0) + account_uuid String @unique() @db.VarChar(36) + website website[] } model event { diff --git a/db/postgresql/migrations/04_account_uuid/migration.sql b/db/postgresql/migrations/04_account_uuid/migration.sql new file mode 100644 index 00000000..f95718d4 --- /dev/null +++ b/db/postgresql/migrations/04_account_uuid/migration.sql @@ -0,0 +1,12 @@ + +-- AlterTable +ALTER TABLE "account" ADD COLUMN "account_uuid" UUID NULL; + +-- Backfill UUID +UPDATE "account" SET account_uuid = gen_random_uuid(); + +-- AlterTable +ALTER TABLE "account" ALTER COLUMN "account_uuid" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "account_account_uuid_key" ON "account"("account_uuid"); diff --git a/db/postgresql/schema.prisma b/db/postgresql/schema.prisma index a76a3da4..d1d346de 100644 --- a/db/postgresql/schema.prisma +++ b/db/postgresql/schema.prisma @@ -8,13 +8,14 @@ datasource db { } model account { - user_id Int @id @default(autoincrement()) - username String @unique @db.VarChar(255) - password String @db.VarChar(60) - is_admin Boolean @default(false) - created_at DateTime? @default(now()) @db.Timestamptz(6) - updated_at DateTime? @default(now()) @db.Timestamptz(6) - website website[] + user_id Int @id @default(autoincrement()) + username String @unique @db.VarChar(255) + password String @db.VarChar(60) + is_admin Boolean @default(false) + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_at DateTime? @default(now()) @db.Timestamptz(6) + account_uuid String @unique @db.Uuid + website website[] } model event { diff --git a/pages/api/account/index.js b/pages/api/account/index.js deleted file mode 100644 index 236538bc..00000000 --- a/pages/api/account/index.js +++ /dev/null @@ -1,27 +0,0 @@ -import { ok, unauthorized, methodNotAllowed, badRequest, hashPassword } from 'next-basics'; -import { getAccountByUsername, createAccount } from 'queries'; -import { useAuth } from 'lib/middleware'; - -export default async (req, res) => { - if (req.method === 'POST') { - await useAuth(req, res); - - if (!req.auth.is_admin) { - return unauthorized(res); - } - - const { username, password } = req.body; - - const accountByUsername = await getAccountByUsername(username); - - if (accountByUsername) { - return badRequest(res, 'Account already exists'); - } - - const created = await createAccount({ username, password: hashPassword(password) }); - - return ok(res, created); - } - - return methodNotAllowed(res); -}; diff --git a/pages/api/account/[id].js b/pages/api/accounts/[id]/index.js similarity index 100% rename from pages/api/account/[id].js rename to pages/api/accounts/[id]/index.js diff --git a/pages/api/account/password.js b/pages/api/accounts/[id]/password.js similarity index 56% rename from pages/api/account/password.js rename to pages/api/accounts/[id]/password.js index da3a643c..96858205 100644 --- a/pages/api/account/password.js +++ b/pages/api/accounts/[id]/password.js @@ -12,24 +12,25 @@ import { export default async (req, res) => { await useAuth(req, res); - const { user_id: auth_user_id, is_admin } = req.auth; - const { user_id, current_password, new_password } = req.body; + const { user_id: currentUserId, is_admin: currentUserIsAdmin } = req.auth; + const { current_password, new_password } = req.body; + const { id } = req.query; + const userId = +id; - if (!is_admin && user_id !== auth_user_id) { + if (!currentUserIsAdmin && userId !== currentUserId) { return unauthorized(res); } if (req.method === 'POST') { - const account = await getAccountById(user_id); - const valid = checkPassword(current_password, account.password); + const account = await getAccountById(userId); - if (!valid) { + if (!checkPassword(current_password, account.password)) { return badRequest(res, 'Current password is incorrect'); } const password = hashPassword(new_password); - const updated = await updateAccount(user_id, { password }); + const updated = await updateAccount(userId, { password }); return ok(res, updated); } diff --git a/pages/api/accounts/index.js b/pages/api/accounts/index.js index 42b96a11..acaf1451 100644 --- a/pages/api/accounts/index.js +++ b/pages/api/accounts/index.js @@ -1,6 +1,7 @@ -import { getAccounts } from 'queries'; +import { ok, unauthorized, methodNotAllowed, badRequest, hashPassword } from 'next-basics'; import { useAuth } from 'lib/middleware'; -import { ok, unauthorized, methodNotAllowed } from 'next-basics'; +import { uuid } from 'lib/crypto'; +import { createAccount, getAccountByUsername, getAccounts } from 'queries'; export default async (req, res) => { await useAuth(req, res); @@ -17,5 +18,29 @@ export default async (req, res) => { return ok(res, accounts); } + if (req.method === 'POST') { + await useAuth(req, res); + + if (!req.auth.is_admin) { + return unauthorized(res); + } + + const { username, password } = req.body; + + const accountByUsername = await getAccountByUsername(username); + + if (accountByUsername) { + return badRequest(res, 'Account already exists'); + } + + const created = await createAccount({ + username, + password: hashPassword(password), + account_uuid: uuid(), + }); + + return ok(res, created); + } + return methodNotAllowed(res); }; diff --git a/pages/api/website/[id]/index.js b/pages/api/website/[id]/index.js deleted file mode 100644 index aaebb3d4..00000000 --- a/pages/api/website/[id]/index.js +++ /dev/null @@ -1,34 +0,0 @@ -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { deleteWebsite, getWebsiteById } from 'queries'; -import { allowQuery } from 'lib/auth'; -import { useCors } from 'lib/middleware'; - -export default async (req, res) => { - const { id } = req.query; - - const websiteId = +id; - - if (req.method === 'GET') { - await useCors(req, res); - - if (!(await allowQuery(req))) { - return unauthorized(res); - } - - const website = await getWebsiteById(websiteId); - - return ok(res, website); - } - - if (req.method === 'DELETE') { - if (!(await allowQuery(req, true))) { - return unauthorized(res); - } - - await deleteWebsite(websiteId); - - return ok(res); - } - - return methodNotAllowed(res); -}; diff --git a/pages/api/website/index.js b/pages/api/website/index.js deleted file mode 100644 index ac02de85..00000000 --- a/pages/api/website/index.js +++ /dev/null @@ -1,44 +0,0 @@ -import { ok, unauthorized, methodNotAllowed, getRandomChars } from 'next-basics'; -import { updateWebsite, createWebsite, getWebsiteById } from 'queries'; -import { useAuth } from 'lib/middleware'; -import { uuid } from 'lib/crypto'; - -export default async (req, res) => { - await useAuth(req, res); - - const { user_id, is_admin } = req.auth; - const { website_id, enable_share_url } = req.body; - - if (req.method === 'POST') { - const { name, domain, owner } = req.body; - const website_owner = parseInt(owner); - - if (website_id) { - const website = await getWebsiteById(website_id); - - if (website.user_id !== user_id && !is_admin) { - return unauthorized(res); - } - - let { share_id } = website; - - if (enable_share_url) { - share_id = share_id ? share_id : getRandomChars(8); - } else { - share_id = null; - } - - await updateWebsite(website_id, { name, domain, share_id, user_id: website_owner }); - - return ok(res); - } else { - const website_uuid = uuid(); - const share_id = enable_share_url ? getRandomChars(8) : null; - const website = await createWebsite(website_owner, { website_uuid, name, domain, share_id }); - - return ok(res, website); - } - } - - return methodNotAllowed(res); -}; diff --git a/pages/api/website/[id]/active.js b/pages/api/websites/[id]/active.js similarity index 100% rename from pages/api/website/[id]/active.js rename to pages/api/websites/[id]/active.js diff --git a/pages/api/website/[id]/events.js b/pages/api/websites/[id]/events.js similarity index 100% rename from pages/api/website/[id]/events.js rename to pages/api/websites/[id]/events.js diff --git a/pages/api/websites/[id]/index.js b/pages/api/websites/[id]/index.js new file mode 100644 index 00000000..bc9cb17f --- /dev/null +++ b/pages/api/websites/[id]/index.js @@ -0,0 +1,59 @@ +import { getRandomChars, methodNotAllowed, ok, unauthorized } from 'next-basics'; +import { deleteWebsite, getWebsiteById, updateWebsite } from 'queries'; +import { allowQuery } from 'lib/auth'; +import { useAuth, useCors } from 'lib/middleware'; + +export default async (req, res) => { + const { id } = req.query; + + const websiteId = +id; + + if (req.method === 'GET') { + await useCors(req, res); + + if (!(await allowQuery(req))) { + return unauthorized(res); + } + + const website = await getWebsiteById(websiteId); + + return ok(res, website); + } + + if (req.method === 'POST') { + await useAuth(req, res); + + const { is_admin: currentUserIsAdmin, user_id: currentUserId } = req.auth; + const { name, domain, owner, enable_share_url } = req.body; + + const website = await getWebsiteById(websiteId); + + if (website.user_id !== currentUserId && !currentUserIsAdmin) { + return unauthorized(res); + } + + let { share_id } = website; + + if (enable_share_url) { + share_id = share_id ? share_id : getRandomChars(8); + } else { + share_id = null; + } + + await updateWebsite(websiteId, { name, domain, share_id, user_id: +owner }); + + return ok(res); + } + + if (req.method === 'DELETE') { + if (!(await allowQuery(req, true))) { + return unauthorized(res); + } + + await deleteWebsite(websiteId); + + return ok(res); + } + + return methodNotAllowed(res); +}; diff --git a/pages/api/website/[id]/metrics.js b/pages/api/websites/[id]/metrics.js similarity index 100% rename from pages/api/website/[id]/metrics.js rename to pages/api/websites/[id]/metrics.js diff --git a/pages/api/website/[id]/pageviews.js b/pages/api/websites/[id]/pageviews.js similarity index 100% rename from pages/api/website/[id]/pageviews.js rename to pages/api/websites/[id]/pageviews.js diff --git a/pages/api/website/[id]/reset.js b/pages/api/websites/[id]/reset.js similarity index 100% rename from pages/api/website/[id]/reset.js rename to pages/api/websites/[id]/reset.js diff --git a/pages/api/website/[id]/stats.js b/pages/api/websites/[id]/stats.js similarity index 100% rename from pages/api/website/[id]/stats.js rename to pages/api/websites/[id]/stats.js diff --git a/pages/api/websites/index.js b/pages/api/websites/index.js index 8b03a9e9..108a4a8a 100644 --- a/pages/api/websites/index.js +++ b/pages/api/websites/index.js @@ -1,6 +1,7 @@ -import { getAllWebsites, getUserWebsites } from 'queries'; +import { createWebsite, getAllWebsites, getUserWebsites } from 'queries'; +import { ok, methodNotAllowed, unauthorized, getRandomChars } from 'next-basics'; import { useAuth } from 'lib/middleware'; -import { ok, methodNotAllowed, unauthorized } from 'next-basics'; +import { uuid } from 'lib/crypto'; export default async (req, res) => { await useAuth(req, res); @@ -22,5 +23,24 @@ export default async (req, res) => { return ok(res, websites); } + if (req.method === 'POST') { + await useAuth(req, res); + + const { is_admin: currentUserIsAdmin, user_id: currentUserId } = req.auth; + const { name, domain, owner, enable_share_url } = req.body; + + const website_owner = +owner; + + if (website_owner !== currentUserId && !currentUserIsAdmin) { + return unauthorized(res); + } + + const website_uuid = uuid(); + const share_id = enable_share_url ? getRandomChars(8) : null; + const website = await createWebsite(website_owner, { website_uuid, name, domain, share_id }); + + return ok(res, website); + } + return methodNotAllowed(res); }; diff --git a/scripts/check-db.js b/scripts/check-db.js index 4de18a89..d5cc2a64 100644 --- a/scripts/check-db.js +++ b/scripts/check-db.js @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ require('dotenv').config(); const { PrismaClient } = require('@prisma/client'); const chalk = require('chalk'); @@ -39,7 +40,7 @@ async function checkConnection() { async function checkTables() { try { - await prisma.account.findFirst(); + await prisma.$queryRaw`select * from account limit 1`; success('Database tables found.'); } catch (e) {