From c380de5e15a74d2cab55195cbed72479cb7b9bc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Trembeck=C3=BD?= Date: Tue, 17 Dec 2024 21:36:37 +0100 Subject: [PATCH] rework API access to support local and deployed BE (#550) * rework API access to support local and deployed BE * add /api prefix * fix infinite redirect on root path * add headers to solve server-side issues --- .env | 23 ++++++++-- deployment/Dockerfile | 5 ++- deployment/compose-test.yaml | 12 ++++-- deployment/local-mac/Dockerfile | 6 ++- deployment/local-mac/README.md | 4 +- deployment/local-mac/compose.yaml | 12 ++++-- next.config.ts | 24 ++++------- package.json | 2 +- src/api/apiAxios.ts | 42 +++++++++++++++++++ src/api/apiInterceptor.ts | 17 ++++++++ src/components/Admin/dataProvider.ts | 25 +++++------ src/components/Admin/useAuthProvider.ts | 2 +- src/components/Archive/Archive.tsx | 4 +- .../CompetitionPage/CompetitionPage.tsx | 4 +- src/components/FileUploader/FileUploader.tsx | 5 ++- src/components/PageLayout/Footer/Footer.tsx | 6 +-- .../PageLayout/MenuMain/MenuMain.tsx | 4 +- .../PasswordResetRequest.tsx | 4 +- .../PasswordReset/PasswordReset.tsx | 4 +- src/components/Posts/Posts.tsx | 4 +- .../ProblemAdministration.tsx | 16 +++---- src/components/Problems/Discussion.tsx | 14 +++---- src/components/Problems/Problems.tsx | 10 ++--- src/components/Problems/UploadProblemForm.tsx | 5 ++- src/components/Profile/PasswordChangeForm.tsx | 5 ++- src/components/Profile/ProfileDetail.tsx | 4 +- src/components/Profile/ProfileForm.tsx | 6 +-- .../PublicationUploader.tsx | 2 +- src/components/RegisterForm/RegisterForm.tsx | 5 ++- src/components/Results/Results.tsx | 6 +-- .../SchoolSubForm/SchoolSubForm.tsx | 6 +-- .../SemesterAdministration.tsx | 15 +++---- src/components/VerifyEmail/VerifyEmail.tsx | 5 ++- src/middleware.ts | 25 +++++++++++ src/middleware/apiMiddleware.ts | 23 ++++++++++ src/pages/strom/[page].tsx | 6 +-- src/pages/strom/akcie/[[...params]].tsx | 8 ++-- src/utils/AuthContainer.tsx | 34 +++++---------- src/utils/addApiTrailingSlash.ts | 11 +++++ src/utils/debugServer.ts | 6 +++ src/utils/urlBase.ts | 10 +++++ src/utils/useDataFromURL.tsx | 6 +-- src/utils/useHasPermissions.ts | 4 +- yarn.lock | 10 ++--- 44 files changed, 301 insertions(+), 150 deletions(-) create mode 100644 src/api/apiAxios.ts create mode 100644 src/api/apiInterceptor.ts create mode 100644 src/middleware.ts create mode 100644 src/middleware/apiMiddleware.ts create mode 100644 src/utils/addApiTrailingSlash.ts create mode 100644 src/utils/debugServer.ts create mode 100644 src/utils/urlBase.ts diff --git a/.env b/.env index 4e852c89..ad22e481 100644 --- a/.env +++ b/.env @@ -1,3 +1,20 @@ -NEXT_PUBLIC_BE_PROTOCOL=http -NEXT_PUBLIC_BE_HOSTNAME=localhost -NEXT_PUBLIC_BE_PORT=8000 \ No newline at end of file +# uncomment for debug server logs +# DEBUG=true + +## developovanie proti lokalnemu BE +BE_PROTOCOL=http +BE_HOSTNAME=localhost +BE_PORT=8000 +BE_PREFIX=/api + +## developovanie proti BE na test.strom.sk +# BE_PROTOCOL=https +# BE_HOSTNAME=test.strom.sk +# BE_PORT= +# BE_PREFIX=/api + +## developovanie proti BE na strom.sk +# BE_PROTOCOL=https +# BE_HOSTNAME=strom.sk +# BE_PORT= +# BE_PREFIX=/api \ No newline at end of file diff --git a/deployment/Dockerfile b/deployment/Dockerfile index 29846121..e84345b4 100644 --- a/deployment/Dockerfile +++ b/deployment/Dockerfile @@ -1,6 +1,9 @@ FROM node:20 -ARG NEXT_PUBLIC_BE_PORT +ARG BE_PROTOCOL +ARG BE_HOSTNAME +ARG BE_PORT +ARG BE_PREFIX WORKDIR /app diff --git a/deployment/compose-test.yaml b/deployment/compose-test.yaml index d099232e..062f570f 100644 --- a/deployment/compose-test.yaml +++ b/deployment/compose-test.yaml @@ -1,4 +1,4 @@ -version: "3" +version: '3' services: webstrom-frontend: @@ -6,13 +6,19 @@ services: dockerfile: deployment/Dockerfile context: .. args: - - NEXT_PUBLIC_BE_PORT=8920 + - BE_PROTOCOL=http + - BE_HOSTNAME=localhost + - BE_PORT=8920 + - BE_PREFIX=/api image: webstrom-test-frontend environment: - PORT=8922 - - NEXT_PUBLIC_BE_PORT=8920 + - BE_PROTOCOL=http + - BE_HOSTNAME=localhost + - BE_PORT=8920 + - BE_PREFIX=/api network_mode: host diff --git a/deployment/local-mac/Dockerfile b/deployment/local-mac/Dockerfile index f077a757..c866f016 100644 --- a/deployment/local-mac/Dockerfile +++ b/deployment/local-mac/Dockerfile @@ -1,7 +1,9 @@ FROM node:20 -ARG NEXT_PUBLIC_BE_PORT -ARG NEXT_PUBLIC_BE_HOSTNAME +ARG BE_PROTOCOL +ARG BE_HOSTNAME +ARG BE_PORT +ARG BE_PREFIX WORKDIR /app diff --git a/deployment/local-mac/README.md b/deployment/local-mac/README.md index 67d48dc5..1a78dacb 100644 --- a/deployment/local-mac/README.md +++ b/deployment/local-mac/README.md @@ -9,12 +9,14 @@ Treba mat samozrejme Docker a docker-compose atd. ## Urobene zmeny oproti compose-test +TODO: Docker Desktop 4.34 priniesol [host networking](https://docs.docker.com/engine/network/drivers/host/#docker-desktop), treba revisitnut + Ked som chcel pustit Docker na macu, musel som urobil nasledujuce zmeny (poradilo ChatGPT): ### `compose.yml` - zmenil som aj meno suboru (lebo nebuildime "test" environment a.k.a test.strom.sk) -- vymena `network_mode: host` za `ports: - '3000:3000'` - network_mode vraj na macu nefunguje +- vymena `network_mode: host` za explicitne `ports: - '3000:3000'` - network_mode vraj na macu nefunguje - pridanie `- NEXT_PUBLIC_BE_HOSTNAME=host.docker.internal` - aby sa to vedelo pripojit na lokalny BE - Docker pouziva takyto hostname pre host machine localhost - zmenil som porty na 3000/8000 ako standardne pouzivame pre development - `dockerfile` a `context` cesty podla file struktury novych suborov diff --git a/deployment/local-mac/compose.yaml b/deployment/local-mac/compose.yaml index d65dbe24..fc372bc2 100644 --- a/deployment/local-mac/compose.yaml +++ b/deployment/local-mac/compose.yaml @@ -4,15 +4,19 @@ services: dockerfile: deployment/local-mac/Dockerfile context: ../.. args: - - NEXT_PUBLIC_BE_PORT=8000 - - NEXT_PUBLIC_BE_HOSTNAME=host.docker.internal + - BE_PROTOCOL=http + - BE_HOSTNAME=host.docker.internal + - BE_PORT=8000 + - BE_PREFIX=/api image: webstrom-test-frontend environment: - PORT=3000 - - NEXT_PUBLIC_BE_PORT=8000 - - NEXT_PUBLIC_BE_HOSTNAME=host.docker.internal + - BE_PROTOCOL=http + - BE_HOSTNAME=host.docker.internal + - BE_PORT=8000 + - BE_PREFIX=/api ports: - '3000:3000' diff --git a/next.config.ts b/next.config.ts index 11930d0a..63de72ee 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,25 +1,12 @@ import type {NextConfig} from 'next' const nextConfig: NextConfig = { - // docs: https://nextjs.org/docs/api-reference/next.config.js/rewrites - async rewrites() { - return [ - // rewrite API requestov na django BE (podstatne aj koncove lomitko) - { - source: '/api/:path*', - destination: `${process.env.NEXT_PUBLIC_BE_PROTOCOL}://${process.env.NEXT_PUBLIC_BE_HOSTNAME}:${process.env.NEXT_PUBLIC_BE_PORT}/:path*/`, - }, - ] - }, images: { remotePatterns: [ { - // TODO: neviem, preco to krici, treba overit, ci funguju obrazky pri ulohach - // @ts-ignore - protocol: process.env.NEXT_PUBLIC_BE_PROTOCOL, - // @ts-ignore - hostname: process.env.NEXT_PUBLIC_BE_HOSTNAME, - port: process.env.NEXT_PUBLIC_BE_PORT, + protocol: process.env.BE_PROTOCOL as 'http' | 'https' | undefined, + hostname: process.env.BE_HOSTNAME ?? 'localhost', + port: process.env.BE_PORT, pathname: '/media/**', }, ], @@ -51,6 +38,11 @@ const nextConfig: NextConfig = { return config }, + // typecheck aj eslint pustame v CI pred buildom osobitne, nemusi ich pustat aj next + typescript: {ignoreBuildErrors: true}, + eslint: {ignoreDuringBuilds: true}, + // https://nextjs.org/docs/app/building-your-application/routing/middleware#advanced-middleware-flags + skipTrailingSlashRedirect: true, experimental: { turbo: { rules: { diff --git a/package.json b/package.json index 0ec8acab..784bb27d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^14.3.1", "@testing-library/user-event": "^14.5.2", - "axios": "^1.7.7", + "axios": "^1.7.9", "clsx": "^2.1.1", "katex": "^0.16.11", "luxon": "^3.5.0", diff --git a/src/api/apiAxios.ts b/src/api/apiAxios.ts new file mode 100644 index 00000000..b70982d0 --- /dev/null +++ b/src/api/apiAxios.ts @@ -0,0 +1,42 @@ +import axios from 'axios' + +import {apiInterceptor} from '@/api/apiInterceptor' +import {debugServer} from '@/utils/debugServer' +import {getInternalBeServerUrl} from '@/utils/urlBase' + +export const newApiAxios = (base: 'server' | 'client') => { + const instance = axios.create({ + baseURL: base === 'server' ? getInternalBeServerUrl() : '/api', + // auth pozostava z comba: + // 1. `sessionid` httpOnly cookie - nastavuje a maze su server pri login/logout + // 2. CSRF hlavicka - server nastavuje cookie, ktorej hodnotu treba vlozit do hlavicky. axios riesi automaticky podla tohto configu + withXSRFToken: true, + xsrfCookieName: 'csrftoken', + xsrfHeaderName: 'X-CSRFToken', + // bez tohto sa neposielaju v requeste cookies + withCredentials: true, + }) + + if (base === 'server') { + // prvy definovany interceptor bezi posledny. logujeme finalnu URL + instance.interceptors.request.use((config) => { + const {method, url, baseURL} = config + + debugServer('[SERVER API]', method?.toUpperCase(), url && baseURL ? new URL(url, baseURL).href : url) + + config.headers['X-Forwarded-Host'] = 'test.strom.sk' + config.headers['X-Forwarded-Proto'] = 'https' + + return config + }) + } + + instance.interceptors.request.use(apiInterceptor) + + return instance +} + +// nasa "globalna" API instancia +export const apiAxios = newApiAxios('client') + +export const serverApiAxios = newApiAxios('server') diff --git a/src/api/apiInterceptor.ts b/src/api/apiInterceptor.ts new file mode 100644 index 00000000..6a3b5289 --- /dev/null +++ b/src/api/apiInterceptor.ts @@ -0,0 +1,17 @@ +import axios from 'axios' + +import {addApiTrailingSlash} from '@/utils/addApiTrailingSlash' + +type RequestInterceptor = Parameters[0] + +export const apiInterceptor: RequestInterceptor = (config) => { + if (config.url) { + const [pathname, query] = config.url.split('?') + + const newPathname = addApiTrailingSlash(pathname) + + config.url = `${newPathname}${query ? `?${query}` : ''}` + } + + return config +} diff --git a/src/components/Admin/dataProvider.ts b/src/components/Admin/dataProvider.ts index 61a13a82..725afa0f 100644 --- a/src/components/Admin/dataProvider.ts +++ b/src/components/Admin/dataProvider.ts @@ -1,7 +1,9 @@ -import axios, {isAxiosError} from 'axios' +import {isAxiosError} from 'axios' import {stringify} from 'querystring' import {DataProvider, FilterPayload, /* PaginationPayload, */ SortPayload} from 'react-admin' +import {apiAxios} from '@/api/apiAxios' + const getFilterQuery = ({q, ...otherSearchParams}: FilterPayload) => ({ ...otherSearchParams, search: q, @@ -34,8 +36,6 @@ const parseError = (error: unknown) => { return 'Nastala neznáma chyba' } -const apiUrl = '/api' - // skopirovane a dost upravene z https://github.com/bmihelac/ra-data-django-rest-framework/blob/master/src/index.ts export const dataProvider: DataProvider = { getList: async (resource, {filter, sort, pagination}) => { @@ -48,7 +48,8 @@ export const dataProvider: DataProvider = { const stringifiedQuery = stringify(query) try { - const {data} = await axios.get(`${apiUrl}/${resource}${stringifiedQuery ? `/?${stringifiedQuery}` : ''}`) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const {data} = await apiAxios.get(`/${resource}${stringifiedQuery ? `/?${stringifiedQuery}` : ''}`) // client-side pagination let pagedData = data @@ -67,7 +68,7 @@ export const dataProvider: DataProvider = { }, getOne: async (resource, params) => { try { - const {data} = await axios.get(`${apiUrl}/${resource}/${params.id}`) + const {data} = await apiAxios.get(`/${resource}/${params.id}`) return {data} } catch (e) { throw new Error(parseError(e)) @@ -75,7 +76,7 @@ export const dataProvider: DataProvider = { }, getMany: async (resource, params) => { try { - const data = await Promise.all(params.ids.map((id) => axios.get(`${apiUrl}/${resource}/${id}`))) + const data = await Promise.all(params.ids.map((id) => apiAxios.get(`/${resource}/${id}`))) return {data: data.map(({data}) => data)} } catch (e) { throw new Error(parseError(e)) @@ -88,7 +89,7 @@ export const dataProvider: DataProvider = { } try { - const {data} = await axios.get(`${apiUrl}/${resource}/?${stringify(query)}`) + const {data} = await apiAxios.get(`/${resource}/?${stringify(query)}`) return { data: data, total: data.length, @@ -105,7 +106,7 @@ export const dataProvider: DataProvider = { const body = formData ?? input try { - const {data} = await axios.patch(`${apiUrl}/${resource}/${id}`, body) + const {data} = await apiAxios.patch(`/${resource}/${id}`, body) return {data} } catch (e) { throw new Error(parseError(e)) @@ -113,7 +114,7 @@ export const dataProvider: DataProvider = { }, updateMany: async (resource, params) => { try { - const data = await Promise.all(params.ids.map((id) => axios.patch(`${apiUrl}/${resource}/${id}`, params.data))) + const data = await Promise.all(params.ids.map((id) => apiAxios.patch(`/${resource}/${id}`, params.data))) return {data: data.map(({data}) => data)} } catch (e) { throw new Error(parseError(e)) @@ -125,7 +126,7 @@ export const dataProvider: DataProvider = { const body = formData ?? input try { - const {data} = await axios.post(`${apiUrl}/${resource}`, body) + const {data} = await apiAxios.post(`/${resource}`, body) return {data} } catch (e) { throw new Error(parseError(e)) @@ -133,7 +134,7 @@ export const dataProvider: DataProvider = { }, delete: async (resource, params) => { try { - const {data} = await axios.delete(`${apiUrl}/${resource}/${params.id}`) + const {data} = await apiAxios.delete(`/${resource}/${params.id}`) return {data} } catch (e) { throw new Error(parseError(e)) @@ -141,7 +142,7 @@ export const dataProvider: DataProvider = { }, deleteMany: async (resource, params) => { try { - const data = await Promise.all(params.ids.map((id) => axios.delete(`${apiUrl}/${resource}/${id}`))) + const data = await Promise.all(params.ids.map((id) => apiAxios.delete(`/${resource}/${id}`))) return {data: data.map(({data}) => data.id)} } catch (e) { throw new Error(parseError(e)) diff --git a/src/components/Admin/useAuthProvider.ts b/src/components/Admin/useAuthProvider.ts index b9d7d617..4a90110f 100644 --- a/src/components/Admin/useAuthProvider.ts +++ b/src/components/Admin/useAuthProvider.ts @@ -19,7 +19,7 @@ export const useAuthProvider = () => { await testAuthRequest() }, checkError: async (error) => { - // rovnaky handling ako v `responseIntercepto`r v `AuthContainer` + // rovnaky handling ako v `responseInterceptor` v `AuthContainer` const status = error.response?.status if (status === 403) { diff --git a/src/components/Archive/Archive.tsx b/src/components/Archive/Archive.tsx index 525cb76a..872a74cb 100644 --- a/src/components/Archive/Archive.tsx +++ b/src/components/Archive/Archive.tsx @@ -1,8 +1,8 @@ import {Stack, Typography} from '@mui/material' import {useQuery} from '@tanstack/react-query' -import axios from 'axios' import {FC} from 'react' +import {apiAxios} from '@/api/apiAxios' import {Event, Publication} from '@/types/api/competition' import {useSeminarInfo} from '@/utils/useSeminarInfo' @@ -60,7 +60,7 @@ export const Archive: FC = () => { const {data: eventListData, isLoading: eventListIsLoading} = useQuery({ queryKey: ['competition', 'event', `competition=${seminarId}`], - queryFn: () => axios.get(`/api/competition/event/?competition=${seminarId}`), + queryFn: () => apiAxios.get(`/competition/event/?competition=${seminarId}`), }) const eventList = eventListData?.data ?? [] diff --git a/src/components/CompetitionPage/CompetitionPage.tsx b/src/components/CompetitionPage/CompetitionPage.tsx index 0f24cee8..1b1bc6ee 100644 --- a/src/components/CompetitionPage/CompetitionPage.tsx +++ b/src/components/CompetitionPage/CompetitionPage.tsx @@ -1,10 +1,10 @@ import {Stack, Typography} from '@mui/material' import Grid from '@mui/material/Unstable_Grid2' import {useQuery} from '@tanstack/react-query' -import axios from 'axios' import {useRouter} from 'next/router' import {FC, Fragment, useEffect} from 'react' +import {apiAxios} from '@/api/apiAxios' import {Link} from '@/components/Clickable/Link' import {Competition, Event, PublicationTypes} from '@/types/api/competition' import {BannerContainer} from '@/utils/BannerContainer' @@ -33,7 +33,7 @@ export const CompetitionPage: FC = ({ const {data: bannerMessage, isLoading: isBannerLoading} = useQuery({ queryKey: ['cms', 'info-banner', 'competition', id], - queryFn: () => axios.get(`/api/cms/info-banner/competition/${id}`), + queryFn: () => apiAxios.get(`/cms/info-banner/competition/${id}`), enabled: id !== -1, }) diff --git a/src/components/FileUploader/FileUploader.tsx b/src/components/FileUploader/FileUploader.tsx index 52b617af..b98e16cc 100644 --- a/src/components/FileUploader/FileUploader.tsx +++ b/src/components/FileUploader/FileUploader.tsx @@ -1,9 +1,10 @@ import {Upload} from '@mui/icons-material' import {useMutation} from '@tanstack/react-query' -import axios from 'axios' import {FC, useCallback} from 'react' import {Accept, DropzoneOptions, useDropzone} from 'react-dropzone' +import {apiAxios} from '@/api/apiAxios' + interface FileUploaderProps { uploadLink: string acceptedFormats?: Accept @@ -13,7 +14,7 @@ interface FileUploaderProps { export const FileUploader: FC = ({uploadLink, acceptedFormats, adjustFormData, refetch}) => { const {mutate: fileUpload} = useMutation({ - mutationFn: (formData: FormData) => axios.post(uploadLink, formData), + mutationFn: (formData: FormData) => apiAxios.post(uploadLink, formData), onSuccess: () => refetch(), }) diff --git a/src/components/PageLayout/Footer/Footer.tsx b/src/components/PageLayout/Footer/Footer.tsx index 6d0c037c..f2c75334 100644 --- a/src/components/PageLayout/Footer/Footer.tsx +++ b/src/components/PageLayout/Footer/Footer.tsx @@ -1,9 +1,9 @@ import {Stack} from '@mui/material' import Grid from '@mui/material/Unstable_Grid2' import {useQuery} from '@tanstack/react-query' -import axios from 'axios' import {FC} from 'react' +import {apiAxios} from '@/api/apiAxios' import {Link} from '@/components/Clickable/Link' import {Loading} from '@/components/Loading/Loading' import {ILogo, Logo} from '@/components/PageLayout/Footer/Logo' @@ -19,7 +19,7 @@ export const Footer: FC = () => { error: menuItemsError, } = useQuery({ queryKey: ['cms', 'menu-item', 'on-site', seminarId, '?footer'], - queryFn: () => axios.get(`/api/cms/menu-item/on-site/${seminarId}?type=footer`), + queryFn: () => apiAxios.get(`/cms/menu-item/on-site/${seminarId}?type=footer`), }) const menuItems = menuItemsData?.data ?? [] @@ -29,7 +29,7 @@ export const Footer: FC = () => { error: logosError, } = useQuery({ queryKey: ['cms', 'logo'], - queryFn: () => axios.get('/api/cms/logo'), + queryFn: () => apiAxios.get('/cms/logo'), }) const logos = logosData?.data.filter((logo) => !logo.disabled) ?? [] diff --git a/src/components/PageLayout/MenuMain/MenuMain.tsx b/src/components/PageLayout/MenuMain/MenuMain.tsx index aa6b52a4..32858043 100644 --- a/src/components/PageLayout/MenuMain/MenuMain.tsx +++ b/src/components/PageLayout/MenuMain/MenuMain.tsx @@ -1,9 +1,9 @@ import {Box, Drawer, Stack, Theme, useMediaQuery} from '@mui/material' import {useQuery} from '@tanstack/react-query' -import axios from 'axios' import {useRouter} from 'next/router' import {FC, useEffect, useState} from 'react' +import {apiAxios} from '@/api/apiAxios' import {Link} from '@/components/Clickable/Link' import {CloseButton} from '@/components/CloseButton/CloseButton' import {Loading} from '@/components/Loading/Loading' @@ -43,7 +43,7 @@ export const MenuMain: FC = () => { const {data: menuItemsData, isLoading: menuItemsIsLoading} = useQuery({ queryKey: ['cms', 'menu-item', 'on-site', seminarId, '?menu'], - queryFn: () => axios.get(`/api/cms/menu-item/on-site/${seminarId}?type=menu`), + queryFn: () => apiAxios.get(`/cms/menu-item/on-site/${seminarId}?type=menu`), }) const menuItems = menuItemsData?.data ?? [] diff --git a/src/components/PageLayout/PasswordResetRequest/PasswordResetRequest.tsx b/src/components/PageLayout/PasswordResetRequest/PasswordResetRequest.tsx index 7040aa9a..3d1df714 100644 --- a/src/components/PageLayout/PasswordResetRequest/PasswordResetRequest.tsx +++ b/src/components/PageLayout/PasswordResetRequest/PasswordResetRequest.tsx @@ -1,9 +1,9 @@ import {Stack} from '@mui/material' import {useMutation} from '@tanstack/react-query' -import axios from 'axios' import {FC} from 'react' import {SubmitHandler, useForm} from 'react-hook-form' +import {apiAxios} from '@/api/apiAxios' import {Button} from '@/components/Clickable/Button' import {FormInput} from '@/components/FormItems/FormInput/FormInput' import {IGeneralPostResponse} from '@/types/api/general' @@ -27,7 +27,7 @@ export const PasswordResetRequestForm: FC = ({cl const {mutate: submitFormData} = useMutation({ mutationFn: (data: PasswordResetRequestFormValues) => { - return axios.post('/api/user/password/reset', data) + return apiAxios.post('/user/password/reset', data) }, onSuccess: () => { diff --git a/src/components/PasswordReset/PasswordReset.tsx b/src/components/PasswordReset/PasswordReset.tsx index 01860aa5..d2e799df 100644 --- a/src/components/PasswordReset/PasswordReset.tsx +++ b/src/components/PasswordReset/PasswordReset.tsx @@ -1,10 +1,10 @@ import {Stack, Typography} from '@mui/material' import {useMutation} from '@tanstack/react-query' -import axios from 'axios' import {useRouter} from 'next/router' import {FC} from 'react' import {SubmitHandler, useForm} from 'react-hook-form' +import {apiAxios} from '@/api/apiAxios' import {Button} from '@/components/Clickable/Button' import {FormInput} from '@/components/FormItems/FormInput/FormInput' import {IGeneralPostResponse} from '@/types/api/general' @@ -46,7 +46,7 @@ export const PasswordResetForm: FC = ({uid, token}) => { const {mutate: submitFormData, isSuccess: isReset} = useMutation({ mutationFn: (data: PasswordResetForm) => { - return axios.post('/api/user/password/reset/confirm', transformFormData(data)) + return apiAxios.post('/user/password/reset/confirm', transformFormData(data)) }, }) diff --git a/src/components/Posts/Posts.tsx b/src/components/Posts/Posts.tsx index 590ed202..f73ffee5 100644 --- a/src/components/Posts/Posts.tsx +++ b/src/components/Posts/Posts.tsx @@ -1,8 +1,8 @@ import {Stack, Typography} from '@mui/material' import {useQuery} from '@tanstack/react-query' -import axios from 'axios' import {FC} from 'react' +import {apiAxios} from '@/api/apiAxios' import {useSeminarInfo} from '@/utils/useSeminarInfo' import {Loading} from '../Loading/Loading' @@ -15,7 +15,7 @@ export const Posts: FC = () => { error: postsError, } = useQuery({ queryKey: ['cms', 'post', 'visible'], - queryFn: () => axios.get(`/api/cms/post/visible?sites=${seminarId}`), + queryFn: () => apiAxios.get(`/cms/post/visible?sites=${seminarId}`), }) const posts = postsData?.data ?? [] diff --git a/src/components/ProblemAdministration/ProblemAdministration.tsx b/src/components/ProblemAdministration/ProblemAdministration.tsx index eec9a28e..fac443b4 100644 --- a/src/components/ProblemAdministration/ProblemAdministration.tsx +++ b/src/components/ProblemAdministration/ProblemAdministration.tsx @@ -1,11 +1,12 @@ import {FormatAlignJustify, Grading} from '@mui/icons-material' import {Stack, Typography} from '@mui/material' import {useMutation, useQuery} from '@tanstack/react-query' -import axios, {isAxiosError} from 'axios' +import {isAxiosError} from 'axios' import {useRouter} from 'next/router' import React, {FC, useCallback, useEffect, useState} from 'react' import {DropzoneOptions, useDropzone} from 'react-dropzone' +import {apiAxios} from '@/api/apiAxios' import {ProblemWithSolutions, SemesterWithProblems, SolutionAdministration} from '@/types/api/competition' import {Accept} from '@/utils/dropzone-accept' import {PageTitleContainer} from '@/utils/PageTitleContainer' @@ -44,7 +45,7 @@ export const ProblemAdministration: FC = () => { isLoading: problemIsLoading, } = useQuery({ queryKey: ['competition', 'problem-administration', problemId], - queryFn: () => axios.get(`/api/competition/problem-administration/${problemId}`), + queryFn: () => apiAxios.get(`/competition/problem-administration/${problemId}`), // router.query.params su v prvom renderi undefined, tak pustime query az so spravnym problemId enabled: problemId !== undefined, }) @@ -53,7 +54,7 @@ export const ProblemAdministration: FC = () => { const semesterId = problem?.series.semester const {data: semesterData, isLoading: semesterIsLoading} = useQuery({ queryKey: ['competition', 'semester', semesterId], - queryFn: () => axios.get(`/api/competition/semester/${semesterId}`), + queryFn: () => apiAxios.get(`/competition/semester/${semesterId}`), // router.query.params su v prvom renderi undefined, tak pustime query az so spravnym semesterId enabled: semesterId !== undefined, }) @@ -76,11 +77,10 @@ export const ProblemAdministration: FC = () => { }, [problem]) const {mutate: uploadPoints} = useMutation({ - mutationFn: (id: string) => { - return axios.post(`/api/competition/problem-administration/${id}/upload-points`, { + mutationFn: (id: string) => + apiAxios.post(`/competition/problem-administration/${id}/upload-points`, { solution_set: solutions, - }) - }, + }), onSuccess: () => refetchProblem(), }) @@ -114,7 +114,7 @@ export const ProblemAdministration: FC = () => { const {mutate: uploadZipFile, error: uploadZipFileError} = useMutation({ mutationFn: ({data, problemId}: {data: FormData; problemId?: string}) => - axios.post(`/api/competition/problem/${problemId}/upload-corrected`, data), + apiAxios.post(`/competition/problem/${problemId}/upload-corrected`, data), onSuccess: () => refetchProblem(), }) diff --git a/src/components/Problems/Discussion.tsx b/src/components/Problems/Discussion.tsx index ec4b91c6..c653c697 100644 --- a/src/components/Problems/Discussion.tsx +++ b/src/components/Problems/Discussion.tsx @@ -1,8 +1,8 @@ import {Stack, Typography} from '@mui/material' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' -import axios from 'axios' import {FC, useState} from 'react' +import {apiAxios} from '@/api/apiAxios' import {Comment, CommentState} from '@/types/api/competition' import {Profile} from '@/types/api/personal' import {AuthContainer} from '@/utils/AuthContainer' @@ -28,7 +28,7 @@ export const Discussion: FC = ({problemId, invalidateSeriesQuer const queryKey = ['competition', 'problem', problemId, 'comments'] const {data: commentsData, isLoading: commentsIsLoading} = useQuery({ queryKey, - queryFn: () => axios.get(`/api/competition/problem/${problemId}/comments`), + queryFn: () => apiAxios.get(`/competition/problem/${problemId}/comments`), }) const comments = commentsData?.data @@ -38,7 +38,7 @@ export const Discussion: FC = ({problemId, invalidateSeriesQuer const {data} = useQuery({ queryKey: ['personal', 'profiles', 'myprofile'], - queryFn: () => axios.get(`/api/personal/profiles/myprofile`), + queryFn: () => apiAxios.get(`/personal/profiles/myprofile`), enabled: isAuthed, }) const userId = data?.data.id @@ -54,7 +54,7 @@ export const Discussion: FC = ({problemId, invalidateSeriesQuer } const {mutate: addComment} = useMutation({ - mutationFn: () => axios.post(`/api/competition/problem/${problemId}/add-comment`, {text: commentText}), + mutationFn: () => apiAxios.post(`/competition/problem/${problemId}/add-comment`, {text: commentText}), onSuccess: () => { setCommentText('') invalidateCommentsAndCount() @@ -62,7 +62,7 @@ export const Discussion: FC = ({problemId, invalidateSeriesQuer }) const {mutate: publishComment} = useMutation({ - mutationFn: (id: number) => axios.post(`/api/competition/comment/${id}/publish`), + mutationFn: (id: number) => apiAxios.post(`/competition/comment/${id}/publish`), onSuccess: () => { invalidateCommentsAndCount() }, @@ -70,7 +70,7 @@ export const Discussion: FC = ({problemId, invalidateSeriesQuer const {mutate: hideComment} = useMutation({ mutationFn: ({id, hiddenResponseText}: {id: number; hiddenResponseText: string}) => - axios.post(`/api/competition/comment/${id}/hide`, {hidden_response: hiddenResponseText}), + apiAxios.post(`/competition/comment/${id}/hide`, {hidden_response: hiddenResponseText}), onSuccess: () => { invalidateCommentsAndCount() sethiddenResponseDialogId(-1) @@ -79,7 +79,7 @@ export const Discussion: FC = ({problemId, invalidateSeriesQuer }) const {mutate: confirmDeleteComment} = useMutation({ - mutationFn: (id: number) => axios.delete(`/api/competition/comment/${id}`), + mutationFn: (id: number) => apiAxios.delete(`/competition/comment/${id}`), onSuccess: () => { invalidateCommentsAndCount() }, diff --git a/src/components/Problems/Problems.tsx b/src/components/Problems/Problems.tsx index 236ff9f3..c1250c45 100644 --- a/src/components/Problems/Problems.tsx +++ b/src/components/Problems/Problems.tsx @@ -1,10 +1,10 @@ import {Stack, Typography} from '@mui/material' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' -import axios from 'axios' import {useRouter} from 'next/router' import {FC, useEffect, useState} from 'react' import {useInterval} from 'usehooks-ts' +import {apiAxios} from '@/api/apiAxios' import {Button} from '@/components/Clickable/Button' import {Link} from '@/components/Clickable/Link' import {SeriesWithProblems} from '@/types/api/competition' @@ -30,7 +30,7 @@ export const Problems: FC = () => { const {data} = useQuery({ queryKey: ['personal', 'profiles', 'myprofile'], - queryFn: () => axios.get(`/api/personal/profiles/myprofile`), + queryFn: () => apiAxios.get(`/personal/profiles/myprofile`), enabled: isAuthed, }) const profile = data?.data @@ -47,13 +47,13 @@ export const Problems: FC = () => { const {data: seriesData, isLoading: seriesIsLoading} = useQuery({ queryKey: ['competition', 'series', id.seriesId], - queryFn: () => axios.get(`/api/competition/series/${id.seriesId}`), + queryFn: () => apiAxios.get(`/competition/series/${id.seriesId}`), enabled: id.seriesId !== -1, }) const {data: bannerMessage, isLoading: isBannerLoading} = useQuery({ queryKey: ['cms', 'info-banner', 'series-problems', id.seriesId], - queryFn: () => axios.get(`/api/cms/info-banner/series-problems/${id.seriesId}`), + queryFn: () => apiAxios.get(`/cms/info-banner/series-problems/${id.seriesId}`), enabled: id.seriesId !== -1, }) @@ -87,7 +87,7 @@ export const Problems: FC = () => { }, [setBannerMessages, isBannerLoading, bannerMessages]) const {mutate: registerToSemester} = useMutation({ - mutationFn: (id: number) => axios.post(`/api/competition/event/${id}/register`), + mutationFn: (id: number) => apiAxios.post(`/competition/event/${id}/register`), onSuccess: () => { // refetch semestra, nech sa aktualizuje is_registered invalidateSeriesQuery() diff --git a/src/components/Problems/UploadProblemForm.tsx b/src/components/Problems/UploadProblemForm.tsx index ae904202..671e2630 100644 --- a/src/components/Problems/UploadProblemForm.tsx +++ b/src/components/Problems/UploadProblemForm.tsx @@ -1,9 +1,9 @@ import {Typography} from '@mui/material' import {useMutation} from '@tanstack/react-query' -import axios from 'axios' import {Dispatch, FC, SetStateAction, useState} from 'react' import {useDropzone} from 'react-dropzone' +import {apiAxios} from '@/api/apiAxios' import {CloseButton} from '@/components/CloseButton/CloseButton' import {Accept} from '@/utils/dropzone-accept' import {niceBytes} from '@/utils/niceBytes' @@ -33,13 +33,14 @@ export const UploadProblemForm: FC<{ const {alert} = useAlert() const {mutate: uploadSolution} = useMutation({ - mutationFn: (formData: FormData) => axios.post(`/api/competition/problem/${problemId}/upload-solution`, formData), + mutationFn: (formData: FormData) => apiAxios.post(`/competition/problem/${problemId}/upload-solution`, formData), onSuccess: (response) => { if (response.status === 201) { // refetch serie, nech sa aktualizuje problem.submitted invalidateSeriesQuery() alert('Riešenie úspešne nahrané.') } else { + // eslint-disable-next-line no-console console.warn(response) alert( 'Niečo sa ASI pokazilo, skontroluj, či bolo riešenie nahrané, a ak si technický typ, môžeš pozrieť chybu v konzole.', diff --git a/src/components/Profile/PasswordChangeForm.tsx b/src/components/Profile/PasswordChangeForm.tsx index ba1d15e3..e7ba284d 100644 --- a/src/components/Profile/PasswordChangeForm.tsx +++ b/src/components/Profile/PasswordChangeForm.tsx @@ -1,10 +1,11 @@ import {Visibility, VisibilityOff} from '@mui/icons-material' import {IconButton, Stack} from '@mui/material' import {useMutation} from '@tanstack/react-query' -import axios, {AxiosError} from 'axios' +import {AxiosError} from 'axios' import {FC, useState} from 'react' import {SubmitHandler, useForm} from 'react-hook-form' +import {apiAxios} from '@/api/apiAxios' import {IGeneralPostResponse} from '@/types/api/general' import {useAlert} from '@/utils/useAlert' @@ -53,7 +54,7 @@ export const PasswordChangeDialog: FC = ({open, close const {mutate: submitFormData} = useMutation({ mutationFn: (data: PasswordChangeDialogValues) => { - return axios.post(`/api/user/password/change`, data) + return apiAxios.post(`/user/password/change`, data) }, onSuccess: onSuccess, onError: onError, diff --git a/src/components/Profile/ProfileDetail.tsx b/src/components/Profile/ProfileDetail.tsx index 6103f07f..7c888f2c 100644 --- a/src/components/Profile/ProfileDetail.tsx +++ b/src/components/Profile/ProfileDetail.tsx @@ -1,8 +1,8 @@ import {Stack, Typography} from '@mui/material' import {useQuery} from '@tanstack/react-query' -import axios from 'axios' import {FC, useState} from 'react' +import {apiAxios} from '@/api/apiAxios' import {Button} from '@/components/Clickable/Button' import {Link} from '@/components/Clickable/Link' import {Profile} from '@/types/api/personal' @@ -41,7 +41,7 @@ export const ProfileDetail: FC = () => { const {data} = useQuery({ queryKey: ['personal', 'profiles', 'myprofile'], - queryFn: () => axios.get(`/api/personal/profiles/myprofile`), + queryFn: () => apiAxios.get(`/personal/profiles/myprofile`), enabled: isAuthed, }) const profile = data?.data diff --git a/src/components/Profile/ProfileForm.tsx b/src/components/Profile/ProfileForm.tsx index 23c297a6..a6a6d60c 100644 --- a/src/components/Profile/ProfileForm.tsx +++ b/src/components/Profile/ProfileForm.tsx @@ -1,10 +1,10 @@ import {Stack} from '@mui/material' import {useMutation, useQuery} from '@tanstack/react-query' -import axios from 'axios' import {useRouter} from 'next/router' import {FC} from 'react' import {SubmitHandler, useForm} from 'react-hook-form' +import {apiAxios} from '@/api/apiAxios' import {FormInput} from '@/components/FormItems/FormInput/FormInput' import {SelectOption} from '@/components/FormItems/FormSelect/FormSelect' import {IGeneralPostResponse} from '@/types/api/general' @@ -39,7 +39,7 @@ export const ProfileForm: FC = () => { const {data} = useQuery({ queryKey: ['personal', 'profiles', 'myprofile'], - queryFn: () => axios.get(`/api/personal/profiles/myprofile`), + queryFn: () => apiAxios.get(`/personal/profiles/myprofile`), enabled: isAuthed, }) const profile = data?.data @@ -87,7 +87,7 @@ export const ProfileForm: FC = () => { const {mutate: submitFormData} = useMutation({ mutationFn: (data: ProfileFormValues) => { - return axios.put(`/api/user/user`, transformFormData(data)) + return apiAxios.put(`/user/user`, transformFormData(data)) }, onSuccess: () => router.push(`/${seminar}/profil`), }) diff --git a/src/components/PublicationUploader/PublicationUploader.tsx b/src/components/PublicationUploader/PublicationUploader.tsx index 206e2bf5..3fea31ff 100644 --- a/src/components/PublicationUploader/PublicationUploader.tsx +++ b/src/components/PublicationUploader/PublicationUploader.tsx @@ -37,7 +37,7 @@ export const PublicationUploader: FC = ({semesterId, o )} { const {mutate: submitFormData} = useMutation({ mutationFn: (data: RegisterFormValues) => { - return axios.post(`/api/user/registration?seminar=${seminar}`, transformFormData(data)) + return apiAxios.post(`/user/registration?seminar=${seminar}`, transformFormData(data)) }, // TODO: show alert/toast and redirect to homepage instead of redirect to info page onSuccess: () => diff --git a/src/components/Results/Results.tsx b/src/components/Results/Results.tsx index 36f93680..a73ee5e1 100644 --- a/src/components/Results/Results.tsx +++ b/src/components/Results/Results.tsx @@ -1,8 +1,8 @@ import {Box} from '@mui/material' import {useQuery} from '@tanstack/react-query' -import axios from 'axios' import {FC, useEffect} from 'react' +import {apiAxios} from '@/api/apiAxios' import {BannerContainer} from '@/utils/BannerContainer' import {useDataFromURL} from '@/utils/useDataFromURL' @@ -17,14 +17,14 @@ export const Results: FC = () => { const {data: resultsData, isLoading: resultsIsLoading} = useQuery({ queryKey: ['competition', competitionEndpoint, idForEndpoint, 'results'], - queryFn: () => axios.get(`/api/competition/${competitionEndpoint}/${idForEndpoint}/results`), + queryFn: () => apiAxios.get(`/competition/${competitionEndpoint}/${idForEndpoint}/results`), enabled: id.semesterId !== -1 || id.seriesId !== -1, }) const results = resultsData?.data ?? [] const {setBannerMessages} = BannerContainer.useContainer() const {data: bannerMessage, isLoading: isBannerLoading} = useQuery({ queryKey: ['cms', 'info-banner', 'series-results', id.seriesId], - queryFn: () => axios.get(`/api/cms/info-banner/series-results/${id.seriesId}`), + queryFn: () => apiAxios.get(`/cms/info-banner/series-results/${id.seriesId}`), enabled: id.seriesId !== -1, }) diff --git a/src/components/SchoolSubForm/SchoolSubForm.tsx b/src/components/SchoolSubForm/SchoolSubForm.tsx index a6f29004..835246f2 100644 --- a/src/components/SchoolSubForm/SchoolSubForm.tsx +++ b/src/components/SchoolSubForm/SchoolSubForm.tsx @@ -1,9 +1,9 @@ import {Stack} from '@mui/material' import {useQuery} from '@tanstack/react-query' -import axios from 'axios' import {useEffect, useRef} from 'react' import {Control, UseFormSetValue, UseFormWatch} from 'react-hook-form' +import {apiAxios} from '@/api/apiAxios' import {IGrade} from '@/types/api/competition' import {ISchool} from '@/types/api/personal' @@ -39,7 +39,7 @@ export const SchoolSubForm = ({control, watch, setValue, gap}: SchoolSubFormProp // načítanie ročníkov z BE, ktorými vyplníme FormSelect s ročníkmi const {data: gradesData} = useQuery({ queryKey: ['competition', 'grade'], - queryFn: () => axios.get(`/api/competition/grade`), + queryFn: () => apiAxios.get(`/competition/grade`), }) const grades = gradesData?.data ?? [] const gradeItems: SelectOption[] = grades.map(({id, name}) => ({id, label: name})) @@ -47,7 +47,7 @@ export const SchoolSubForm = ({control, watch, setValue, gap}: SchoolSubFormProp // načítanie škôl z BE, ktorými vyplníme FormAutocomplete so školami const {data: schoolsData} = useQuery({ queryKey: ['personal', 'schools'], - queryFn: () => axios.get(`/api/personal/schools`), + queryFn: () => apiAxios.get(`/personal/schools`), }) const schools = (schoolsData?.data ?? []).sort((a, b) => a.name.localeCompare(b.name)) const allSchoolItems: SelectOption[] = schools.map(({code, city, name, street}) => ({ diff --git a/src/components/SemesterAdministration/SemesterAdministration.tsx b/src/components/SemesterAdministration/SemesterAdministration.tsx index 61d97e2c..13604bd5 100644 --- a/src/components/SemesterAdministration/SemesterAdministration.tsx +++ b/src/components/SemesterAdministration/SemesterAdministration.tsx @@ -1,9 +1,10 @@ import {Stack, Typography} from '@mui/material' import Grid from '@mui/material/Unstable_Grid2' import {useMutation, useQuery} from '@tanstack/react-query' -import axios, {AxiosError} from 'axios' +import {AxiosError} from 'axios' import {FC, Fragment, useState} from 'react' +import {apiAxios} from '@/api/apiAxios' import {Button} from '@/components/Clickable/Button' import {Link} from '@/components/Clickable/Link' import {SemesterWithProblems, SeriesWithProblems} from '@/types/api/generated/competition' @@ -40,7 +41,7 @@ export const SemesterAdministration: FC = () => { refetch, } = useQuery({ queryKey: ['competition', 'semester', semesterId], - queryFn: () => axios.get(`/api/competition/semester/${semesterId}`), + queryFn: () => apiAxios.get(`/competition/semester/${semesterId}`), // router.query.params su v prvom renderi undefined, tak pustime query az so spravnym semesterId enabled: semesterId !== undefined, }) @@ -50,8 +51,8 @@ export const SemesterAdministration: FC = () => { const getResults = async (seriesId: number | null) => { const isSemester = seriesId === null - const {data} = await axios.get( - isSemester ? `/api/competition/semester/${semesterId}/results` : `/api/competition/series/${seriesId}/results`, + const {data} = await apiAxios.get( + isSemester ? `/competition/semester/${semesterId}/results` : `/competition/series/${seriesId}/results`, ) setTextareaContent( data @@ -79,8 +80,8 @@ export const SemesterAdministration: FC = () => { } const getPostalCards = async (offline_only: boolean) => { - const {data} = await axios.get( - `/api/competition/semester/${semesterId}/${offline_only ? 'offline-schools' : 'schools'}`, + const {data} = await apiAxios.get( + `/competition/semester/${semesterId}/${offline_only ? 'offline-schools' : 'schools'}`, ) setTextareaContent( data @@ -92,7 +93,7 @@ export const SemesterAdministration: FC = () => { const [seriesFreezeErrors, setSeriesFreezeErrors] = useState>() const {mutate: freezeSeries} = useMutation({ - mutationFn: (series: SeriesWithProblems) => axios.post(`/api/competition/series/${series.id}/results/freeze`), + mutationFn: (series: SeriesWithProblems) => apiAxios.post(`/competition/series/${series.id}/results/freeze`), onSuccess: (_, variables: SeriesWithProblems) => { setSeriesFreezeErrors((prev) => new Map(prev).set(variables.id, '')) refetch() diff --git a/src/components/VerifyEmail/VerifyEmail.tsx b/src/components/VerifyEmail/VerifyEmail.tsx index 5fc38fa1..a11a3186 100644 --- a/src/components/VerifyEmail/VerifyEmail.tsx +++ b/src/components/VerifyEmail/VerifyEmail.tsx @@ -1,9 +1,10 @@ import {Stack, Typography} from '@mui/material' import {useMutation} from '@tanstack/react-query' -import axios from 'axios' import {useRouter} from 'next/router' import {FC, useEffect, useState} from 'react' +import {apiAxios} from '@/api/apiAxios' + import {Button} from '../Clickable/Button' import {Dialog} from '../Dialog/Dialog' import {Loading} from '../Loading/Loading' @@ -23,7 +24,7 @@ export const VerifyEmail: FC = () => { isError, isSuccess: isVerified, } = useMutation({ - mutationFn: (verificationKey: string) => axios.post('/api/user/registration/verify-email', {key: verificationKey}), + mutationFn: (verificationKey: string) => apiAxios.post('/user/registration/verify-email', {key: verificationKey}), }) useEffect(() => { diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 00000000..14af30b6 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,25 @@ +import {NextRequest, NextResponse} from 'next/server' + +import {apiMiddleware} from './middleware/apiMiddleware' + +export function middleware(req: NextRequest) { + const url = req.nextUrl + + if (url.pathname.startsWith('/api')) { + return apiMiddleware(req) + } + + // https://nextjs.org/docs/app/building-your-application/routing/middleware#advanced-middleware-flags + // odstran trailing slash - default next.js spravanie, ale vypli sme ho v next.config.ts pomocou + // `skipTrailingSlashRedirect: true`, aby sme dovolili (a v axiose a middlewari vyssie aj forcli) + // trailing slash pre BE Django + // pre root route `/` to nemozeme spravit (infinite redirect) ¯\_(ツ)_/¯ + if (url.pathname.endsWith('/') && url.pathname !== '/') { + const newPathname = url.pathname.slice(0, -1) + const newUrl = new URL(`${newPathname}${url.search}`, url) + + return NextResponse.redirect(newUrl, 308) + } + + return NextResponse.next() +} diff --git a/src/middleware/apiMiddleware.ts b/src/middleware/apiMiddleware.ts new file mode 100644 index 00000000..81aa33e9 --- /dev/null +++ b/src/middleware/apiMiddleware.ts @@ -0,0 +1,23 @@ +import {NextRequest, NextResponse} from 'next/server' + +import {addApiTrailingSlash} from '@/utils/addApiTrailingSlash' +import {getInternalBeServerUrl} from '@/utils/urlBase' + +export const apiMiddleware = (req: NextRequest) => { + const {method, nextUrl} = req + const {pathname, search, href} = nextUrl + + let newPathname = pathname + + // nahrad prefix "/api" za iny prefix (na lokalny BE "", na deployed BE "/api") + newPathname = newPathname.replace(/^\/api/, process.env.BE_PREFIX ?? '') + + newPathname = addApiTrailingSlash(newPathname) + + const newUrl = new URL(`${newPathname}${search}`, getInternalBeServerUrl()) + + // eslint-disable-next-line no-console + if (process.env.DEBUG === 'true') console.log('[MIDDLEWARE]', method, href, '->', newUrl.href) + + return NextResponse.rewrite(newUrl) +} diff --git a/src/pages/strom/[page].tsx b/src/pages/strom/[page].tsx index c9006b8b..86956e99 100644 --- a/src/pages/strom/[page].tsx +++ b/src/pages/strom/[page].tsx @@ -1,6 +1,6 @@ -import axios from 'axios' import {GetServerSideProps, NextPage} from 'next' +import {serverApiAxios} from '@/api/apiAxios' import {Markdown} from '@/components/Markdown/Markdown' import {PageLayout} from '@/components/PageLayout/PageLayout' import {FlatPage} from '@/types/api/generated/base' @@ -27,9 +27,7 @@ export const seminarBasedGetServerSideProps = // tento check je hlavne pre typescript - parameter `page` by vzdy mal existovat a vzdy ako string if (query?.page && typeof query.page === 'string') { const requestedUrl = query.page - const {data} = await axios.get( - `${process.env.NEXT_PUBLIC_BE_PROTOCOL}://${process.env.NEXT_PUBLIC_BE_HOSTNAME}:${process.env.NEXT_PUBLIC_BE_PORT}/cms/flat-page/by-url/${requestedUrl}`, - ) + const {data} = await serverApiAxios.get(`/cms/flat-page/by-url/${requestedUrl}`) // ked stranka neexistuje, vrati sa `content: ""`. teraz renderujeme stranku len ked je content neprazdny a server rovno vrati redirect. // druha moznost by bola nechat prazdny content handlovat clienta - napriklad zobrazit custom error, ale nechat usera na neplatnej stranke. // tretia moznost je miesto redirectu vratit nextovsku 404 diff --git a/src/pages/strom/akcie/[[...params]].tsx b/src/pages/strom/akcie/[[...params]].tsx index 7cf9ebbf..70aad31d 100644 --- a/src/pages/strom/akcie/[[...params]].tsx +++ b/src/pages/strom/akcie/[[...params]].tsx @@ -1,6 +1,6 @@ -import axios from 'axios' import {GetServerSideProps, NextPage} from 'next' +import {serverApiAxios} from '@/api/apiAxios' import {CompetitionPage} from '@/components/CompetitionPage/CompetitionPage' import {RulesPage} from '@/components/CompetitionPage/RulesPage' import {PageLayout} from '@/components/PageLayout/PageLayout' @@ -44,8 +44,8 @@ export const competitionBasedGetServerSideProps = const requestedUrl = query.params[0] try { - const {data} = await axios.get( - `${process.env.NEXT_PUBLIC_BE_PROTOCOL}://${process.env.NEXT_PUBLIC_BE_HOSTNAME}:${process.env.NEXT_PUBLIC_BE_PORT}/competition/competition/slug/${requestedUrl}`, + const {data} = await serverApiAxios.get( + `/competition/competition/slug/${requestedUrl}`, ) if (!data) return redirectToSeminar @@ -57,7 +57,7 @@ export const competitionBasedGetServerSideProps = } return {props: {competition: data, is_rules: false}} - } catch (e: unknown) { + } catch { return redirectToSeminar } } diff --git a/src/utils/AuthContainer.tsx b/src/utils/AuthContainer.tsx index fca075f3..d3203889 100644 --- a/src/utils/AuthContainer.tsx +++ b/src/utils/AuthContainer.tsx @@ -1,29 +1,27 @@ import {useMutation, useQueryClient} from '@tanstack/react-query' -import axios, {AxiosError} from 'axios' +import {AxiosError} from 'axios' import {useEffect, useState} from 'react' -import {Cookies} from 'react-cookie' import {createContainer} from 'unstated-next' +import {apiAxios, newApiAxios} from '@/api/apiAxios' import {Login, Token} from '@/types/api/generated/user' import {MyPermissions} from '@/types/api/personal' -// special axios instance to prevent interceptors -const specialAxios = axios.create() +// specialna axios instancia bez error handlingu pridaneho do `apiAxios` nizsie +const specialApiAxios = newApiAxios('client') -export const testAuthRequest = async () => specialAxios.get('/api/personal/profiles/mypermissions') +export const testAuthRequest = async () => specialApiAxios.get('/personal/profiles/mypermissions') // call na lubovolny "auth" endpoint ako test prihlasenia, vracia true/false podla uspesnosti export const testAuth = async () => { try { await testAuthRequest() return true - } catch (e: unknown) { + } catch { return false } } -const cookies = new Cookies() - const useAuth = () => { // stav, ktory napoveda, ci mame sessionid cookie a vieme robit auth requesty const [isAuthed, setIsAuthed] = useState(false) @@ -34,18 +32,6 @@ const useAuth = () => { const success = await testAuth() if (success) setIsAuthed(true) })() - - // interceptor pre auth - axios.interceptors.request.use((config) => { - config.headers = config.headers ?? {} - // auth pozostava z comba: - // 1. `sessionid` httpOnly cookie ktoru nastavuje aj maze server pri login/logout - // 2. tato CSRF hlavicka, ktora ma obsahovat cookie, ktoru nastavuje server - config.headers['X-CSRFToken'] = cookies.get('csrftoken') - - return config - }) - // one-time vec pri prvom nacitani stranky }, []) @@ -56,7 +42,7 @@ const useAuth = () => { useEffect(() => { if (isAuthed) { // ked sme authed a dostaneme 403, chceme overit, ci nam vyprsalo prihlasenie - ak hej, chceme odhlasit usera, nech vie, co sa deje - const responseInterceptor = axios.interceptors.response.use( + const responseInterceptor = apiAxios.interceptors.response.use( (response) => response, async (error: AxiosError) => { const status = error.response?.status @@ -81,13 +67,13 @@ const useAuth = () => { // useEffect unmount callback return () => { - axios.interceptors.response.eject(responseInterceptor) + apiAxios.interceptors.response.eject(responseInterceptor) } } }, [isAuthed]) const {mutate: login, mutateAsync: loginAsync} = useMutation({ - mutationFn: ({data}: {data: Login; onSuccess?: () => void}) => axios.post('/api/user/login', data), + mutationFn: ({data}: {data: Login; onSuccess?: () => void}) => apiAxios.post('/user/login', data), onSuccess: async (_, {onSuccess}) => { onSuccess?.() @@ -99,7 +85,7 @@ const useAuth = () => { // zavoláme logout API point, ktorý zmaže token na BE a odstráni sessionid cookie. const {mutate: logout, mutateAsync: logoutAsync} = useMutation({ - mutationFn: () => axios.post('/api/user/logout'), + mutationFn: () => apiAxios.post('/user/logout'), onSettled: () => { setIsAuthed(false) // sessionid cookie odstrani server sam diff --git a/src/utils/addApiTrailingSlash.ts b/src/utils/addApiTrailingSlash.ts new file mode 100644 index 00000000..d9709b2a --- /dev/null +++ b/src/utils/addApiTrailingSlash.ts @@ -0,0 +1,11 @@ +/** + * BE Django server ocakava trailing slash (aj pred query params). + * + * @param pathname local URL without query params. e.g. `/api/cms/post` + * @returns local URL with trailing slash. e.g. `/api/cms/post/` + * */ +export const addApiTrailingSlash = (pathname: string) => { + if (!pathname.endsWith('/')) return `${pathname}/` + + return pathname +} diff --git a/src/utils/debugServer.ts b/src/utils/debugServer.ts new file mode 100644 index 00000000..7d94f437 --- /dev/null +++ b/src/utils/debugServer.ts @@ -0,0 +1,6 @@ +export const debugServer: typeof console.log = (...args) => { + if (process.env.DEBUG === 'true') { + // eslint-disable-next-line no-console + console.log(...args) + } +} diff --git a/src/utils/urlBase.ts b/src/utils/urlBase.ts new file mode 100644 index 00000000..ea141a6c --- /dev/null +++ b/src/utils/urlBase.ts @@ -0,0 +1,10 @@ +export const composeUrlBase = (protocol: string, hostname: string, port: string, prefix: string) => + `${protocol}://${hostname}${port ? `:${port}` : ''}${prefix}` + +export const getInternalBeServerUrl = () => + composeUrlBase( + process.env.BE_PROTOCOL as string, + process.env.BE_HOSTNAME as string, + process.env.BE_PORT as string, + process.env.BE_PREFIX as string, + ) diff --git a/src/utils/useDataFromURL.tsx b/src/utils/useDataFromURL.tsx index a58d9ffe..5ad25443 100644 --- a/src/utils/useDataFromURL.tsx +++ b/src/utils/useDataFromURL.tsx @@ -1,8 +1,8 @@ import {useQuery} from '@tanstack/react-query' -import axios from 'axios' import {useRouter} from 'next/router' import {useMemo} from 'react' +import {apiAxios} from '@/api/apiAxios' import {Semester, SeriesWithProblems} from '@/types/api/competition' import {useSeminarInfo} from '@/utils/useSeminarInfo' @@ -12,7 +12,7 @@ export const useDataFromURL = () => { const {data: semesterListData, isLoading: semesterListIsLoading} = useQuery({ queryKey: ['competition', 'semester-list', {competition: seminarId}], - queryFn: () => axios.get(`/api/competition/semester-list?competition=${seminarId}`), + queryFn: () => apiAxios.get(`/competition/semester-list?competition=${seminarId}`), }) // memoized because the array fallback would create new object on each render, which would ruin seriesId memoization as semesterList is a dependency const semesterList = useMemo(() => semesterListData?.data || [], [semesterListData]) @@ -21,7 +21,7 @@ export const useDataFromURL = () => { // - napr. prideme na `/zadania` cez menu, nie na `/zadania/44/leto/2` const {data: currentSeriesData, isLoading: currentSeriesIsLoading} = useQuery({ queryKey: ['competition', 'series', 'current', seminarId], - queryFn: () => axios.get(`/api/competition/series/current/` + seminarId), + queryFn: () => apiAxios.get(`/competition/series/current/` + seminarId), }) const currentSeriesId = currentSeriesData?.data.id ?? -1 const currentSemesterId = currentSeriesData?.data.semester ?? -1 diff --git a/src/utils/useHasPermissions.ts b/src/utils/useHasPermissions.ts index 5072aab1..aeadebd6 100644 --- a/src/utils/useHasPermissions.ts +++ b/src/utils/useHasPermissions.ts @@ -1,6 +1,6 @@ import {useQuery} from '@tanstack/react-query' -import axios from 'axios' +import {apiAxios} from '@/api/apiAxios' import {MyPermissions} from '@/types/api/personal' import {AuthContainer} from './AuthContainer' @@ -11,7 +11,7 @@ export const useHasPermissions = () => { const {data, isLoading: permissionsIsLoading} = useQuery({ queryKey: ['personal', 'profiles', 'mypermissions'], - queryFn: () => axios.get('/api/personal/profiles/mypermissions'), + queryFn: () => apiAxios.get('/personal/profiles/mypermissions'), enabled: isAuthed, }) diff --git a/yarn.lock b/yarn.lock index c4d7cf6f..9f837a7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3382,14 +3382,14 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.7.7": - version: 1.7.7 - resolution: "axios@npm:1.7.7" +"axios@npm:^1.7.9": + version: 1.7.9 + resolution: "axios@npm:1.7.9" dependencies: follow-redirects: "npm:^1.15.6" form-data: "npm:^4.0.0" proxy-from-env: "npm:^1.1.0" - checksum: 10/7f875ea13b9298cd7b40fd09985209f7a38d38321f1118c701520939de2f113c4ba137832fe8e3f811f99a38e12c8225481011023209a77b0c0641270e20cde1 + checksum: 10/b7a5f660ea53ba9c2a745bf5ad77ad8bf4f1338e13ccc3f9f09f810267d6c638c03dac88b55dae8dc98b79c57d2d6835be651d58d2af97c174f43d289a9fd007 languageName: node linkType: hard @@ -10225,7 +10225,7 @@ __metadata: "@types/react-router-dom": "npm:^5.3.3" "@typescript-eslint/eslint-plugin": "npm:^8.15.0" "@typescript-eslint/parser": "npm:^8.15.0" - axios: "npm:^1.7.7" + axios: "npm:^1.7.9" clsx: "npm:^2.1.1" confusing-browser-globals: "npm:^1.0.11" eslint: "npm:^8.57.1"