feat: experimental basePath support when built-from-source

This commit is contained in:
fallenbagel
2025-02-18 01:20:50 +08:00
committed by gauthier-th
parent 110adfaf66
commit 1d5378cf35
21 changed files with 123 additions and 39 deletions

View File

@@ -1,9 +1,15 @@
/**
* @type {import('next').NextConfig}
*/
// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path');
module.exports = {
basePath: process.env.NEXT_PUBLIC_BASE_PATH || '',
assetPrefix: process.env.NEXT_PUBLIC_BASE_PATH || '',
env: {
commitTag: process.env.COMMIT_TAG || 'local',
basePath: process.env.NEXT_PUBLIC_BASE_PATH || '',
},
images: {
remotePatterns: [
@@ -19,6 +25,9 @@ module.exports = {
issuer: /\.(js|ts)x?$/,
use: ['@svgr/webpack'],
});
config.resolve.alias['next/image'] = path.resolve(
'./src/components/Common/BaseImage/index.ts'
);
return config;
},

View File

@@ -154,11 +154,13 @@ app
});
if (settings.network.csrfProtection) {
server.use(
`${process.env.NEXT_PUBLIC_BASE_PATH || ''}`,
csurf({
cookie: {
httpOnly: true,
sameSite: true,
secure: !dev,
path: `${process.env.NEXT_PUBLIC_BASE_PATH || ''}` || '/',
},
})
);
@@ -174,7 +176,7 @@ app
// Set up sessions
const sessionRespository = getRepository(Session);
server.use(
'/api',
`${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api`,
session({
secret: settings.clientId,
resave: false,
@@ -184,6 +186,7 @@ app
httpOnly: true,
sameSite: settings.network.csrfProtection ? 'strict' : 'lax',
secure: 'auto',
path: `${process.env.NEXT_PUBLIC_BASE_PATH || ''}` || '/',
},
store: new TypeormStore({
cleanupLimit: 2,
@@ -194,6 +197,7 @@ app
const apiDocs = YAML.load(API_SPEC_PATH);
server.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDocs));
server.use(
`${process.env.NEXT_PUBLIC_BASE_PATH || ''}`,
OpenApiValidator.middleware({
apiSpec: API_SPEC_PATH,
validateRequests: true,
@@ -211,11 +215,12 @@ app
};
next();
});
server.use('/api/v1', routes);
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
server.use(`${basePath}/api/v1`, routes);
// Do not set cookies so CDNs can cache them
server.use('/imageproxy', clearCookies, imageproxy);
server.use('/avatarproxy', clearCookies, avatarproxy);
server.use(`${basePath}/imageproxy`, clearCookies, imageproxy);
server.use(`${basePath}/avatarproxy`, clearCookies, avatarproxy);
server.get('*', (req, res) => handle(req, res));
server.use(

View File

@@ -0,0 +1,32 @@
// src/components/Common/BaseImage/index.ts
import type { ImageProps } from 'next/image';
import NextImage from 'next/image';
import React from 'react';
// Instead of defining our own props, extend from Next's ImageProps
const BaseImage = React.forwardRef<HTMLImageElement, ImageProps>(
(props, ref) => {
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
const modifiedSrc =
typeof props.src === 'string' && props.src.startsWith('/')
? `${basePath}${props.src}`
: props.src;
const shouldUnoptimize =
typeof props.src === 'string' && props.src.endsWith('.svg');
return React.createElement(NextImage, {
...props,
ref,
src: modifiedSrc,
unoptimized: shouldUnoptimize || props.unoptimized,
});
}
);
BaseImage.displayName = 'Image';
export default BaseImage;
// Re-export ImageProps type for consumers
export type { ImageProps };

View File

@@ -1,6 +1,6 @@
import Image from '@app/components/Common/BaseImage';
import useSettings from '@app/hooks/useSettings';
import type { ImageLoader, ImageProps } from 'next/image';
import Image from 'next/image';
const imageLoader: ImageLoader = ({ src }) => src;

View File

@@ -1,4 +1,5 @@
import { useUser } from '@app/hooks/useUser';
import { getBasedPath } from '@app/utils/navigationUtil';
import type { Permission } from '@server/lib/permissions';
import { hasPermission } from '@server/lib/permissions';
import Link from 'next/link';
@@ -85,10 +86,10 @@ const SettingsTabs = ({
</label>
<select
onChange={(e) => {
router.push(e.target.value);
router.push(getBasedPath(e.target.value));
}}
onBlur={(e) => {
router.push(e.target.value);
router.push(getBasedPath(e.target.value));
}}
defaultValue={
settingsRoutes.find((route) => !!router.pathname.match(route.regex))

View File

@@ -13,6 +13,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import ErrorPage from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import { getBasedPath } from '@app/utils/navigationUtil';
import { Transition } from '@headlessui/react';
import {
ChatBubbleOvalLeftEllipsisIcon,
@@ -166,7 +167,7 @@ const IssueDetails = () => {
appearance: 'success',
autoDismiss: true,
});
router.push('/issues');
router.push(getBasedPath('/issues'));
} catch (e) {
addToast(intl.formatMessage(messages.toastissuedeletefailed), {
appearance: 'error',

View File

@@ -6,6 +6,7 @@ import IssueItem from '@app/components/IssueList/IssueItem';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { getBasedPath } from '@app/utils/navigationUtil';
import {
BarsArrowDownIcon,
ChevronLeftIcon,
@@ -107,7 +108,7 @@ const IssueList = () => {
onChange={(e) => {
setCurrentFilter(e.target.value as Filter);
router.push({
pathname: router.pathname,
pathname: getBasedPath(router.pathname),
query: router.query.userId
? { userId: router.query.userId }
: {},
@@ -137,7 +138,7 @@ const IssueList = () => {
onChange={(e) => {
setCurrentSort(e.target.value as Sort);
router.push({
pathname: router.pathname,
pathname: getBasedPath(router.pathname),
query: router.query.userId
? { userId: router.query.userId }
: {},

View File

@@ -1,4 +1,5 @@
import Badge from '@app/components/Common/Badge';
import Image from '@app/components/Common/BaseImage';
import UserWarnings from '@app/components/Layout/UserWarnings';
import VersionStatus from '@app/components/Layout/VersionStatus';
import useClickOutside from '@app/hooks/useClickOutside';
@@ -16,7 +17,6 @@ import {
UsersIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { Fragment, useEffect, useRef } from 'react';

View File

@@ -2,6 +2,7 @@ import EmbyLogo from '@app/assets/services/emby-icon-only.svg';
import JellyfinLogo from '@app/assets/services/jellyfin-icon.svg';
import PlexLogo from '@app/assets/services/plex.svg';
import Button from '@app/components/Common/Button';
import Image from '@app/components/Common/BaseImage';
import ImageFader from '@app/components/Common/ImageFader';
import PageTitle from '@app/components/Common/PageTitle';
import LanguagePicker from '@app/components/Layout/LanguagePicker';
@@ -11,12 +12,12 @@ import PlexLoginButton from '@app/components/Login/PlexLoginButton';
import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { getBasedPath } from '@app/utils/navigationUtil';
import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@heroicons/react/24/solid';
import { MediaServerType } from '@server/constants/server';
import axios from 'axios';
import { useRouter } from 'next/dist/client/router';
import Image from 'next/image';
import { useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import { CSSTransition, SwitchTransition } from 'react-transition-group';
@@ -71,7 +72,7 @@ const Login = () => {
// valid user, we redirect the user to the home page as the login was successful.
useEffect(() => {
if (user) {
router.push('/');
router.push(getBasedPath('/'));
}
}, [user, router]);

View File

@@ -1,3 +1,4 @@
import Image from '@app/components/Common/BaseImage';
import Button from '@app/components/Common/Button';
import ImageFader from '@app/components/Common/ImageFader';
import PageTitle from '@app/components/Common/PageTitle';
@@ -6,7 +7,6 @@ import defineMessages from '@app/utils/defineMessages';
import { ArrowLeftIcon, EnvelopeIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Image from 'next/image';
import Link from 'next/link';
import { useState } from 'react';
import { useIntl } from 'react-intl';

View File

@@ -1,3 +1,4 @@
import Image from '@app/components/Common/BaseImage';
import Button from '@app/components/Common/Button';
import ImageFader from '@app/components/Common/ImageFader';
import SensitiveInput from '@app/components/Common/SensitiveInput';
@@ -7,7 +8,6 @@ import defineMessages from '@app/utils/defineMessages';
import { LifebuoyIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Form, Formik } from 'formik';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';

View File

@@ -2,6 +2,7 @@ import EmbyLogo from '@app/assets/services/emby.svg';
import JellyfinLogo from '@app/assets/services/jellyfin.svg';
import PlexLogo from '@app/assets/services/plex.svg';
import AppDataWarning from '@app/components/AppDataWarning';
import Image from '@app/components/Common/BaseImage';
import Button from '@app/components/Common/Button';
import ImageFader from '@app/components/Common/ImageFader';
import PageTitle from '@app/components/Common/PageTitle';
@@ -13,10 +14,10 @@ import SetupSteps from '@app/components/Setup/SetupSteps';
import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages';
import { getBasedPath } from '@app/utils/navigationUtil';
import { MediaServerType } from '@server/constants/server';
import type { Library } from '@server/lib/settings';
import axios from 'axios';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
@@ -65,7 +66,7 @@ const Setup = () => {
setIsUpdating(false);
if (response.data.initialized) {
await axios.post('/api/v1/settings/main', { locale });
mutate('/api/v1/settings/public');
mutate(getBasedPath('/api/v1/settings/public'));
router.push('/');
}
@@ -74,9 +75,9 @@ const Setup = () => {
const validateLibraries = useCallback(async () => {
try {
const endpointMap: Record<MediaServerType, string> = {
[MediaServerType.JELLYFIN]: '/api/v1/settings/jellyfin',
[MediaServerType.EMBY]: '/api/v1/settings/jellyfin',
[MediaServerType.PLEX]: '/api/v1/settings/plex',
[MediaServerType.JELLYFIN]: getBasedPath('/api/v1/settings/jellyfin'),
[MediaServerType.EMBY]: getBasedPath('/api/v1/settings/jellyfin'),
[MediaServerType.PLEX]: getBasedPath('/api/v1/settings/plex'),
[MediaServerType.NOT_CONFIGURED]: '',
};
@@ -108,7 +109,7 @@ const Setup = () => {
useEffect(() => {
if (settings.currentSettings.initialized) {
router.push('/');
router.push(getBasedPath('/'));
}
if (

View File

@@ -33,6 +33,7 @@ import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { sortCrewPriority } from '@app/utils/creditHelpers';
import defineMessages from '@app/utils/defineMessages';
import { getBasedPath } from '@app/utils/navigationUtil';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
import { Disclosure, Transition } from '@headlessui/react';
import {
@@ -520,7 +521,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
onClose={() => {
setShowManager(false);
router.push({
pathname: router.pathname,
pathname: getBasedPath(router.pathname),
query: { tvId: router.query.tvId },
});
}}

View File

@@ -1,10 +1,10 @@
import Alert from '@app/components/Common/Alert';
import Image from '@app/components/Common/BaseImage';
import Modal from '@app/components/Common/Modal';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import axios from 'axios';
import Image from 'next/image';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';

View File

@@ -16,6 +16,7 @@ import type { User } from '@app/hooks/useUser';
import { Permission, UserType, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { getBasedPath } from '@app/utils/navigationUtil';
import { Transition } from '@headlessui/react';
import {
BarsArrowDownIcon,
@@ -550,7 +551,7 @@ const UserList = () => {
name="sort"
onChange={(e) => {
setCurrentSort(e.target.value as Sort);
router.push(router.pathname);
router.push(getBasedPath(router.pathname));
}}
value={currentSort}
className="rounded-r-only"

View File

@@ -58,6 +58,7 @@ const useDiscover = <
): DiscoverResult<T, S> => {
const settings = useSettings();
const { hasPermission } = useUser();
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
const { data, error, size, setSize, isValidating, mutate } = useSWRInfinite<
BaseSearchResult<T> & S
>(
@@ -78,7 +79,10 @@ const useDiscover = <
)
.join('&');
return `${endpoint}?${finalQueryString}`;
const fullEndpoint = endpoint.startsWith('/')
? `${basePath}${endpoint}`
: endpoint;
return `${fullEndpoint}?${finalQueryString}`;
},
{
initialSize: 3,

View File

@@ -1,3 +1,4 @@
import { getBasedPath } from '@app/utils/navigationUtil';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import type { Permission, PermissionCheckOptions } from './useUser';
@@ -12,7 +13,7 @@ const useRouteGuard = (
useEffect(() => {
if (user && !hasPermission(permission, options)) {
router.push('/');
router.push(getBasedPath('/'));
}
}, [user, permission, router, hasPermission, options]);
};

View File

@@ -1,4 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { getBasedPath } from '@app/utils/navigationUtil';
import type { Nullable } from '@app/utils/typeHelpers';
import { useRouter } from 'next/router';
import type { Dispatch, SetStateAction } from 'react';
@@ -35,7 +36,7 @@ const useSearchInput = (): SearchObject => {
if (debouncedValue !== '' && searchOpen) {
if (router.pathname.startsWith('/search')) {
router.replace({
pathname: router.pathname,
pathname: getBasedPath(router.pathname),
query: {
...router.query,
query: debouncedValue,
@@ -45,7 +46,7 @@ const useSearchInput = (): SearchObject => {
setLastRoute(router.asPath);
router
.push({
pathname: '/search',
pathname: getBasedPath('/search'),
query: { query: debouncedValue },
})
.then(() => window.scrollTo(0, 0));
@@ -66,9 +67,14 @@ const useSearchInput = (): SearchObject => {
!searchOpen
) {
if (lastRoute) {
router.push(lastRoute).then(() => window.scrollTo(0, 0));
const route =
typeof lastRoute === 'string'
? getBasedPath(lastRoute)
: getBasedPath(lastRoute.pathname || '/');
router.push(route).then(() => window.scrollTo(0, 0));
} else {
router.replace('/').then(() => window.scrollTo(0, 0));
router.replace(getBasedPath('/')).then(() => window.scrollTo(0, 0));
}
}
}, [searchOpen]);

View File

@@ -1,3 +1,4 @@
import { getBasedPath } from '@app/utils/navigationUtil';
import type { NextRouter } from 'next/router';
import { useRouter } from 'next/router';
import type { ParsedUrlQuery } from 'querystring';
@@ -106,9 +107,15 @@ export const useQueryParams = (): UseQueryParamReturnedFunction => {
if (newRoute.path !== router.asPath) {
if (routerAction === 'replace') {
router.replace(newRoute.pathname, newRoute.path);
router.replace(
getBasedPath(newRoute.pathname),
getBasedPath(newRoute.path)
);
} else {
router.push(newRoute.pathname, newRoute.path);
router.push(
getBasedPath(newRoute.pathname),
getBasedPath(newRoute.path)
);
}
}
},

View File

@@ -185,9 +185,17 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
return (
<SWRConfig
value={{
fetcher: (url) => axios.get(url).then((res) => res.data),
fetcher: async (url) => {
const API_BASE = process.env.NEXT_PUBLIC_BASE_PATH || '';
const fullUrl =
url.startsWith('/') && !url.startsWith(API_BASE)
? `${API_BASE}${url}`
: url;
const res = await axios.get(fullUrl);
return res.data;
},
fallback: {
'/api/v1/auth/me': user,
[`${process.env.NEXT_PUBLIC_BASE_PATH}/api/v1/auth/me`]: user,
},
}}
>
@@ -250,13 +258,14 @@ CoreApp.getInitialProps = async (initialProps) => {
newPlexLogin: true,
youtubeUrl: '',
};
const API_BASE = process.env.NEXT_PUBLIC_BASE_PATH || '';
if (ctx.res) {
// Check if app is initialized and redirect if necessary
const response = await axios.get<PublicSettingsResponse>(
`http://${process.env.HOST || 'localhost'}:${
process.env.PORT || 5055
}/api/v1/settings/public`
}${API_BASE}/api/v1/settings/public`
);
currentSettings = response.data;
@@ -266,7 +275,7 @@ CoreApp.getInitialProps = async (initialProps) => {
if (!initialized) {
if (!router.pathname.match(/(setup|login\/plex)/)) {
ctx.res.writeHead(307, {
Location: '/setup',
Location: `${API_BASE}/setup`,
});
ctx.res.end();
}
@@ -276,7 +285,7 @@ CoreApp.getInitialProps = async (initialProps) => {
const response = await axios.get<User>(
`http://${process.env.HOST || 'localhost'}:${
process.env.PORT || 5055
}/api/v1/auth/me`,
}${API_BASE}/api/v1/auth/me`,
{
headers:
ctx.req && ctx.req.headers.cookie
@@ -288,7 +297,7 @@ CoreApp.getInitialProps = async (initialProps) => {
if (router.pathname.match(/(setup|login)/)) {
ctx.res.writeHead(307, {
Location: '/',
Location: `/`,
});
ctx.res.end();
}
@@ -298,7 +307,7 @@ CoreApp.getInitialProps = async (initialProps) => {
// before anything actually renders
if (!router.pathname.match(/(login|setup|resetpassword)/)) {
ctx.res.writeHead(307, {
Location: '/login',
Location: `${API_BASE}/login`,
});
ctx.res.end();
}

View File

@@ -0,0 +1,4 @@
export const getBasedPath = (path: string) => {
const API_BASE = process.env.NEXT_PUBLIC_BASE_PATH || '';
return path.startsWith('/') ? `${API_BASE}${path}` : path;
};