Skip to content

Commit

Permalink
feat(linked-accounts): add support for linking/unlinking jellyfin acc…
Browse files Browse the repository at this point in the history
…ounts
  • Loading branch information
michaelhthomas committed Jul 21, 2024
1 parent 2dc13ac commit be5a2ec
Show file tree
Hide file tree
Showing 9 changed files with 625 additions and 21 deletions.
48 changes: 48 additions & 0 deletions overseerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4229,6 +4229,54 @@ paths:
responses:
'204':
description: User password updated
/user/{userId}/settings/linked-accounts/jellyfin:
post:
summary: Link the provided Jellyfin account to the current user
description: Logs in to Jellyfin with the provided credentials, then links the associated Jellyfin account with the user's account. Users can only link external accounts to their own account.
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
username:
type: string
example: 'Mr User'
password:
type: string
example: 'supersecret'
responses:
'204':
description: Linking account succeeded
'403':
description: Invalid credentials
'422':
description: Account already linked to a user
delete:
summary: Remove the linked Jellyfin account for a user
description: Removes the linked Jellyfin account for a specific user. Requires `MANAGE_USERS` permission if editing other users.
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
responses:
'204':
description: Unlinking account succeeded
'404':
description: User does not exist
/user/{userId}/settings/notifications:
get:
summary: Get notification settings for a user
Expand Down
8 changes: 6 additions & 2 deletions server/api/jellyfin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,11 @@ class JellyfinAPI extends ExternalAPI {
private userId?: string;
private jellyfinHost: string;

constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
constructor(
jellyfinHost: string,
authToken?: string | null,
deviceId?: string | null
) {
let authHeaderVal: string;
if (authToken) {
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
Expand All @@ -116,7 +120,7 @@ class JellyfinAPI extends ExternalAPI {
);

this.jellyfinHost = jellyfinHost;
this.authToken = authToken;
this.authToken = authToken ?? undefined;
}

public async login(
Expand Down
16 changes: 8 additions & 8 deletions server/entity/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ export class User {
@Column({ nullable: true })
public plexUsername?: string;

@Column({ nullable: true })
public jellyfinUsername?: string;
@Column({ type: 'varchar', nullable: true })
public jellyfinUsername?: string | null;

@Column({ nullable: true })
public username?: string;
Expand All @@ -80,14 +80,14 @@ export class User {
@Column({ nullable: true, select: true })
public plexId?: number;

@Column({ nullable: true })
public jellyfinUserId?: string;
@Column({ type: 'varchar', nullable: true })
public jellyfinUserId?: string | null;

@Column({ nullable: true })
public jellyfinDeviceId?: string;
@Column({ type: 'varchar', nullable: true })
public jellyfinDeviceId?: string | null;

@Column({ nullable: true })
public jellyfinAuthToken?: string;
@Column({ type: 'varchar', nullable: true })
public jellyfinAuthToken?: string | null;

@Column({ nullable: true })
public plexToken?: string;
Expand Down
140 changes: 140 additions & 0 deletions server/routes/user/usersettings.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import JellyfinAPI from '@server/api/jellyfin';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { UserSettings } from '@server/entity/UserSettings';
Expand All @@ -9,9 +13,24 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { ApiError } from '@server/types/error';
import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import net from 'net';
import { canMakePermissionsChange } from '.';

const isOwnProfile = (): Middleware => {
return (req, res, next) => {
if (req.user?.id !== Number(req.params.id)) {
return next({
status: 403,
message: "You do not have permission to view this user's settings.",
});
}
next();
};
};

const isOwnProfileOrAdmin = (): Middleware => {
const authMiddleware: Middleware = (req, res, next) => {
if (
Expand Down Expand Up @@ -248,6 +267,127 @@ userSettingsRoutes.post<
}
});

userSettingsRoutes.post<{ username: string; password: string }>(
'/linked-accounts/jellyfin',
isOwnProfile(),
async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);

if (!req.user) {
return next({ status: 401, message: 'Unauthorized' });
}
// Make sure jellyfin login is enabled
if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) {
return res.status(500).json({ error: 'Jellyfin login is disabled' });
}

// Do not allow linking of an already linked account
if (
await userRepository.exist({
where: { jellyfinUsername: req.body.username },
})
) {
return res.status(422).json({
error:
'The specified Jellyfin account is already linked to a Jellyseerr user',
});
}

const hostname = getHostname();
const deviceId = Buffer.from(
`BOT_overseerr_${req.user.username ?? ''}`
).toString('base64');

const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);

const ip = req.ip;
let clientIp;
if (ip) {
if (net.isIPv4(ip)) {
clientIp = ip;
} else if (net.isIPv6(ip)) {
clientIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
}
}

try {
const account = await jellyfinserver.login(
req.body.username,
req.body.password,
clientIp
);

// Do not allow linking of an already linked account
if (
await userRepository.exist({
where: { jellyfinUserId: account.User.Id },
})
) {
return res.status(422).json({
error:
'The specified Jellyfin account is already linked to a Jellyseerr user',
});
}

const user = req.user;

// valid jellyfin user found, link to current user
user.userType = UserType.JELLYFIN;
user.jellyfinUserId = account.User.Id;
user.jellyfinUsername = account.User.Name;
user.jellyfinAuthToken = account.AccessToken;
user.jellyfinDeviceId = deviceId;
await userRepository.save(user);

return res.status(204).send();
} catch (e) {
logger.error('Failed to link Jellyfin account to user.', {
label: 'API',
ip: req.ip,
error: e,
});
if (
e instanceof ApiError &&
(e.errorCode == ApiErrorCode.InvalidCredentials ||
e.errorCode == ApiErrorCode.NotAdmin)
)
return next({ status: 401, message: 'Unauthorized' });

return next({ status: 500 });
}
}
);

userSettingsRoutes.delete<{ id: string }>(
'/linked-accounts/jellyfin',
isOwnProfileOrAdmin(),
async (req, res, next) => {
const userRepository = getRepository(User);

try {
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
});

if (!user) {
return next({ status: 404, message: 'User not found.' });
}

user.userType = UserType.LOCAL;
user.jellyfinUserId = null;
user.jellyfinUsername = null;
user.jellyfinAuthToken = null;
user.jellyfinDeviceId = null;
await userRepository.save(user);

return res.status(204).send();
} catch (e) {
next({ status: 500, message: e.message });
}
}
);

userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
'/notifications',
isOwnProfileOrAdmin(),
Expand Down
111 changes: 111 additions & 0 deletions src/components/Common/Dropdown/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { withProperties } from '@app/utils/typeHelpers';
import { Menu, Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/24/solid';
import {
Fragment,
useRef,
type AnchorHTMLAttributes,
type ButtonHTMLAttributes,
} from 'react';

interface DropdownItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
buttonType?: 'primary' | 'ghost';
}

const DropdownItem = ({
children,
buttonType = 'primary',
...props
}: DropdownItemProps) => {
let styleClass = 'button-md text-white';

switch (buttonType) {
case 'ghost':
styleClass +=
' bg-transparent rounded hover:bg-gradient-to-br from-indigo-600 to-purple-600 text-white focus:border-gray-500 focus:text-white';
break;
default:
styleClass +=
' bg-indigo-600 rounded hover:bg-indigo-500 focus:border-indigo-700 focus:text-white';
}
return (
<Menu.Item>
<a
className={`flex cursor-pointer items-center px-4 py-2 text-sm leading-5 focus:outline-none ${styleClass}`}
{...props}
>
{children}
</a>
</Menu.Item>
);
};

interface DropdownProps extends ButtonHTMLAttributes<HTMLButtonElement> {
text: React.ReactNode;
dropdownIcon?: React.ReactNode;
buttonType?: 'primary' | 'ghost';
}

const Dropdown = ({
text,
children,
dropdownIcon,
className,
buttonType = 'primary',
...props
}: DropdownProps) => {
const buttonRef = useRef<HTMLButtonElement>(null);

const styleClasses = {
mainButtonClasses: 'button-md text-white border',
dropdownSideButtonClasses: 'button-md border',
dropdownClasses: 'button-md',
};

switch (buttonType) {
case 'ghost':
styleClasses.mainButtonClasses +=
' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses;
styleClasses.dropdownClasses +=
' bg-gray-800 border border-gray-700 bg-opacity-80 p-1 backdrop-blur';
break;
default:
styleClasses.mainButtonClasses +=
' bg-indigo-600 border-indigo-500 bg-opacity-80 hover:bg-opacity-100 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue';
styleClasses.dropdownSideButtonClasses +=
' bg-indigo-600 bg-opacity-80 border-indigo-500 hover:bg-opacity-100 active:bg-opacity-100 focus:ring-blue';
styleClasses.dropdownClasses += ' bg-indigo-600 p-1';
}

return (
<Menu as="div" className="relative z-10">
<Menu.Button
type="button"
className={` inline-flex h-full items-center space-x-2 rounded-md px-4 py-2 text-sm font-medium leading-5 transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none ${styleClasses.mainButtonClasses} ${className}`}
ref={buttonRef}
disabled={!children}
{...props}
>
<span>{text}</span>
{children && (dropdownIcon ? dropdownIcon : <ChevronDownIcon />)}
</Menu.Button>
{children && (
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg">
{children}
</Menu.Items>
</Transition>
)}
</Menu>
);
};
export default withProperties(Dropdown, { Item: DropdownItem });
Loading

0 comments on commit be5a2ec

Please sign in to comment.