Compare commits

...

7 Commits

Author SHA1 Message Date
0xsysr3ll
3070ba74ae docs(webhook): clarify conflict between Authorization header methods
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-15 19:04:08 +01:00
0xsysr3ll
8979085fb5 feat(webhook): add validation for Authorization header conflict
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-15 19:04:08 +01:00
0xsysr3ll
4232ef19d4 docs(webhook): add a note on the Authorization header precedence
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-15 19:04:08 +01:00
0xsysr3ll
db48f449f5 feat(webhook): add simple validation
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-15 19:04:08 +01:00
0xsysr3ll
37b83fe56d feat(webhook): add support for custom headers in webhook notifications
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-15 19:04:08 +01:00
fallenbagel
3ee69663dc fix(local-login): remove automatic plex linking and reduce logout log verbosity (#2225)
Removed redundant Plex user discovery logic that applies to all media servers currently. This is now
handled explicitly via linked accounts settings page. Also changed the successful logout log level
from info to debug since its routine behaviour
2025-12-15 19:44:43 +08:00
Ludovic Ortega
539d49879d chore: fix translate badge svg url (#2228)
* chore: fix translate badge svg url

Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>

* fix: use https instead of http

Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>

---------

Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2025-12-14 05:37:36 +08:00
8 changed files with 189 additions and 80 deletions

View File

@@ -8,7 +8,7 @@
<p align="center">
<a href="https://discord.gg/seerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
<a href="https://hub.docker.com/r/seerr/seerr"><img src="https://img.shields.io/docker/pulls/seerr/seerr" alt="Docker pulls"></a>
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/seerr-frontend/svg-badge.svg" alt="Translation status" /></a>
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/svg-badge.svg" alt="Translation status" /></a>
<a href="https://github.com/seerr-team/seerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/seerr-team/seerr"></a>
**Seerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.

View File

@@ -22,6 +22,17 @@ This is typically not needed. Please refer to your webhook provider's documentat
This value will be sent as an `Authorization` HTTP header.
### Custom Headers (optional)
You can add additional custom HTTP headers to be sent with each webhook request. This is useful for API keys, custom authentication schemes, or any other headers your webhook endpoint requires.
- Click "Add Header" to add a new header
- Enter the header name and value
:::warning
You cannot configure both the **Authorization Header** field and a custom `Authorization` header in Custom Headers at the same time. You must choose one method.
:::
### JSON Payload
Customize the JSON payload to suit your needs. Seerr provides several [template variables](#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.

View File

@@ -196,16 +196,33 @@ class WebhookAgent
}
try {
const headers: Record<string, string> = {};
if (settings.options.authHeader) {
headers.Authorization = settings.options.authHeader;
}
if (
settings.options.customHeaders &&
settings.options.customHeaders.length > 0
) {
settings.options.customHeaders.forEach((header) => {
if (header.key && header.value) {
// Don't override Authorization header if it's already set via authHeader
if (
header.key.toLowerCase() !== 'authorization' ||
!settings.options.authHeader
) {
headers[header.key] = header.value;
}
}
});
}
await axios.post(
webhookUrl,
this.buildPayload(type, payload),
settings.options.authHeader
? {
headers: {
Authorization: settings.options.authHeader,
},
}
: undefined
Object.keys(headers).length > 0 ? { headers } : undefined
);
return true;

View File

@@ -275,6 +275,7 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
webhookUrl: string;
jsonPayload: string;
authHeader?: string;
customHeaders?: { key: string; value: string }[];
supportVariables?: boolean;
};
}

View File

@@ -626,76 +626,6 @@ authRoutes.post('/local', async (req, res, next) => {
});
}
const mainUser = await userRepository.findOneOrFail({
select: { id: true, plexToken: true, plexId: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
if (!user.plexId) {
try {
const plexUsersResponse = await mainPlexTv.getUsers();
const account = plexUsersResponse.MediaContainer.User.find(
(account) =>
account.$.email &&
account.$.email.toLowerCase() === user.email.toLowerCase()
)?.$;
if (
account &&
(await mainPlexTv.checkUserAccess(parseInt(account.id)))
) {
logger.info(
'Found matching Plex user; updating user with Plex data',
{
label: 'API',
ip: req.ip,
email: body.email,
userId: user.id,
plexId: account.id,
plexUsername: account.username,
}
);
user.plexId = parseInt(account.id);
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
user.userType = UserType.PLEX;
await userRepository.save(user);
}
} catch (e) {
logger.error('Something went wrong fetching Plex users', {
label: 'API',
errorMessage: e.message,
});
}
}
if (
user.plexId &&
user.plexId !== mainUser.plexId &&
!(await mainPlexTv.checkUserAccess(user.plexId))
) {
logger.warn(
'Failed sign-in attempt from Plex user without access to the media server',
{
label: 'API',
account: {
ip: req.ip,
email: body.email,
userId: user.id,
plexId: user.plexId,
},
}
);
return next({
status: 403,
message: 'Access denied.',
});
}
// Set logged in session
if (user && req.session) {
req.session.userId = user.id;
@@ -775,7 +705,7 @@ authRoutes.post('/logout', async (req, res, next) => {
});
return next({ status: 500, message: 'Failed to destroy session.' });
}
logger.info('Successfully logged out user', {
logger.debug('Successfully logged out user', {
label: 'Auth',
userId,
});

View File

@@ -279,6 +279,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
'utf8'
)
),
customHeaders: webhookSettings.options.customHeaders ?? [],
supportVariables: webhookSettings.options.supportVariables ?? false,
},
};
@@ -301,6 +302,7 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
),
webhookUrl: req.body.options.webhookUrl,
authHeader: req.body.options.authHeader,
customHeaders: req.body.options.customHeaders ?? [],
supportVariables: req.body.options.supportVariables ?? false,
},
};
@@ -333,6 +335,7 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
),
webhookUrl: req.body.options.webhookUrl,
authHeader: req.body.options.authHeader,
customHeaders: req.body.options.customHeaders ?? [],
supportVariables: req.body.options.supportVariables ?? false,
},
};

