fix: harden the appliance against cross-origin reads, unvalidated settings bodies, HTML in email and world-writable code (#3407)
Release 1 - Prepare PR / guard_manual_run (push) Has been cancelled
Release 1 - Prepare PR / Guard release branch (push) Has been cancelled
Release 1 - Prepare PR / Create or update release PR (push) Has been cancelled
Build Development / guard_manual_run (push) Has been cancelled
Build Development / Build linux/arm64 (push) Has been cancelled
Build Development / Build linux/amd64 (push) Has been cancelled
Build Development / Merge Docker digests and push (push) Has been cancelled

* fix(server): stop reflecting any origin in CORS responses

`enableCors({ origin: true })` reflects whatever `Origin` a request carries
into `Access-Control-Allow-Origin`, so any page on the internet could read
Maintainerr's API responses (settings, tokens, the database download) out of a
victim's browser on the LAN. The UI is served by this same process in
production and proxied through Vite's `/api` proxy in development, so nothing
first-party ever needs CORS.

Production now emits no CORS headers at all unless `CORS_ALLOWED_ORIGINS` names
the origins that may call the API; development keeps reflecting so a UI on
another port still works.

* fix(notifications): escape HTML in email notification bodies

The email templates render the message with `!= body`, which pug emits
verbatim. The message is assembled from media titles, collection names and
requester names, so a title containing markup lands as live HTML in the mail an
admin opens.

Escape the message before substituting `\n` for `<br>`, so the only tag the
body can contribute is the line break Maintainerr inserts itself.

* fix(settings): validate the bulk settings request bodies

`POST` and `PATCH /api/settings` took plain DTO classes. The global pipe is
nestjs-zod's `ZodValidationPipe`, which returns the body untouched for any
metatype that is not a `createZodDto` class, so those bodies were never
validated: a service URL could be stored with a `file://` scheme or a trailing
slash even though every per-service endpoint rejects both, a field could be
stored with the wrong type, and an `id` in the body made TypeORM write a second
settings row.

Both routes now go through `settingsUpdateSchema`, reusing the same
`serviceUrlSchema` refinement as the granular routes, matching how the rest of
this controller validates. `POST /settings/plex/token` and
`/settings/cron/validate` were unvalidated for the same reason and now use
schemas too.

Every field stays optional because both routes merge over the stored row, so
existing partial callers keep working.

* fix(docker): stop shipping world-writable application code

Every COPY in the runner stage used `--chmod=777`, so `node_modules`, the
compiled server, the bundled assets and both shell scripts landed
world-writable. Anything that can write inside the container - or on a host that
shares those paths - could edit code that runs on the next restart.

The one write the app tree actually needed was start.sh resolving the UI's
`/__PATH_PREFIX__` placeholder in place, which had to work for whichever uid the
`user` directive selects. start.sh now stages the bundle into the data directory
instead - the one tree that uid is guaranteed to own - and rewrites it there;
ServeStaticModule serves the staged copy and falls back to the bundle so a plain
`node dist/main` still works. That leaves nothing under /opt/app writable.

The staged copy is rebuilt each boot so a changed BASE_PATH or an upgrade cannot
leave a stale chunk behind, and it is made writable after copying because cp
reproduces the read-only source mode. It costs about 12 MB in the data volume.

/opt/data stays world-writable: the image cannot know the uid that will own it.
The runtime data subdirectories drop from 0o777 to 0o755, which is what the
process umask already reduced them to in practice.

* fix(settings): stop partial updates downgrading a bare-hostname HTTPS Plex to HTTP
This commit is contained in:
enoch85
2026-08-05 02:09:03 +02:00
committed by GitHub
parent 66409ef12a
commit 071bda4dc6
23 changed files with 692 additions and 102 deletions
+23 -16
View File
@@ -49,36 +49,43 @@ FROM base AS runner
WORKDIR /opt/app
# Application code is only ever read at runtime, so 755 is enough for any uid
# the container is started with (see the `user` directive) to load it, while
# keeping it unwritable.
# copy root node_modules
COPY --from=builder --chmod=777 --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chmod=755 --chown=node:node /app/node_modules ./node_modules
# Copy standalone server
COPY --from=builder --chmod=777 --chown=node:node /app/apps/server/dist ./apps/server/dist
COPY --from=builder --chmod=777 --chown=node:node /app/apps/server/package.json ./apps/server/package.json
COPY --from=builder --chmod=777 --chown=node:node /app/apps/server/node_modules ./apps/server/node_modules
COPY --from=builder --chmod=755 --chown=node:node /app/apps/server/dist ./apps/server/dist
COPY --from=builder --chmod=755 --chown=node:node /app/apps/server/package.json ./apps/server/package.json
COPY --from=builder --chmod=755 --chown=node:node /app/apps/server/node_modules ./apps/server/node_modules
# copy UI output to API to be served statically
COPY --from=builder --chmod=777 --chown=node:node /app/apps/ui/dist ./apps/server/dist/ui
# copy UI output to API to be served statically. Read-only like the rest:
# start.sh stages a copy under the data directory and resolves the BASE_PATH
# placeholder there, so nothing rewrites this tree.
COPY --from=builder --chmod=755 --chown=node:node /app/apps/ui/dist ./apps/server/dist/ui
# Copy bundled fonts for overlay rendering
COPY --from=builder --chmod=777 --chown=node:node /app/apps/server/assets ./apps/server/dist/assets
COPY --from=builder --chmod=755 --chown=node:node /app/apps/server/assets ./apps/server/dist/assets
# Copy packages/contracts
COPY --from=builder --chmod=777 --chown=node:node /app/packages/contracts/dist ./packages/contracts/dist
COPY --from=builder --chmod=777 --chown=node:node /app/packages/contracts/package.json ./packages/contracts/package.json
COPY --from=builder --chmod=777 --chown=node:node /app/packages/contracts/node_modules ./packages/contracts/node_modules
COPY --from=builder --chmod=755 --chown=node:node /app/packages/contracts/dist ./packages/contracts/dist
COPY --from=builder --chmod=755 --chown=node:node /app/packages/contracts/package.json ./packages/contracts/package.json
COPY --from=builder --chmod=755 --chown=node:node /app/packages/contracts/node_modules ./packages/contracts/node_modules
COPY --chmod=777 --chown=node:node docker/start.sh /opt/app/start.sh
COPY --chmod=777 --chown=node:node docker/healthcheck.sh /opt/app/healthcheck.sh
# 755 keeps these executable by whichever uid the docker user directive selects.
COPY --chmod=755 --chown=node:node docker/start.sh /opt/app/start.sh
COPY --chmod=755 --chown=node:node docker/healthcheck.sh /opt/app/healthcheck.sh
# Create required directories
# Create required directories. World-writable on purpose: the image cannot know
# which uid the container will run as, and this is the one tree that uid has to
# write. It only applies to a fresh named volume - a bind mount keeps the host's
# own ownership and modes.
RUN mkdir -m 777 /opt/data && \
mkdir -m 777 /opt/data/logs && \
chown -R node:node /opt/data
# This is required for docker user directive to work
RUN chmod 777 /opt/app/start.sh /opt/app/healthcheck.sh
# Runtime dependencies for node-canvas (cairo) and sharp (vips)
RUN apk --update --no-cache add \
curl \
+2 -1
View File
@@ -38,6 +38,7 @@ import { AppService } from './app.service';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
import ormConfig from './config/typeOrmConfig';
import { resolveUiRootPath } from './config/uiPath';
@Module({
imports: [
@@ -73,7 +74,7 @@ import ormConfig from './config/typeOrmConfig';
return [
{
rootPath: join(__dirname, '..', 'ui'),
rootPath: resolveUiRootPath(join(__dirname, '..', 'ui')),
serveRoot: process.env.BASE_PATH || undefined,
exclude: ['/api/{*path}'],
},
+32
View File
@@ -0,0 +1,32 @@
import { resolveCorsOptions } from './cors';
describe('resolveCorsOptions', () => {
it('returns nothing in production, so enableCors is never called', () => {
expect(resolveCorsOptions({ NODE_ENV: 'production' })).toBeUndefined();
});
it('reflects any origin outside production', () => {
expect(resolveCorsOptions({ NODE_ENV: 'development' })).toEqual({
origin: true,
});
expect(resolveCorsOptions({})).toEqual({ origin: true });
});
it('uses the configured allowlist, trimming and dropping empty entries', () => {
expect(
resolveCorsOptions({
NODE_ENV: 'production',
CORS_ALLOWED_ORIGINS: ' https://a.example , ,https://b.example ',
}),
).toEqual({ origin: ['https://a.example', 'https://b.example'] });
});
it('prefers the allowlist over development reflection', () => {
expect(
resolveCorsOptions({
NODE_ENV: 'development',
CORS_ALLOWED_ORIGINS: 'https://a.example',
}),
).toEqual({ origin: ['https://a.example'] });
});
});
+47
View File
@@ -0,0 +1,47 @@
import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
/**
* The UI is served by this same Nest process (ServeStaticModule) in production
* and proxied through Vite's `/api` proxy in development, so first-party
* requests are always same-origin and never need CORS headers at all.
*
* Returning `undefined` means "do not call enableCors", so no CORS middleware
* is registered and no Access-Control-Allow-Origin header can be emitted.
*/
export function resolveCorsOptions(
env: NodeJS.ProcessEnv = process.env,
): CorsOptions | undefined {
const allowedOrigins = parseAllowedOrigins(env.CORS_ALLOWED_ORIGINS);
if (allowedOrigins.length > 0) {
// Array form reflects the request origin only when it matches an entry,
// and adds `Vary: Origin` so caches never share the response.
return { origin: allowedOrigins };
}
if (env.NODE_ENV === 'production') {
return undefined;
}
// Development only: reflect whatever origin asks, so a UI served from another
// port or host (and API pokes from tooling) keeps working.
return { origin: true };
}
function parseAllowedOrigins(value: string | undefined): string[] {
if (!value) {
return [];
}
const origins: string[] = [];
for (const entry of value.split(',')) {
const origin = entry.trim();
if (origin.length > 0) {
origins.push(origin);
}
}
return origins;
}
+26
View File
@@ -0,0 +1,26 @@
import * as fs from 'fs';
import path from 'path';
import { resolveUiRootPath, servedUiPath } from './uiPath';
jest.mock('fs', () => ({ existsSync: jest.fn() }));
describe('resolveUiRootPath', () => {
const bundled = '/opt/app/apps/server/dist/ui';
it('serves the staged copy once start.sh has written its index.html', () => {
(fs.existsSync as jest.Mock).mockReturnValue(true);
expect(resolveUiRootPath(bundled)).toBe(servedUiPath);
expect(fs.existsSync).toHaveBeenCalledWith(
path.join(servedUiPath, 'index.html'),
);
});
it('falls back to the bundle when nothing is staged', () => {
// Keys off index.html rather than the directory: a failed copy leaves the
// directory behind, and serving that would 404 every asset.
(fs.existsSync as jest.Mock).mockReturnValue(false);
expect(resolveUiRootPath(bundled)).toBe(bundled);
});
});
+23
View File
@@ -0,0 +1,23 @@
import * as fs from 'fs';
import path from 'path';
import { dataDir } from './dataDir';
/** Where docker/start.sh stages the UI after rewriting the BASE_PATH placeholder. */
export const servedUiPath = path.join(dataDir, 'ui');
/**
* Root that ServeStaticModule hands to Express.
*
* Prefers the staged copy, because that is the one whose /__PATH_PREFIX__
* placeholder has been resolved. Falls back to the bundle shipped next to the
* compiled server so a plain `node dist/main` - no start.sh - still serves a UI,
* exactly as it did before staging existed.
*
* `bundledUiPath` is passed in rather than derived here so the caller can resolve
* it from its own __dirname.
*/
export function resolveUiRootPath(bundledUiPath: string): string {
return fs.existsSync(path.join(servedUiPath, 'index.html'))
? servedUiPath
: bundledUiPath;
}
+8 -3
View File
@@ -6,6 +6,7 @@ import * as fs from 'fs';
import { cleanupOpenApiDoc } from 'nestjs-zod';
import path from 'path';
import { AppModule } from './app/app.module';
import { resolveCorsOptions } from './app/config/cors';
import { dataDir } from './app/config/dataDir';
import { MaintainerrLogger } from './modules/logging/logs.service';
import { installStdioPipeGuards } from './modules/logging/winston/stdioPipeGuard';
@@ -48,7 +49,11 @@ async function bootstrap() {
SwaggerModule.setup('api/swagger', app, document);
app.useLogger(await app.resolve(MaintainerrLogger));
app.enableCors({ origin: true });
const corsOptions = resolveCorsOptions();
if (corsOptions) {
app.enableCors(corsOptions);
}
if (!isSharpAvailable) {
const sharpLogger = await app.resolve(MaintainerrLogger);
@@ -71,7 +76,7 @@ function createDataDirectoryStructure() {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, {
recursive: true,
mode: 0o777,
mode: 0o755,
});
}
@@ -80,7 +85,7 @@ function createDataDirectoryStructure() {
for (const name of ['overlays/fonts', 'overlays/images']) {
const overlayDir = path.join(dataDir, name);
if (!fs.existsSync(overlayDir)) {
fs.mkdirSync(overlayDir, { recursive: true, mode: 0o777 });
fs.mkdirSync(overlayDir, { recursive: true, mode: 0o755 });
}
}
@@ -0,0 +1,111 @@
import nodemailer from 'nodemailer';
import { createMockLogger } from '../../../../test/utils/data';
import { SettingsDataService } from '../../settings/settings-data.service';
import { Notification } from '../entities/notification.entities';
import {
NotificationAgentEmail,
NotificationAgentKey,
NotificationType,
} from '../notifications-interfaces';
import EmailAgent from './email';
const sendMail = jest.fn();
jest.mock('nodemailer', () => ({
__esModule: true,
default: {
createTransport: jest.fn(() => ({
sendMail: (...args: unknown[]) => sendMail(...args),
use: jest.fn(),
})),
},
}));
describe('EmailAgent', () => {
const createAgent = () => {
const settings: NotificationAgentEmail = {
enabled: true,
types: [],
options: {
agent: NotificationAgentKey.EMAIL,
emailFrom: 'maintainerr@example.com',
emailTo: 'admin@example.com',
senderName: 'Maintainerr',
smtpHost: 'smtp.example.com',
smtpPort: 587,
},
} as NotificationAgentEmail;
return new EmailAgent(
{} as SettingsDataService,
settings,
createMockLogger(),
new Notification(),
);
};
const renderedHtml = () => sendMail.mock.calls[0][0].html as string;
beforeEach(() => {
jest.clearAllMocks();
sendMail.mockResolvedValue({ messageId: 'test' });
(nodemailer.createTransport as jest.Mock).mockClear();
});
it('escapes HTML in the message body', async () => {
const agent = createAgent();
const result = await agent.send(
NotificationType.MEDIA_ADDED_TO_COLLECTION,
{
subject: 'Media Added to Collection',
message:
"📂 '<img src=x onerror=\"alert(1)\">' has been added to 'Sample Collection'.",
},
);
expect(result).toBe('Success');
const html = renderedHtml();
expect(html).toContain('&lt;img src=x onerror=&quot;alert(1)&quot;&gt;');
// The Maintainerr logo is the only tag the template is allowed to emit.
expect(html.split('<img').length - 1).toBe(1);
});
it('keeps line breaks in the message body', async () => {
const agent = createAgent();
await agent.send(NotificationType.MEDIA_ABOUT_TO_BE_HANDLED, {
subject: 'Media About to be Handled',
message: 'First line\nSecond line',
});
expect(renderedHtml()).toContain('First line<br>Second line');
});
it('escapes HTML in the test notification body too', async () => {
const agent = createAgent();
await agent.send(NotificationType.TEST_NOTIFICATION, {
subject: 'Test Notification',
message: '<script>alert(1)</script>',
});
const html = renderedHtml();
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
});
it('does not double-escape an ampersand', async () => {
const agent = createAgent();
await agent.send(NotificationType.MEDIA_ADDED_TO_COLLECTION, {
subject: 'Media Added to Collection',
message: 'Sample Show & Friends',
});
const html = renderedHtml();
expect(html).toContain('Sample Show &amp; Friends');
expect(html).not.toContain('&amp;amp;');
});
});
@@ -1,5 +1,6 @@
import type { EmailOptions } from 'email-templates';
import path from 'path';
import { escapeHtml } from '../../../utils/escapeHtml';
import { MaintainerrLogger } from '../../logging/logs.service';
import { SettingsDataService } from '../../settings/settings-data.service';
import PreparedEmail from '../email/preparedEmail';
@@ -43,6 +44,15 @@ class EmailAgent implements NotificationAgent {
return false;
}
/**
* The templates render `body` unescaped so the line breaks below survive, so
* escaping has to happen here - the message carries media titles, collection
* names and requester names, none of which are trusted HTML.
*/
private buildBody(message: string): string {
return escapeHtml(message).replaceAll('\n', '<br>');
}
private buildMessage(
type: NotificationType,
payload: NotificationPayload,
@@ -55,7 +65,7 @@ class EmailAgent implements NotificationAgent {
to: recipientEmail,
},
locals: {
body: payload.message.replaceAll('\n', '<br>'),
body: this.buildBody(payload.message),
recipientEmail,
},
};
@@ -68,7 +78,7 @@ class EmailAgent implements NotificationAgent {
},
locals: {
subject: payload.subject,
body: payload.message.replaceAll('\n', '<br>'),
body: this.buildBody(payload.message),
extra: payload.extra ?? [],
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
@@ -44,4 +44,6 @@ html
| Maintainerr
tr
td(style='padding-top: 16px; color: #fff; font-family: Inter, Arial, sans-serif; font-size: 20px; line-height: 1.6; mso-line-height-rule: exactly;')
//- `body` is pre-escaped by EmailAgent.buildBody so the <br> it
//- inserts survives; never interpolate raw payload text here.
!= body
@@ -44,4 +44,6 @@ html
| Maintainerr
tr
td(style='padding-top: 16px; color: #fff; font-family: Inter, Arial, sans-serif; font-size: 20px; line-height: 1.6; mso-line-height-rule: exactly;')
//- `body` is pre-escaped by EmailAgent.buildBody so the <br> it
//- inserts survives; never interpolate raw payload text here.
!= body
@@ -1,3 +0,0 @@
export class CronScheduleDto {
schedule: string;
}
@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { SettingDto } from './setting.dto';
export class UpdateSettingDto extends PartialType(SettingDto) {}
@@ -0,0 +1,105 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { AddressInfo } from 'net';
import { DatabaseDownloadService } from './database-download.service';
import { MediaServerSwitchService } from './media-server-switch.service';
import { MetadataSettingsService } from './metadata-settings.service';
import { SettingsController } from './settings.controller';
import { SettingsDataService } from './settings-data.service';
import { SettingsOperationsService } from './settings-operations.service';
/**
* Driven over real HTTP because the bug was wiring, not schema: the bulk routes
* took plain DTO classes, which the global nestjs-zod pipe passes through
* untouched. A spec that called the pipe directly would have stayed green.
*/
describe('settings body validation', () => {
let app: INestApplication;
let baseUrl: string;
const settingsOperationsService = {
updateSettings: jest.fn(),
patchSettings: jest.fn(),
savePlexApiAuthToken: jest.fn(),
cronIsValid: jest.fn(),
};
const patch = (body: unknown) =>
fetch(`${baseUrl}/api/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [SettingsController],
providers: [
{
provide: SettingsOperationsService,
useValue: settingsOperationsService,
},
{ provide: SettingsDataService, useValue: {} },
{ provide: MetadataSettingsService, useValue: {} },
{ provide: MediaServerSwitchService, useValue: {} },
{ provide: DatabaseDownloadService, useValue: {} },
],
}).compile();
app = moduleRef.createNestApplication({ logger: false });
await app.listen(0, '127.0.0.1');
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
});
afterAll(async () => {
await app?.close();
});
beforeEach(() => {
jest.clearAllMocks();
settingsOperationsService.patchSettings.mockResolvedValue({ code: 1 });
});
it.each([
{
case: 'a URL without an http(s) scheme',
body: { seerr_url: 'file:///e' },
},
{
case: 'a URL with a trailing slash',
body: { tautulli_url: 'http://t/' },
},
{
case: 'a wrongly typed field',
body: { download_client_fallback_ratio: 'nope' },
},
])('rejects $case', async ({ body }) => {
expect((await patch(body)).status).toBe(400);
expect(settingsOperationsService.patchSettings).not.toHaveBeenCalled();
});
it('strips keys the client has no business setting', async () => {
// An `id` in the body made TypeORM insert a second settings row.
expect((await patch({ id: 999, applicationTitle: 'M' })).status).toBe(200);
expect(settingsOperationsService.patchSettings).toHaveBeenCalledWith({
applicationTitle: 'M',
});
});
it('still accepts the partial payloads the UI sends', async () => {
const body = { plex_hostname: 'plex.local', plex_port: 32400 };
expect((await patch(body)).status).toBe(200);
expect(settingsOperationsService.patchSettings).toHaveBeenCalledWith(body);
});
it('keeps every field GET returns, so a read-modify-write loses nothing', async () => {
const body = {
jellyfin_server_name: 'living-room',
emby_server_name: 'study',
};
expect((await patch(body)).status).toBe(200);
expect(settingsOperationsService.patchSettings).toHaveBeenCalledWith(body);
});
});
@@ -176,6 +176,32 @@ describe('SettingsOperationsService', () => {
expect(settingsDataService.saveSettings).not.toHaveBeenCalled();
});
it('accepts an update that omits the cron schedules', async () => {
// Both routes merge over the stored row, so an absent schedule means "leave
// as-is". Validating it anyway reached `undefined.trim()` and threw a 500.
const response = await service.updateSettings({
applicationTitle: 'Maintainerr Dev',
});
expect(response).toEqual({ status: 'OK', code: 1, message: 'Success' });
expect(settingsDataService.saveSettings).toHaveBeenCalledTimes(1);
});
it('still rejects a schedule that is present but invalid', async () => {
settingsDataService.cronIsValid.mockReturnValue(false);
const response = await service.updateSettings({
collection_handler_job_cron: 'not a cron',
});
expect(response).toEqual({
status: 'NOK',
code: 0,
message: 'Update failed, invalid CRON value was found',
});
expect(settingsDataService.saveSettings).not.toHaveBeenCalled();
});
it('still allows unrelated settings updates when Plex server settings are unchanged', async () => {
const response = await service.updateSettings(
createSettings({ applicationTitle: 'Maintainerr Dev' }),
@@ -265,6 +291,46 @@ describe('SettingsOperationsService', () => {
);
});
it('keeps plex_ssl when a partial update omits the Plex connection fields', async () => {
settingsRepo.findOne.mockResolvedValue(
createSettings({
plex_hostname: 'abc.plex.direct',
plex_port: 32400,
plex_ssl: 1,
}),
);
const response = await service.patchSettings({
applicationTitle: 'Maintainerr Dev',
});
expect(response).toEqual({ status: 'OK', code: 1, message: 'Success' });
expect(settingsDataService.saveSettings).toHaveBeenCalledWith(
expect.objectContaining({
plex_hostname: 'abc.plex.direct',
plex_port: 32400,
plex_ssl: 1,
}),
);
});
it('lets an explicit plex_ssl update turn ssl off for a bare hostname', async () => {
settingsRepo.findOne.mockResolvedValue(
createSettings({
plex_hostname: 'abc.plex.direct',
plex_port: 32400,
plex_ssl: 1,
}),
);
const response = await service.patchSettings({ plex_ssl: 0 });
expect(response).toEqual({ status: 'OK', code: 1, message: 'Success' });
expect(settingsDataService.saveSettings).toHaveBeenCalledWith(
expect.objectContaining({ plex_ssl: 0 }),
);
});
it('returns a clear Plex auth message before calling the Plex API test endpoint', async () => {
settingsDataService.plex_auth_token = null;
@@ -1108,26 +1108,11 @@ export class SettingsOperationsService {
}
}
/** Kept as its own route verb; updateSettings merges over the stored row too. */
public async patchSettings(
settings: Partial<Settings>,
): Promise<BasicResponseDto> {
const settingsDb = await this.settingsRepo.findOne({ where: {} });
if (!settingsDb) {
this.logger.error('Settings could not be loaded for partial update.');
return {
status: 'NOK',
code: 0,
message: 'No settings found to update',
};
}
const mergedSettings: Settings = {
...settingsDb,
...settings,
};
return this.updateSettings(mergedSettings);
return this.updateSettings(settings);
}
private stripPlexProtocolPrefix(hostname: string | null | undefined) {
@@ -1149,16 +1134,25 @@ export class SettingsOperationsService {
private normalizePlexServerConnectionSettings({
hostname,
port,
fallbackSsl,
}: {
hostname: string | null | undefined;
port: number | null | undefined;
fallbackSsl: number | null | undefined;
}) {
const normalizedHostnameInput = hostname?.trim().toLowerCase();
const normalizedHostname = this.stripPlexProtocolPrefix(
normalizedHostnameInput,
);
// Only a scheme prefix or port 443 says anything about TLS. The stored
// hostname is always bare and auto-discovery stores plex.direct hosts on
// 32400 with ssl=1, so a bare hostname must not downgrade fallbackSsl.
const normalizedSsl =
normalizedHostnameInput?.startsWith('https://') || port === 443 ? 1 : 0;
normalizedHostnameInput?.startsWith('https://') || port === 443
? 1
: normalizedHostnameInput?.startsWith('http://')
? 0
: (fallbackSsl ?? 0);
return {
hostname: normalizedHostname,
@@ -1169,7 +1163,7 @@ export class SettingsOperationsService {
private isPlexServerSettingsUpdate(
currentSettings: Settings,
nextSettings: Settings,
nextSettings: Partial<Settings>,
): boolean {
const currentMediaServerType =
nextSettings.media_server_type ?? currentSettings.media_server_type;
@@ -1181,10 +1175,12 @@ export class SettingsOperationsService {
const normalizedCurrent = this.normalizePlexServerConnectionSettings({
hostname: currentSettings.plex_hostname,
port: currentSettings.plex_port,
fallbackSsl: currentSettings.plex_ssl,
});
const normalizedNext = this.normalizePlexServerConnectionSettings({
hostname: nextSettings.plex_hostname,
port: nextSettings.plex_port,
fallbackSsl: nextSettings.plex_ssl,
});
return (
@@ -1195,21 +1191,9 @@ export class SettingsOperationsService {
);
}
public async updateSettings(settings: Settings): Promise<BasicResponseDto> {
if (
!this.cronIsValid(settings.collection_handler_job_cron) ||
!this.cronIsValid(settings.rules_handler_job_cron)
) {
this.logger.error(
'Invalid CRON configuration found, settings update aborted.',
);
return {
status: 'NOK',
code: 0,
message: 'Update failed, invalid CRON value was found',
};
}
public async updateSettings(
settings: Partial<Settings>,
): Promise<BasicResponseDto> {
try {
const settingsDb = await this.settingsRepo.findOne({ where: {} });
@@ -1222,8 +1206,29 @@ export class SettingsOperationsService {
};
}
// Merge before anything reads the payload. An absent field means "leave
// as-is", and every step below - cron validation, the Plex-change check,
// URL lowercasing, hostname/ssl normalisation - assumes it is looking at
// a complete settings object. Reading the raw partial instead reset
// plex_ssl to 0 and rescheduled the collection handler to "undefined".
const merged: Settings = { ...settingsDb, ...settings };
if (
this.isPlexServerSettingsUpdate(settingsDb, settings) &&
!this.cronIsValid(merged.collection_handler_job_cron) ||
!this.cronIsValid(merged.rules_handler_job_cron)
) {
this.logger.error(
'Invalid CRON configuration found, settings update aborted.',
);
return {
status: 'NOK',
code: 0,
message: 'Update failed, invalid CRON value was found',
};
}
if (
this.isPlexServerSettingsUpdate(settingsDb, merged) &&
!settingsDb.plex_auth_token
) {
return {
@@ -1233,22 +1238,20 @@ export class SettingsOperationsService {
};
}
settings.seerr_url = settings.seerr_url?.toLowerCase();
settings.tautulli_url = settings.tautulli_url?.toLowerCase();
merged.seerr_url = merged.seerr_url?.toLowerCase();
merged.tautulli_url = merged.tautulli_url?.toLowerCase();
const normalizedPlexServerSettings =
this.normalizePlexServerConnectionSettings({
hostname: settings.plex_hostname,
port: settings.plex_port,
hostname: merged.plex_hostname,
port: merged.plex_port,
fallbackSsl: merged.plex_ssl,
});
settings.plex_hostname = normalizedPlexServerSettings.hostname;
settings.plex_ssl = normalizedPlexServerSettings.ssl;
merged.plex_hostname = normalizedPlexServerSettings.hostname;
merged.plex_ssl = normalizedPlexServerSettings.ssl;
await this.settingsDataService.saveSettings({
...settingsDb,
...settings,
});
await this.settingsDataService.saveSettings(merged);
await this.settingsDataService.init();
this.logger.log('Settings updated');
@@ -1261,7 +1264,7 @@ export class SettingsOperationsService {
// reload Collection handler job if changed
if (
settingsDb.collection_handler_job_cron !==
settings.collection_handler_job_cron
merged.collection_handler_job_cron
) {
this.logger.log(
`Collection Handler cron schedule changed.. Reloading job.`,
@@ -1270,7 +1273,7 @@ export class SettingsOperationsService {
.getApi()
.put(
'/collections/schedule/update',
`{"schedule": "${settings.collection_handler_job_cron}"}`,
`{"schedule": "${merged.collection_handler_job_cron}"}`,
);
}
@@ -1,5 +1,7 @@
import {
BasicResponseDto,
CronSchedule,
cronScheduleSchema,
EmbyLoginRequest,
embyLoginRequestSchema,
EmbySetting,
@@ -13,10 +15,14 @@ import {
downloadClientSettingSchema,
MetadataProviderSetting,
metadataProviderSettingSchema,
PlexAuthToken,
plexAuthTokenSchema,
RadarrSetting,
radarrSettingSchema,
SeerrSetting,
seerrSettingSchema,
SettingsUpdate,
settingsUpdateSchema,
SonarrSetting,
sonarrSettingSchema,
SportarrSetting,
@@ -61,9 +67,6 @@ import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { ZodValidationPipe } from 'nestjs-zod';
import { DatabaseDownloadService } from './database-download.service';
import { CronScheduleDto } from "./dto's/cron.schedule.dto";
import { SettingDto } from "./dto's/setting.dto";
import { UpdateSettingDto } from "./dto's/update-setting.dto";
import { Settings } from './entities/settings.entities';
import { MediaServerSwitchService } from './media-server-switch.service';
import { MetadataProvider } from './metadata-provider';
@@ -130,15 +133,24 @@ export class SettingsController {
return this.settingsOperationsService.deletePlexApiAuth();
}
@Post()
updateSettings(@Body() payload: SettingDto) {
updateSettings(
@Body(new ZodValidationPipe(settingsUpdateSchema))
payload: SettingsUpdate,
) {
return this.settingsOperationsService.updateSettings(payload);
}
@Patch()
patchSettings(@Body() payload: UpdateSettingDto) {
patchSettings(
@Body(new ZodValidationPipe(settingsUpdateSchema))
payload: SettingsUpdate,
) {
return this.settingsOperationsService.patchSettings(payload);
}
@Post('/plex/token')
updateAuthToken(@Body() payload: { plex_auth_token: string }) {
updateAuthToken(
@Body(new ZodValidationPipe(plexAuthTokenSchema))
payload: PlexAuthToken,
) {
return this.settingsOperationsService.savePlexApiAuthToken(
payload.plex_auth_token,
);
@@ -683,7 +695,9 @@ export class SettingsController {
}
@Post('/cron/validate')
validateSingleCron(@Body() payload: CronScheduleDto) {
validateSingleCron(
@Body(new ZodValidationPipe(cronScheduleSchema)) payload: CronSchedule,
) {
return this.settingsOperationsService.cronIsValid(payload.schedule)
? { status: 'OK', code: 1, message: 'Success' }
: { status: 'NOK', code: 0, message: 'Failure' };
+21
View File
@@ -0,0 +1,21 @@
const HTML_ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
/**
* Escape text for interpolation into an HTML document. Char-by-char rather than
* a chained replace so `&` can never be double-escaped.
*/
export function escapeHtml(value: string): string {
let escaped = '';
for (const char of value) {
escaped += HTML_ESCAPES[char] ?? char;
}
return escaped;
}
+17 -10
View File
@@ -1,17 +1,24 @@
import { LoggerService } from '@nestjs/common';
jest.mock('@nestjs/common', () => {
const Logger = function () {
return {
debug: jest.fn(),
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
fatal: jest.fn(),
verbose: jest.fn(),
} satisfies LoggerService;
};
// Nest calls these statics when a spec boots a real application; the real
// Logger has them, so the stand-in needs them too.
Logger.overrideLogger = jest.fn();
Logger.flush = jest.fn();
return {
...jest.requireActual('@nestjs/common'),
Logger: function () {
return {
debug: jest.fn(),
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
fatal: jest.fn(),
verbose: jest.fn(),
} satisfies LoggerService;
},
Logger,
};
});
+29 -9
View File
@@ -1,16 +1,36 @@
#!/bin/sh
BASE_PATH_REPLACE="${BASE_PATH:-}"
UI_DIST_DIR="/opt/app/apps/server/dist/ui"
BUNDLED_UI_DIR="/opt/app/apps/server/dist/ui"
SERVED_UI_DIR="${DATA_DIR:-/opt/data}/ui"
# Replace the path prefix placeholder inside the built UI files; this can fail when
# the directory is mounted as read-only, so surface a clearer error in that case.
if ! find "$UI_DIST_DIR" -type f -not -path '*/node_modules/*' -print0 | xargs -0 sed -i "s,/__PATH_PREFIX__,$BASE_PATH_REPLACE,g"; then
printf 'Failed to rewrite UI base paths under %s.\n' "$UI_DIST_DIR" >&2
if [ ! -w "$UI_DIST_DIR" ]; then
printf 'Read-only filesystem detected. Mounting this directory as read-only is not supported.\n' >&2
fi
printf 'Please run the container with a writable filesystem and try again.\n' >&2
# The UI bundle carries a /__PATH_PREFIX__ placeholder that only resolves once
# BASE_PATH is known, which is at boot. Rewriting it inside the image would mean
# shipping the app tree world-writable, since the container runs as whichever uid
# the `user` directive picks. Instead the bundle is copied into the data
# directory - the one tree that uid is guaranteed to own - and rewritten there.
# Rebuilt from scratch every boot so an upgrade or a changed BASE_PATH can never
# leave a stale chunk behind.
#
# cp reproduces the source mode and the bundle ships read-only, so both the copy
# and the previous boot's copy come out unwritable. rm needs write on a directory
# to unlink what is inside it, so restore that before clearing and again after
# copying. The staging user owns these paths, so chmod works whichever uid it is.
if [ -d "$SERVED_UI_DIR" ]; then
chmod -R u+w "$SERVED_UI_DIR" 2>/dev/null
fi
rm -rf "$SERVED_UI_DIR"
if ! mkdir -p "$SERVED_UI_DIR" ||
! cp -R "$BUNDLED_UI_DIR/." "$SERVED_UI_DIR/" ||
! chmod -R u+w "$SERVED_UI_DIR"; then
printf 'Failed to stage the UI into %s.\n' "$SERVED_UI_DIR" >&2
printf 'The data directory must be writable by the user the container runs as.\n' >&2
exit 1
fi
if ! find "$SERVED_UI_DIR" -type f -print0 | xargs -0 sed -i "s,/__PATH_PREFIX__,$BASE_PATH_REPLACE,g"; then
printf 'Failed to rewrite UI base paths under %s.\n' "$SERVED_UI_DIR" >&2
exit 1
fi
+1
View File
@@ -99,6 +99,7 @@ A list of all available environment variables are below. No other env variables
| UI_PORT | 6246 | The listen port of the web server. |
| BASE_PATH | (*none*) | If reverse proxying with a subfolder you'll want to set this. Must be in the format of `/subfolder` |
| GITHUB_TOKEN | (*none*) | GitHub Personal Access Token for higher API rate limits |
| CORS_ALLOWED_ORIGINS | (*none*) | Comma-separated list of origins allowed to call the API cross-origin, e.g. `https://maintainerr.example.com`. The bundled UI is served from the same origin as the API and does not need this; only set it if a separate front end calls the API. |
# Features
+1
View File
@@ -12,6 +12,7 @@ export * from './settings/metadata'
export * from './settings/seerr'
export * from './settings/servarr'
export * from './settings/serviceUrl'
export * from './settings/settings'
export * from './settings/streamystats'
export * from './settings/tautulli'
export * from './settings/tracearr'
@@ -0,0 +1,93 @@
import z from 'zod'
import { MediaServerType } from '../media-server/enums'
import { MetadataProviderPreference } from './metadata'
import { serviceUrlSchema } from './serviceUrl'
/**
* Body schema for the bulk settings endpoints (`POST` / `PATCH /api/settings`).
*
* Both endpoints merge the body over the stored row, so every field is
* optional and an absent key means "leave as-is". Service URLs reuse
* `serviceUrlSchema`, the same refinement the per-service endpoints enforce, so
* a URL cannot be smuggled past it through the bulk route.
*
* `id` is deliberately absent: letting it through made TypeORM write a second
* settings row. Everything else `GET /api/settings` returns is accepted, so a
* read-modify-write round trip does not silently drop fields.
*/
export const settingsUpdateSchema = z.object({
clientId: z.string().trim().optional(),
applicationTitle: z.string().trim().optional(),
// Not a service URL: this defaults to the bare host 'localhost'.
applicationUrl: z.string().trim().optional(),
apikey: z.string().trim().optional(),
locale: z.string().trim().optional(),
media_server_type: z.enum(MediaServerType).nullable().optional(),
plex_name: z.string().trim().optional(),
// A host or host:port, optionally scheme-prefixed; the server normalises it.
plex_hostname: z.string().trim().optional(),
plex_port: z.number().int().min(1).max(65535).optional(),
plex_ssl: z.number().int().min(0).max(1).optional(),
plex_auth_token: z.string().trim().optional(),
plex_machine_id: z.string().trim().optional(),
plex_manual_mode: z.number().int().min(0).max(1).optional(),
jellyfin_url: serviceUrlSchema.optional(),
jellyfin_api_key: z.string().trim().optional(),
jellyfin_user_id: z.string().trim().optional(),
jellyfin_server_name: z.string().trim().optional(),
emby_url: serviceUrlSchema.optional(),
emby_api_key: z.string().trim().optional(),
emby_user_id: z.string().trim().optional(),
emby_server_name: z.string().trim().optional(),
seerr_url: serviceUrlSchema.optional(),
seerr_api_key: z.string().trim().optional(),
tmdb_api_key: z.string().trim().optional(),
tvdb_api_key: z.string().trim().optional(),
metadata_provider_preference: z.enum(MetadataProviderPreference).optional(),
tautulli_url: serviceUrlSchema.optional(),
tautulli_api_key: z.string().trim().optional(),
streamystats_url: serviceUrlSchema.optional(),
tracearr_url: serviceUrlSchema.optional(),
tracearr_api_key: z.string().trim().optional(),
tracearr_server_id: z.string().trim().optional(),
download_client_url: serviceUrlSchema.optional(),
// Not trimmed: the download client compares credentials verbatim.
download_client_username: z.string().optional(),
download_client_password: z.string().optional(),
download_client_delete_data: z.boolean().optional(),
download_client_fallback_ratio: z.number().min(0.5).optional(),
collection_handler_job_cron: z.string().trim().optional(),
rules_handler_job_cron: z.string().trim().optional(),
radarr_tag_exclusions: z.boolean().optional(),
radarr_exclusion_tag: z.string().trim().optional(),
radarr_untag_on_unexclude: z.boolean().optional(),
sonarr_tag_exclusions: z.boolean().optional(),
sonarr_exclusion_tag: z.string().trim().optional(),
sonarr_untag_on_unexclude: z.boolean().optional(),
})
export type SettingsUpdate = z.infer<typeof settingsUpdateSchema>
export const cronScheduleSchema = z.object({
schedule: z.string(),
})
export type CronSchedule = z.infer<typeof cronScheduleSchema>
export const plexAuthTokenSchema = z.object({
plex_auth_token: z.string().trim().min(1, 'Plex auth token is required'),
})
export type PlexAuthToken = z.infer<typeof plexAuthTokenSchema>