Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
53 changes: 25 additions & 28 deletions server/routes/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,15 @@ describe('POST /auth/jellyfin/quickconnect/initiate', () => {
assert.strictEqual(initiateQCMock.mock.callCount(), 1);
});

it('returns 403 when the media server is Emby', async () => {
getSettings().main.mediaServerType = MediaServerType.EMBY;

const res = await request(app).post('/auth/jellyfin/quickconnect/initiate');

assert.strictEqual(res.status, 403);
assert.strictEqual(initiateQCMock.mock.callCount(), 0);
});

it('returns 500 when Jellyfin API fails', async () => {
initiateQCMock.mock.mockImplementation(async () => {
throw new Error('Connection refused');
Expand Down Expand Up @@ -239,6 +248,17 @@ describe('GET /auth/jellyfin/quickconnect/check', () => {
assert.strictEqual(checkQCMock.mock.callCount(), 0);
});

it('returns 403 when the media server is Emby', async () => {
getSettings().main.mediaServerType = MediaServerType.EMBY;

const res = await request(app)
.get('/auth/jellyfin/quickconnect/check')
.query({ secret: 'abc123def456abc123def456' });

assert.strictEqual(res.status, 403);
assert.strictEqual(checkQCMock.mock.callCount(), 0);
});

it('returns error when Jellyfin API fails', async () => {
checkQCMock.mock.mockImplementation(async () => {
throw new ApiError(500, ApiErrorCode.Unknown);
Expand Down Expand Up @@ -396,38 +416,15 @@ describe('POST /auth/jellyfin/quickconnect/authenticate', () => {
assert.strictEqual(meRes.status, 200);
});

it('sets userType to EMBY when media server is Emby', async () => {
const settings = getSettings();
settings.main.mediaServerType = MediaServerType.EMBY;
settings.main.newPlexLogin = true;

authenticateQCMock.mock.mockImplementation(async () => ({
User: {
Id: 'emby-new-user',
Name: 'embyuser',
ServerId: 'server-1',
Policy: { IsAdministrator: false },
},
AccessToken: 'emby-token',
}));
it('returns 403 when the media server is Emby', async () => {
getSettings().main.mediaServerType = MediaServerType.EMBY;

const agent = request.agent(app);
const res = await agent
const res = await request(app)
.post('/auth/jellyfin/quickconnect/authenticate')
.send({ secret: 'abc123def456abc123def456' });

assert.strictEqual(res.status, 200);

const meRes = await agent.get('/auth/me');
assert.strictEqual(meRes.status, 200);
assert.strictEqual(meRes.body.jellyfinUsername, 'embyuser');

const userRepo = getRepository(User);
const user = await userRepo.findOne({
where: { jellyfinUserId: 'emby-new-user' },
});
assert.ok(user);
assert.strictEqual(user.userType, UserType.EMBY);
assert.strictEqual(res.status, 403);
assert.strictEqual(authenticateQCMock.mock.callCount(), 0);
});

it('applies default permissions to newly created users', async () => {
Expand Down
30 changes: 26 additions & 4 deletions server/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,15 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
});

authRoutes.post('/jellyfin/quickconnect/initiate', async (req, res, next) => {
const settings = getSettings();

if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) {
return next({
status: 403,
message: 'Quick Connect is only supported by Jellyfin.',
});
}
Comment thread
fallenbagel marked this conversation as resolved.
Resolved

try {
const hostname = getHostname();
const jellyfinServer = new JellyfinAPI(
Expand Down Expand Up @@ -630,6 +639,15 @@ authRoutes.post('/jellyfin/quickconnect/initiate', async (req, res, next) => {
});

authRoutes.get('/jellyfin/quickconnect/check', async (req, res, next) => {
const settings = getSettings();

if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) {
return next({
status: 403,
message: 'Quick Connect is only supported by Jellyfin.',
});
}

const result = quickConnectSecret.safeParse(req.query);
if (!result.success) {
return next({
Expand Down Expand Up @@ -684,6 +702,13 @@ authRoutes.post(
});
}

if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) {
return next({
status: 403,
message: 'Quick Connect is only supported by Jellyfin.',
});
}

try {
const hostname = getHostname();
const jellyfinServer = new JellyfinAPI(
Expand Down Expand Up @@ -744,10 +769,7 @@ authRoutes.post(
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
permissions: settings.main.defaultPermissions,
userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
userType: UserType.JELLYFIN,
});
user.avatar = getUserAvatarUrl(user);
await userRepository.save(user);
Expand Down
134 changes: 134 additions & 0 deletions server/routes/user/usersettings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import { before, beforeEach, describe, it, mock } from 'node:test';

import JellyfinAPI from '@server/api/jellyfin';
import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { getSettings } from '@server/lib/settings';
import { checkUser, isAuthenticated } from '@server/middleware/auth';
import authRoutes from '@server/routes/auth';
import { setupTestDb } from '@server/test/db';
import type { Express } from 'express';
import express from 'express';
import session from 'express-session';
import request from 'supertest';
import userRoutes from '.';

const defaultAuthenticateResponse = {
User: {
Id: 'jf-link-user-001',
Name: 'linkeduser',
ServerId: 'server-1',
Policy: { IsAdministrator: false },
},
AccessToken: 'fake-qc-access-token',
};

const authenticateQCMock = mock.method(
JellyfinAPI.prototype,
'authenticateQuickConnect',
async () => ({ ...defaultAuthenticateResponse })
);

let app: Express;

function createApp() {
const app = express();
app.use(express.json());
app.use(
session({
secret: 'test-secret',
resave: false,
saveUninitialized: false,
})
Comment thread
fallenbagel marked this conversation as resolved.
Resolved
);
app.use(checkUser);
app.use('/auth', authRoutes);
app.use('/user', isAuthenticated(), userRoutes);
app.use(
(
err: { status?: number; message?: string },
_req: express.Request,
res: express.Response,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_next: express.NextFunction
) => {
res
.status(err.status ?? 500)
.json({ status: err.status ?? 500, message: err.message });
}
);
return app;
}

before(async () => {
app = createApp();
});

setupTestDb();

function configureJellyfin() {
const settings = getSettings();
settings.main.mediaServerType = MediaServerType.JELLYFIN;
settings.jellyfin.ip = 'localhost';
settings.jellyfin.port = 8096;
settings.jellyfin.useSsl = false;
settings.jellyfin.urlBase = '';
}

async function loginAs(email: string, password: string) {
const settings = getSettings();
settings.main.localLogin = true;

const agent = request.agent(app);
const res = await agent.post('/auth/local').send({ email, password });

assert.strictEqual(res.status, 200);
return { agent, userId: res.body.id as number };
}

describe('POST /user/:id/settings/linked-accounts/jellyfin/quickconnect', () => {
beforeEach(() => {
authenticateQCMock.mock.resetCalls();
authenticateQCMock.mock.mockImplementation(async () => ({
...defaultAuthenticateResponse,
}));
configureJellyfin();
});

it('links the account when the media server is Jellyfin', async () => {
const { agent, userId } = await loginAs('friend@seerr.dev', 'test1234');

const res = await agent
.post(`/user/${userId}/settings/linked-accounts/jellyfin/quickconnect`)
.send({ secret: 'abc123def456abc123def456' });

assert.strictEqual(res.status, 204);
assert.strictEqual(authenticateQCMock.mock.callCount(), 1);

const user = await getRepository(User).findOneOrFail({
where: { id: userId },
});
assert.strictEqual(user.jellyfinUserId, 'jf-link-user-001');
assert.strictEqual(user.userType, UserType.JELLYFIN);
});

it('returns 403 when the media server is Emby', async () => {
const { agent, userId } = await loginAs('friend@seerr.dev', 'test1234');
getSettings().main.mediaServerType = MediaServerType.EMBY;

const res = await agent
.post(`/user/${userId}/settings/linked-accounts/jellyfin/quickconnect`)
.send({ secret: 'abc123def456abc123def456' });

assert.strictEqual(res.status, 403);
assert.strictEqual(authenticateQCMock.mock.callCount(), 0);

const user = await getRepository(User).findOneOrFail({
where: { id: userId },
});
assert.strictEqual(user.jellyfinUserId, null);
});
});
11 changes: 7 additions & 4 deletions server/routes/user/usersettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,12 @@ userSettingsRoutes.post<{ secret: string }>(
.json({ message: 'Jellyfin/Emby login is disabled' });
}

if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) {
return res
.status(403)
.json({ message: 'Quick Connect is only supported by Jellyfin.' });
}

const hostname = getHostname();
const jellyfinServer = new JellyfinAPI(hostname);

Expand All @@ -563,10 +569,7 @@ userSettingsRoutes.post<{ secret: string }>(
user.id === 1 ? 'BOT_seerr' : `BOT_seerr_${user.username ?? ''}`
).toString('base64');

user.userType =
settings.main.mediaServerType === MediaServerType.EMBY
? UserType.EMBY
: UserType.JELLYFIN;
user.userType = UserType.JELLYFIN;
user.jellyfinUserId = account.User.Id;
user.jellyfinUsername = account.User.Name;
user.jellyfinAuthToken = account.AccessToken;
Expand Down
24 changes: 13 additions & 11 deletions src/components/Login/JellyfinLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,17 +217,19 @@ const JellyfinLogin = ({ revalidate, serverType }: JellyfinLoginProps) => {
}}
</Formik>

<div className="mt-4">
<Button
buttonType="ghost"
type="button"
onClick={() => setShowQuickConnect(true)}
className="w-full"
>
<QrCodeIcon />
<span>{intl.formatMessage(messages.quickconnect)}</span>
</Button>
</div>
{serverType === MediaServerType.JELLYFIN && (
<div className="mt-4">
<Button
buttonType="ghost"
type="button"
onClick={() => setShowQuickConnect(true)}
className="w-full"
>
<QrCodeIcon />
<span>{intl.formatMessage(messages.quickconnect)}</span>
</Button>
</div>
)}

{showQuickConnect && (
<JellyfinQuickConnectModal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,20 +172,23 @@ const LinkJellyfinModal = ({
<div className="error">{errors.password}</div>
)}
</div>
<div className="mt-4">
<Button
buttonType="ghost"
type="button"
onClick={() => {
setError(null);
onSwitchToQuickConnect();
}}
className="w-full gap-2"
>
<QrCodeIcon />
<span>{intl.formatMessage(messages.quickConnect)}</span>
</Button>
</div>
{settings.currentSettings.mediaServerType ===
MediaServerType.JELLYFIN && (
<div className="mt-4">
<Button
buttonType="ghost"
type="button"
onClick={() => {
setError(null);
onSwitchToQuickConnect();
}}
className="w-full gap-2"
>
<QrCodeIcon />
<span>{intl.formatMessage(messages.quickConnect)}</span>
</Button>
</div>
)}
</Form>
</Modal>
);
Expand Down
Loading