View File

@@ -5,7 +5,12 @@ import SettingsBadge from '@app/components/Settings/SettingsBadge';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { isValidURL } from '@app/utils/urlValidationHelper';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import {
ArrowDownOnSquareIcon,
BeakerIcon,
PlusIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import {
ArrowPathIcon,
QuestionMarkCircleIcon,
@@ -80,6 +85,16 @@ const messages = defineMessages(
supportVariablesTip:
'Available variables are documented in the webhook template variables section',
authheader: 'Authorization Header',
customHeaders: 'Custom Headers',
customHeadersTip:
'Add custom HTTP headers to include with webhook requests',
customHeadersAdd: 'Add Header',
customHeadersRemove: 'Remove',
customHeadersKey: 'Header Name',
customHeadersValue: 'Header Value',
customHeadersIncomplete: 'All headers must have both name and value',
customHeadersAuthConflict:
'Cannot use both Authorization Header and custom Authorization header. Please remove one.',
validationJsonPayloadRequired: 'You must provide a valid JSON payload',
webhooksettingssaved: 'Webhook notification settings saved successfully!',
webhooksettingsfailed: 'Webhook notification settings failed to save.',
@@ -125,6 +140,43 @@ const NotificationsWebhook = () => {
supportVariables: Yup.boolean(),
customHeaders: Yup.array()
.of(
Yup.object().shape({
key: Yup.string(),
value: Yup.string(),
})
)
.test(
'complete-headers',
intl.formatMessage(messages.customHeadersIncomplete),
function (headers) {
if (!headers || headers.length === 0) return true;
return headers.every(
(header) =>
(!header.key || !header.key.trim()) ===
(!header.value || !header.value.trim())
);
}
)
.test(
'auth-conflict',
intl.formatMessage(messages.customHeadersAuthConflict),
function (headers) {
const { authHeader } = this.parent;
if (!authHeader || !headers || headers.length === 0) return true;
const hasCustomAuthHeader = headers.some(
(header) =>
header.key &&
header.value &&
header.key.toLowerCase() === 'authorization'
);
return !hasCustomAuthHeader;
}
),
jsonPayload: Yup.string()
.when('enabled', {
is: true,
@@ -159,6 +211,7 @@ const NotificationsWebhook = () => {
webhookUrl: data.options.webhookUrl,
jsonPayload: data.options.jsonPayload,
authHeader: data.options.authHeader,
customHeaders: data.options.customHeaders ?? [],
supportVariables: data.options.supportVariables ?? false,
}}
validationSchema={NotificationsWebhookSchema}
@@ -171,6 +224,9 @@ const NotificationsWebhook = () => {
webhookUrl: values.webhookUrl,
jsonPayload: JSON.stringify(values.jsonPayload),
authHeader: values.authHeader,
customHeaders: values.customHeaders.filter(
(h: { key: string; value: string }) => h.key && h.value
),
supportVariables: values.supportVariables,
},
});
@@ -229,6 +285,9 @@ const NotificationsWebhook = () => {
webhookUrl: values.webhookUrl,
jsonPayload: JSON.stringify(values.jsonPayload),
authHeader: values.authHeader,
customHeaders: values.customHeaders.filter(
(h: { key: string; value: string }) => h.key && h.value
),
supportVariables: values.supportVariables ?? false,
},
});
@@ -344,6 +403,86 @@ const NotificationsWebhook = () => {
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="customHeaders" className="text-label">
{intl.formatMessage(messages.customHeaders)}
<span className="label-tip">
{intl.formatMessage(messages.customHeadersTip)}
</span>
</label>
<div className="form-input-area">
<div className="space-y-2">
{values.customHeaders.map(
(header: { key: string; value: string }, index: number) => (
<div key={index} className="flex gap-2">
<div className="flex-1">
<div className="form-input-field">
<Field
name={`customHeaders.${index}.key`}
type="text"
placeholder={intl.formatMessage(
messages.customHeadersKey
)}
/>
</div>
</div>
<div className="flex-1">
<div className="form-input-field">
<Field
name={`customHeaders.${index}.value`}
type="text"
placeholder={intl.formatMessage(
messages.customHeadersValue
)}
/>
</div>
</div>
<div className="flex items-center">
<Button
buttonType="danger"
buttonSize="sm"
onClick={(e) => {
e.preventDefault();
const newHeaders = values.customHeaders.filter(
(
_: { key: string; value: string },
i: number
) => i !== index
);
setFieldValue('customHeaders', newHeaders);
}}
title={intl.formatMessage(
messages.customHeadersRemove
)}
>
<TrashIcon />
</Button>
</div>
</div>
)
)}
<Button
buttonType="default"
buttonSize="sm"
onClick={(e) => {
e.preventDefault();
setFieldValue('customHeaders', [
...values.customHeaders,
{ key: '', value: '' },
]);
}}
>
<PlusIcon />
<span>{intl.formatMessage(messages.customHeadersAdd)}</span>
</Button>
</div>
{errors.customHeaders &&
touched.customHeaders &&
typeof errors.customHeaders === 'string' && (
<div className="error">{errors.customHeaders}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="webhook-json-payload" className="text-label">
{intl.formatMessage(messages.customJson)}

View File

@@ -681,6 +681,14 @@
"components.Settings.Notifications.NotificationsSlack.webhookUrlTip": "Create an <WebhookLink>Incoming Webhook</WebhookLink> integration",
"components.Settings.Notifications.NotificationsWebhook.agentenabled": "Enable Agent",
"components.Settings.Notifications.NotificationsWebhook.authheader": "Authorization Header",
"components.Settings.Notifications.NotificationsWebhook.customHeaders": "Custom Headers",
"components.Settings.Notifications.NotificationsWebhook.customHeadersAdd": "Add Header",
"components.Settings.Notifications.NotificationsWebhook.customHeadersAuthConflict": "Cannot use both Authorization Header and custom Authorization header. Please remove one.",
"components.Settings.Notifications.NotificationsWebhook.customHeadersIncomplete": "All headers must have both name and value",
"components.Settings.Notifications.NotificationsWebhook.customHeadersKey": "Header Name",
"components.Settings.Notifications.NotificationsWebhook.customHeadersRemove": "Remove",
"components.Settings.Notifications.NotificationsWebhook.customHeadersTip": "Add custom HTTP headers to include with webhook requests",
"components.Settings.Notifications.NotificationsWebhook.customHeadersValue": "Header Value",
"components.Settings.Notifications.NotificationsWebhook.customJson": "JSON Payload",
"components.Settings.Notifications.NotificationsWebhook.resetPayload": "Reset to Default",
"components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON payload reset successfully!",