Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { NextConfig } from 'next';
import path from 'path';

const nextConfig: NextConfig = {
env: {
commitTag: process.env.COMMIT_TAG || 'local',
},
images: {
unoptimized: true,
remotePatterns: [
{ hostname: 'gravatar.com' },
{ hostname: 'image.tmdb.org' },
Expand All @@ -14,13 +16,38 @@ const nextConfig: NextConfig = {
},
transpilePackages: ['country-flag-icons'],
turbopack: {
resolveAlias: {
'next/link': './src/components/Common/BaseLink/index.tsx',
'next/router': './src/utils/router.ts',
},
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
},
},
webpack: (config) => {
const svgRule = config.module.rules.find((rule: { test?: RegExp }) =>
rule?.test?.test?.('.svg')
);

if (svgRule) {
svgRule.exclude = /\.svg$/i;
}

config.module.rules.push({
test: /\.svg$/i,
use: ['@svgr/webpack'],
});

config.resolve.alias['next/link'] = path.resolve(
'./src/components/Common/BaseLink/index.tsx'
);
config.resolve.alias['next/router'] = path.resolve('./src/utils/router.ts');

return config;
},
experimental: {
scrollRestoration: true,
largePageDataBytes: 512 * 1000,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"postinstall": "next telemetry disable",
"dev": "nodemon -e ts,json,yml --watch server --watch seerr-api.yml --exec 'ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts'",
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && copyfiles -u 2 \"server/i18n/locale/*.json\" dist/i18n && tsc-alias -p server/tsconfig.json",
"build:next": "next build",
"build:next": "next build --webpack",
"build": "pnpm build:next && pnpm build:server",
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
"lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix",
Expand Down
47 changes: 36 additions & 11 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,24 @@
// previously cached resources to be updated from the network.
// This variable is intentionally declared and unused.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const OFFLINE_VERSION = 5;
const OFFLINE_VERSION = 6;
const CACHE_NAME = 'offline';
const BASE_PATH = new URL(self.registration.scope).pathname.replace(/\/$/, '');
const withBasePath = (path) => {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;

if (
!BASE_PATH ||
normalizedPath === BASE_PATH ||
normalizedPath.startsWith(`${BASE_PATH}/`)
) {
return normalizedPath;
}

return `${BASE_PATH}${normalizedPath}`;
};
// Customize this with a different URL if needed.
const OFFLINE_URL = '/offline.html';
const OFFLINE_URL = withBasePath('/offline.html');

self.addEventListener('install', (event) => {
event.waitUntil(
Expand Down Expand Up @@ -75,8 +89,10 @@ self.addEventListener('push', (event) => {

const options = {
body: payload.message,
badge: 'badge-128x128.png',
icon: payload.image ? payload.image : 'android-chrome-192x192.png',
badge: withBasePath('/badge-128x128.png'),
icon: payload.image
? payload.image
: withBasePath('/android-chrome-192x192.png'),
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
Expand Down Expand Up @@ -137,17 +153,26 @@ self.addEventListener(
event.notification.close();

if (event.action === 'approve') {
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
method: 'POST',
});
fetch(
withBasePath(`/api/v1/request/${notificationData.requestId}/approve`),
{
method: 'POST',
}
);
} else if (event.action === 'decline') {
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
method: 'POST',
});
fetch(
withBasePath(`/api/v1/request/${notificationData.requestId}/decline`),
{
method: 'POST',
}
);
}

if (notificationData.actionUrl) {
clients.openWindow(notificationData.actionUrl);
const actionUrl = notificationData.actionUrl.startsWith('/')
? withBasePath(notificationData.actionUrl)
: notificationData.actionUrl;
clients.openWindow(actionUrl);
}
},
false
Expand Down
42 changes: 33 additions & 9 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import avatarproxy from '@server/routes/avatarproxy';
import imageproxy from '@server/routes/imageproxy';
import { appDataPermissions } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion';
import { getRuntimeBasePath } from '@server/utils/basePath';
import createCustomProxyAgent, {
setForceIpv4First,
} from '@server/utils/customProxyAgent';
Expand All @@ -46,8 +47,17 @@ import path from 'path';
import swaggerUi from 'swagger-ui-express';

const API_SPEC_PATH = path.join(__dirname, '../seerr-api.yml');
const basePath = getRuntimeBasePath();
const cookiePath = basePath || '/';

// Expose the resolved value to server-rendered application code without
// compiling the user-facing setting into the browser bundle.
process.env.SEERR_RUNTIME_BASE_PATH = basePath;

logger.info(`Starting Seerr version ${getAppVersion()}`);
if (basePath) {
logger.info(`Using base path ${basePath}`, { label: 'Server' });
}
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
Expand All @@ -61,6 +71,11 @@ if (!appDataPermissions()) {
app
.prepare()
.then(async () => {
// Next.js basePath is build-time only, but assetPrefix can be changed after
// the production build has been loaded. Express strips the runtime prefix
// before handing requests to Next.js below.
app.setAssetPrefix(basePath);

// Run Overseerr to Seerr migration
await checkOverseerrMerge();

Expand Down Expand Up @@ -197,22 +212,26 @@ app
sameSite: true,
secure: !dev,
key: '_csrf',
path: '/',
path: cookiePath,
},
})
);
server.use((req, res, next) => {
res.cookie('XSRF-TOKEN', req.csrfToken(), {
sameSite: true,
secure: !dev,
path: cookiePath,
});
next();
});
}

// Mount Seerr-owned HTTP routes below the configured application base path.
const appRouter = express.Router();

// Set up sessions
const sessionRespository = getRepository(Session);
server.use(
appRouter.use(
'/api',
session({
secret: settings.sessionSecret,
Expand All @@ -223,6 +242,7 @@ app
httpOnly: true,
sameSite: settings.network.csrfProtection ? 'strict' : 'lax',
secure: 'auto',
path: cookiePath,
},
store: new TypeormStore({
cleanupLimit: 2,
Expand All @@ -232,8 +252,8 @@ app
);
const apiSpecContent = await fs.readFile(API_SPEC_PATH, 'utf-8');
const apiDocs = yaml.load(apiSpecContent) as Record<string, unknown>;
server.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDocs));
server.use(
appRouter.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDocs));
appRouter.use(
OpenApiValidator.middleware({
apiSpec: API_SPEC_PATH,
validateRequests: true,
Expand All @@ -244,20 +264,24 @@ app
* OpenAPI validator. Otherwise, they are treated as objects instead of strings
* and response validation will fail
*/
server.use((_req, res, next) => {
appRouter.use((_req, res, next) => {
const original = res.json;
res.json = function jsonp(json) {
return original.call(this, JSON.parse(JSON.stringify(json)));
};
next();
});
server.use('/api/v1', routes);
appRouter.use('/api/v1', routes);

// Do not set cookies so CDNs can cache them
server.use('/imageproxy', clearCookies, imageproxy);
server.use('/avatarproxy', clearCookies, avatarproxy);
appRouter.use('/imageproxy', clearCookies, imageproxy);
appRouter.use('/avatarproxy', clearCookies, avatarproxy);

server.use(basePath || '/', appRouter);

server.get('*path', (req, res) => handle(req, res));
// Express removes the mount path from req.url while this middleware runs,
// allowing one root-built Next.js bundle to serve any configured URL base.
server.use(basePath || '/', (req, res) => handle(req, res));
server.use(
(
err: { status: number; message: string; errors: string[] },
Expand Down
2 changes: 2 additions & 0 deletions server/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export interface DnsCacheSettings {
}

export interface NetworkSettings {
basePath: string;
csrfProtection: boolean;
forceIpv4First: boolean;
trustProxy: boolean;
Expand Down Expand Up @@ -611,6 +612,7 @@ class Settings {
},
},
network: {
basePath: '',
csrfProtection: false,
forceIpv4First: false,
trustProxy: false,
Expand Down
24 changes: 24 additions & 0 deletions server/middleware/basePath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { isValidBasePath, normalizeBasePath } from '@server/utils/basePath';
import type { RequestHandler } from 'express';

const validateNetworkBasePath: RequestHandler = (req, _res, next) => {
if (
req.method !== 'POST' ||
req.path !== '/network' ||
!Object.prototype.hasOwnProperty.call(req.body ?? {}, 'basePath')
) {
return next();
}

if (!isValidBasePath(req.body.basePath)) {
return next({
status: 400,
message: 'Invalid URL base path.',
});
}

req.body.basePath = normalizeBasePath(req.body.basePath);
return next();
};

export default validateNetworkBasePath;
8 changes: 7 additions & 1 deletion server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { checkUser, isAuthenticated } from '@server/middleware/auth';
import validateNetworkBasePath from '@server/middleware/basePath';
import deprecatedRoute from '@server/middleware/deprecation';
import { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv';
Expand Down Expand Up @@ -153,7 +154,12 @@ router.get(
}
}
);
router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes);
router.use(
'/settings',
isAuthenticated(Permission.ADMIN),
validateNetworkBasePath,
settingsRoutes
);
router.use('/search', isAuthenticated(), searchRoutes);
router.use('/discover', isAuthenticated(), discoverRoutes);
router.use('/request', isAuthenticated(), requestRoutes);
Expand Down
38 changes: 38 additions & 0 deletions server/utils/basePath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { appDataPath } from '@server/utils/appDataVolume';
import fs from 'fs';
import path from 'path';

const SETTINGS_PATH = path.join(appDataPath(), 'settings.json');
const BASE_PATH_PATTERN = /^(?:|\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*)$/;

export const normalizeBasePath = (value?: string): string => {
const normalized = value?.trim().replace(/^\/+|\/+$/g, '') ?? '';

return normalized ? `/${normalized}` : '';
};

export const isValidBasePath = (value: unknown): value is string =>
typeof value === 'string' && BASE_PATH_PATTERN.test(value);

const getStoredBasePath = (): string => {
try {
const settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')) as {
network?: { basePath?: string };
};

return normalizeBasePath(settings.network?.basePath);
} catch {
return '';
}
};

export const getRuntimeBasePath = (): string => {
const hasEnvironmentOverride = Object.prototype.hasOwnProperty.call(
process.env,
'SEERR_BASE_PATH'
);

return normalizeBasePath(
hasEnvironmentOverride ? process.env.SEERR_BASE_PATH : getStoredBasePath()
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
3 changes: 2 additions & 1 deletion server/utils/restartFlag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ class RestartFlag {
return (
this.networkSettings.csrfProtection !== networkSettings.csrfProtection ||
this.networkSettings.trustProxy !== networkSettings.trustProxy ||
this.networkSettings.proxy.enabled !== networkSettings.proxy.enabled
this.networkSettings.proxy.enabled !== networkSettings.proxy.enabled ||
this.networkSettings.basePath !== networkSettings.basePath
);
}
}
Expand Down
Loading
Loading