Compare commits

..

1 Commits

Author SHA1 Message Date
fallenbagel
6dc00c8732 fix(externalapi): extract basic auth and pass it through header
This commit adds extraction of basic authentication credentials from the URL and then pass the
credentials as the `Authorization` header. And then credentials are removed from the URL before
being passed to fetch. This is done because fetch request cannot be constructed using a URL with
credentials

fix #1027
2024-11-01 02:52:26 +08:00
13 changed files with 70 additions and 144 deletions

View File

@@ -18,7 +18,7 @@ config/logs/*
config/*.json config/*.json
dist dist
Dockerfile* Dockerfile*
compose.yaml docker-compose.yml
docs docs
LICENSE LICENSE
node_modules node_modules

2
.gitattributes vendored
View File

@@ -40,7 +40,7 @@ docs export-ignore
.all-contributorsrc export-ignore .all-contributorsrc export-ignore
.editorconfig export-ignore .editorconfig export-ignore
Dockerfile.local export-ignore Dockerfile.local export-ignore
compose.yaml export-ignore docker-compose.yml export-ignore
stylelint.config.js export-ignore stylelint.config.js export-ignore
public/os_logo_filled.png export-ignore public/os_logo_filled.png export-ignore

View File

@@ -52,7 +52,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
pnpm dev pnpm dev
``` ```
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly. - Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
5. Create your patch and test your changes. 5. Create your patch and test your changes.

View File

@@ -1,3 +1,4 @@
version: '3'
services: services:
jellyseerr: jellyseerr:
build: build:

View File

@@ -190,7 +190,7 @@ Caddy will automatically obtain and renew SSL certificates for your domain.
## Traefik (v2) ## Traefik (v2)
Add the following labels to the Jellyseerr service in your `compose.yaml` file: Add the following labels to the Jellyseerr service in your `docker-compose.yml` file:
```yaml ```yaml
labels: labels:

View File

@@ -71,7 +71,7 @@ You could also use [diun](https://github.com/crazy-max/diun) to receive notifica
For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/). For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/).
#### Installation: #### Installation:
Define the `jellyseerr` service in your `compose.yaml` as follows: Define the `jellyseerr` service in your `docker-compose.yml` as follows:
```yaml ```yaml
--- ---
services: services:
@@ -94,17 +94,17 @@ If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable
Then, start all services defined in the Compose file: Then, start all services defined in the Compose file:
```bash ```bash
docker compose up -d docker-compose up -d
``` ```
#### Updating: #### Updating:
Pull the latest image: Pull the latest image:
```bash ```bash
docker compose pull jellyseerr docker-compose pull jellyseerr
``` ```
Then, restart all services defined in the Compose file: Then, restart all services defined in the Compose file:
```bash ```bash
docker compose up -d docker-compose up -d
``` ```
:::tip :::tip
You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files. You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files.

View File

@@ -4142,21 +4142,6 @@ paths:
'412': '412':
description: Item has already been blacklisted description: Item has already been blacklisted
/blacklist/{tmdbId}: /blacklist/{tmdbId}:
get:
summary: Get media from blacklist
tags:
- blacklist
parameters:
- in: path
name: tmdbId
description: tmdbId ID
required: true
example: '1'
schema:
type: string
responses:
'200':
description: Blacklist details in JSON
delete: delete:
summary: Remove media from blacklist summary: Remove media from blacklist
tags: tags:

View File

@@ -80,12 +80,12 @@ export class Blacklist implements BlacklistItem {
status: MediaStatus.BLACKLISTED, status: MediaStatus.BLACKLISTED,
status4k: MediaStatus.BLACKLISTED, status4k: MediaStatus.BLACKLISTED,
mediaType: blacklistRequest.mediaType, mediaType: blacklistRequest.mediaType,
blacklist: Promise.resolve(blacklist), blacklist: blacklist,
}); });
await mediaRepository.save(media); await mediaRepository.save(media);
} else { } else {
media.blacklist = Promise.resolve(blacklist); media.blacklist = blacklist;
media.status = MediaStatus.BLACKLISTED; media.status = MediaStatus.BLACKLISTED;
media.status4k = MediaStatus.BLACKLISTED; media.status4k = MediaStatus.BLACKLISTED;

View File

@@ -118,8 +118,10 @@ class Media {
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true }) @OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
public issues: Issue[]; public issues: Issue[];
@OneToOne(() => Blacklist, (blacklist) => blacklist.media) @OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
public blacklist: Promise<Blacklist>; eager: true,
})
public blacklist: Blacklist;
@CreateDateColumn() @CreateDateColumn()
public createdAt: Date; public createdAt: Date;

View File

@@ -299,27 +299,14 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
where: { jellyfinUserId: account.User.Id }, where: { jellyfinUserId: account.User.Id },
}); });
const missingAdminUser = !user && !(await userRepository.count()); if (!user && !(await userRepository.count())) {
if (
missingAdminUser ||
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED
) {
// Check if user is admin on jellyfin // Check if user is admin on jellyfin
if (account.User.Policy.IsAdministrator === false) { if (account.User.Policy.IsAdministrator === false) {
throw new ApiError(403, ApiErrorCode.NotAdmin); throw new ApiError(403, ApiErrorCode.NotAdmin);
} }
if (
body.serverType !== MediaServerType.JELLYFIN &&
body.serverType !== MediaServerType.EMBY
) {
throw new Error('select_server_type');
}
settings.main.mediaServerType = body.serverType;
if (missingAdminUser) {
logger.info( logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Jellyseerr', 'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr',
{ {
label: 'API', label: 'API',
ip: req.ip, ip: req.ip,
@@ -329,9 +316,10 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
// User doesn't exist, and there are no users in the database, we'll create the user // User doesn't exist, and there are no users in the database, we'll create the user
// with admin permissions // with admin permissions
switch (body.serverType) {
case MediaServerType.EMBY:
settings.main.mediaServerType = MediaServerType.EMBY;
user = new User({ user = new User({
id: 1,
email: body.email || account.User.Name, email: body.email || account.User.Name,
jellyfinUsername: account.User.Name, jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id, jellyfinUserId: account.User.Id,
@@ -339,44 +327,26 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
jellyfinAuthToken: account.AccessToken, jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN, permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`, avatar: `/avatarproxy/${account.User.Id}`,
userType: userType: UserType.EMBY,
body.serverType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
}); });
await userRepository.save(user); break;
} else { case MediaServerType.JELLYFIN:
logger.info( settings.main.mediaServerType = MediaServerType.JELLYFIN;
'Sign-in attempt from Jellyfin user with access to the media server; editing admin user for Jellyseerr', user = new User({
{ email: body.email || account.User.Name,
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name, jellyfinUsername: account.User.Name,
} jellyfinUserId: account.User.Id,
); jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
// User alread exist but settings.json is not configured, we'll edit the admin user permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
user = await userRepository.findOne({ userType: UserType.JELLYFIN,
where: { id: 1 },
}); });
if (!user) {
throw new Error('Unable to find admin user to edit');
}
user.email = body.email || account.User.Name;
user.jellyfinUsername = account.User.Name;
user.jellyfinUserId = account.User.Id;
user.jellyfinDeviceId = deviceId;
user.jellyfinAuthToken = account.AccessToken;
user.permissions = Permission.ADMIN;
user.avatar = `/avatarproxy/${account.User.Id}`;
user.userType =
body.serverType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY;
await userRepository.save(user); break;
default:
throw new Error('select_server_type');
} }
// Create an API key on Jellyfin from this admin user // Create an API key on Jellyfin from this admin user
@@ -398,6 +368,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
settings.jellyfin.apiKey = apiKey; settings.jellyfin.apiKey = apiKey;
await settings.save(); await settings.save();
startJobs(); startJobs();
await userRepository.save(user);
} }
// User already exists, let's update their information // User already exists, let's update their information
else if (account.User.Id === user?.jellyfinUserId) { else if (account.User.Id === user?.jellyfinUserId) {

View File

@@ -2,13 +2,14 @@ import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist'; import { Blacklist } from '@server/entity/Blacklist';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { NotFoundError } from '@server/entity/Watchlist';
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces'; import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express'; import { Router } from 'express';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import { EntityNotFoundError, QueryFailedError } from 'typeorm'; import { QueryFailedError } from 'typeorm';
import { z } from 'zod'; import { z } from 'zod';
const blacklistRoutes = Router(); const blacklistRoutes = Router();
@@ -70,32 +71,6 @@ blacklistRoutes.get(
} }
); );
blacklistRoutes.get(
'/:id',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
type: 'or',
}),
async (req, res, next) => {
try {
const blacklisteRepository = getRepository(Blacklist);
const blacklistItem = await blacklisteRepository.findOneOrFail({
where: { tmdbId: Number(req.params.id) },
});
return res.status(200).send(blacklistItem);
} catch (e) {
if (e instanceof EntityNotFoundError) {
return next({
status: 401,
message: e.message,
});
}
return next({ status: 500, message: e.message });
}
}
);
blacklistRoutes.post( blacklistRoutes.post(
'/', '/',
isAuthenticated([Permission.MANAGE_BLACKLIST], { isAuthenticated([Permission.MANAGE_BLACKLIST], {
@@ -159,7 +134,7 @@ blacklistRoutes.delete(
return res.status(204).send(); return res.status(204).send();
} catch (e) { } catch (e) {
if (e instanceof EntityNotFoundError) { if (e instanceof NotFoundError) {
return next({ return next({
status: 401, status: 401,
message: e.message, message: e.message,

View File

@@ -1,6 +1,5 @@
import Badge from '@app/components/Common/Badge'; import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Tooltip from '@app/components/Common/Tooltip'; import Tooltip from '@app/components/Common/Tooltip';
import { useUser } from '@app/hooks/useUser'; import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
@@ -11,7 +10,6 @@ import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('component.BlacklistBlock', { const messages = defineMessages('component.BlacklistBlock', {
blacklistedby: 'Blacklisted By', blacklistedby: 'Blacklisted By',
@@ -19,13 +17,13 @@ const messages = defineMessages('component.BlacklistBlock', {
}); });
interface BlacklistBlockProps { interface BlacklistBlockProps {
tmdbId: number; blacklistItem: Blacklist;
onUpdate?: () => void; onUpdate?: () => void;
onDelete?: () => void; onDelete?: () => void;
} }
const BlacklistBlock = ({ const BlacklistBlock = ({
tmdbId, blacklistItem,
onUpdate, onUpdate,
onDelete, onDelete,
}: BlacklistBlockProps) => { }: BlacklistBlockProps) => {
@@ -33,7 +31,6 @@ const BlacklistBlock = ({
const intl = useIntl(); const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const { addToast } = useToasts(); const { addToast } = useToasts();
const { data } = useSWR<Blacklist>(`/api/v1/blacklist/${tmdbId}`);
const removeFromBlacklist = async (tmdbId: number, title?: string) => { const removeFromBlacklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true); setIsUpdating(true);
@@ -65,14 +62,6 @@ const BlacklistBlock = ({
setIsUpdating(false); setIsUpdating(false);
}; };
if (!data) {
return (
<>
<LoadingSpinner />
</>
);
}
return ( return (
<div className="px-4 py-3 text-gray-300"> <div className="px-4 py-3 text-gray-300">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -84,13 +73,13 @@ const BlacklistBlock = ({
<span className="w-40 truncate md:w-auto"> <span className="w-40 truncate md:w-auto">
<Link <Link
href={ href={
data.user.id === user?.id blacklistItem.user.id === user?.id
? '/profile' ? '/profile'
: `/users/${data.user.id}` : `/users/${blacklistItem.user.id}`
} }
> >
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"> <span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{data.user.displayName} {blacklistItem.user.displayName}
</span> </span>
</Link> </Link>
</span> </span>
@@ -102,7 +91,9 @@ const BlacklistBlock = ({
> >
<Button <Button
buttonType="danger" buttonType="danger"
onClick={() => removeFromBlacklist(data.tmdbId, data.title)} onClick={() =>
removeFromBlacklist(blacklistItem.tmdbId, blacklistItem.title)
}
disabled={isUpdating} disabled={isUpdating}
> >
<TrashIcon className="icon-sm" /> <TrashIcon className="icon-sm" />
@@ -123,7 +114,7 @@ const BlacklistBlock = ({
<CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" /> <CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
</Tooltip> </Tooltip>
<span> <span>
{intl.formatDate(data.createdAt, { {intl.formatDate(blacklistItem.createdAt, {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',

View File

@@ -292,7 +292,7 @@ const ManageSlideOver = ({
</h3> </h3>
<div className="overflow-hidden rounded-md border border-gray-700 shadow"> <div className="overflow-hidden rounded-md border border-gray-700 shadow">
<BlacklistBlock <BlacklistBlock
tmdbId={data.mediaInfo.tmdbId} blacklistItem={data.mediaInfo.blacklist}
onUpdate={() => revalidate()} onUpdate={() => revalidate()}
onDelete={() => onClose()} onDelete={() => onClose()}
/> />