Add server-side credential requirement

This commit is contained in:
jeffvli
2022-10-27 20:33:42 -07:00
parent cbbf3087ff
commit ca664d9430
8 changed files with 222 additions and 104 deletions
+24 -4
View File
@@ -1,3 +1,4 @@
import { ServerType } from '@prisma/client';
import { Response } from 'express'; import { Response } from 'express';
import { ApiSuccess, getSuccessResponse } from '@/utils'; import { ApiSuccess, getSuccessResponse } from '@/utils';
import { toApiModel } from '@helpers/api-model'; import { toApiModel } from '@helpers/api-model';
@@ -18,7 +19,10 @@ const getServerList = async (
req: TypedRequest<typeof validation.servers.list>, req: TypedRequest<typeof validation.servers.list>,
res: Response res: Response
) => { ) => {
const data = await service.servers.findMany(req.authUser); const { enabled } = req.query;
const data = await service.servers.findMany(req.authUser, {
enabled: Boolean(enabled),
});
const success = ApiSuccess.ok({ data: toApiModel.servers(data) }); const success = ApiSuccess.ok({ data: toApiModel.servers(data) });
return res.status(success.statusCode).json(getSuccessResponse(success)); return res.status(success.statusCode).json(getSuccessResponse(success));
}; };
@@ -55,7 +59,8 @@ const updateServer = async (
res: Response res: Response
) => { ) => {
const { serverId } = req.params; const { serverId } = req.params;
const { username, password, name, legacy, type, url } = req.body; const { username, password, name, legacy, type, url, noCredential } =
req.body;
if (type && username && password && url) { if (type && username && password && url) {
const remoteServerLoginRes = await service.servers.remoteServerLogin({ const remoteServerLoginRes = await service.servers.remoteServerLogin({
@@ -68,14 +73,27 @@ const updateServer = async (
const data = await service.servers.update( const data = await service.servers.update(
{ id: serverId }, { id: serverId },
{ name, ...remoteServerLoginRes } {
name,
remoteUserId: remoteServerLoginRes.remoteUserId,
token:
type === ServerType.NAVIDROME
? `${remoteServerLoginRes.token}||${remoteServerLoginRes?.altToken}`
: remoteServerLoginRes.token,
type,
url: remoteServerLoginRes.url,
username: remoteServerLoginRes.username,
}
); );
const success = ApiSuccess.ok({ data: toApiModel.servers([data])[0] }); const success = ApiSuccess.ok({ data: toApiModel.servers([data])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success)); return res.status(success.statusCode).json(getSuccessResponse(success));
} }
const data = await service.servers.update({ id: serverId }, { name, url }); const data = await service.servers.update(
{ id: serverId },
{ name, noCredential, url }
);
const success = ApiSuccess.ok({ data: toApiModel.servers([data])[0] }); const success = ApiSuccess.ok({ data: toApiModel.servers([data])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success)); return res.status(success.statusCode).json(getSuccessResponse(success));
}; };
@@ -98,6 +116,8 @@ const scanServer = async (
const { serverId } = req.params; const { serverId } = req.params;
const { serverFolderId } = req.body; const { serverFolderId } = req.body;
// TODO: Check that server is accessible first with the saved token, otherwise throw error
const data = await service.servers.fullScan({ const data = await service.servers.fullScan({
id: serverId, id: serverId,
serverFolderId, serverFolderId,
+1 -1
View File
@@ -33,7 +33,7 @@ const include = (user: AuthUser, options: { songs?: boolean }) => {
}, },
}, },
server: true, server: true,
serverFolders: true, serverFolders: { where: { enabled: true } },
songs: options?.songs && songHelpers.findMany(user), songs: options?.songs && songHelpers.findMany(user),
}; };
+176 -80
View File
@@ -22,28 +22,30 @@ import {
UserServerUrl, UserServerUrl,
} from '@prisma/client'; } from '@prisma/client';
const getSubsonicStreamUrl = ( const getSubsonicStreamUrl = (options: {
remoteId: string, deviceId: string;
url: string, remoteId: string;
token: string, token?: string;
deviceId: string url: string;
) => { }) => {
const { deviceId, remoteId, token, url } = options;
return ( return (
`${url}/rest/stream.view` + `${url}/rest/stream.view` +
`?id=${remoteId}` + `?id=${remoteId}` +
`&${token}` +
`&v=1.13.0` + `&v=1.13.0` +
`&c=Feishin_${deviceId}` `&c=Feishin_${deviceId}` +
`&${token ? `${token}` : ''}`
); );
}; };
const getJellyfinStreamUrl = ( const getJellyfinStreamUrl = (options: {
remoteId: string, deviceId: string;
url: string, remoteId: string;
token: string, token?: string;
userId: string, url: string;
deviceId: string userId: string;
) => { }) => {
const { deviceId, remoteId, token, url, userId } = options;
return ( return (
`${url}/audio` + `${url}/audio` +
`/${remoteId}/universal` + `/${remoteId}/universal` +
@@ -54,14 +56,15 @@ const getJellyfinStreamUrl = (
`&transcodingProtocol=hls` + `&transcodingProtocol=hls` +
`&deviceId=Feishin_${deviceId}` + `&deviceId=Feishin_${deviceId}` +
`&playSessionId=${deviceId}` + `&playSessionId=${deviceId}` +
`&api_key=${token}` `&api_key=${token ? `${token}` : ''}`
); );
}; };
const streamUrl = ( const buildStreamUrl = (
type: ServerType, type: ServerType,
args: { options: {
deviceId: string; deviceId: string;
noCredential: boolean;
remoteId: string; remoteId: string;
token: string; token: string;
url: string; url: string;
@@ -69,33 +72,69 @@ const streamUrl = (
} }
) => { ) => {
if (type === ServerType.JELLYFIN) { if (type === ServerType.JELLYFIN) {
return getJellyfinStreamUrl( return getJellyfinStreamUrl({
args.remoteId, deviceId: options.deviceId,
args.url, remoteId: options.remoteId,
args.token, token: options.noCredential ? undefined : options.token,
args.userId || '', url: options.url,
args.deviceId userId: options.userId || '',
); });
} }
return getSubsonicStreamUrl(
args.remoteId, if (type === ServerType.SUBSONIC) {
args.url, return getSubsonicStreamUrl({
args.token, deviceId: options.deviceId,
args.deviceId remoteId: options.remoteId,
); token: options.noCredential ? undefined : options.token,
url: options.url,
});
}
if (type === ServerType.NAVIDROME) {
const [_ndToken, ssToken] = options.token.split('||');
if (options.noCredential) {
return getSubsonicStreamUrl({
deviceId: options.deviceId,
remoteId: options.remoteId,
url: options.url,
});
}
return getSubsonicStreamUrl({
deviceId: options.deviceId,
remoteId: options.remoteId,
token: ssToken,
url: options.url,
});
}
return null;
}; };
const imageUrl = ( const imageUrl = (
type: ServerType, type: ServerType,
imageType: ImageType,
baseUrl: string, baseUrl: string,
imageId: string, imageId: string,
token?: string token?: string
) => { ) => {
if (type === ServerType.JELLYFIN) { if (type === ServerType.JELLYFIN) {
if (imageType === ImageType.PRIMARY) {
return (
`${baseUrl}/Items` +
`/${imageId}` +
`/Images/Primary` +
'?fillHeight=250' +
`&fillWidth=250` +
'&quality=90'
);
}
return ( return (
`${baseUrl}/Items` + `${baseUrl}/Items` +
`/${imageId}` + `/${imageId}` +
`/Images/Primary` + `/Images/Backdrop` +
'?fillHeight=250' + '?fillHeight=250' +
`&fillWidth=250` + `&fillWidth=250` +
'&quality=90' '&quality=90'
@@ -109,7 +148,7 @@ const imageUrl = (
`&size=250` + `&size=250` +
`&v=1.13.0` + `&v=1.13.0` +
`&c=Feishin` + `&c=Feishin` +
`&${token}` `&${token ? `${token}` : ''}`
); );
} }
@@ -219,23 +258,42 @@ const rating = (
return null; return null;
}; };
const image = ( const buildImageUrl = (options: {
images: Image[], imageType: ImageType;
type: ServerType, images: Image[];
imageType: ImageType, noCredential?: boolean;
url: string, remoteId: string;
remoteId: string, token?: string;
token?: string type: ServerType;
) => { url: string;
const imageRemoteUrl = images.find((i) => i.type === imageType)?.remoteUrl; }) => {
const { imageType, images, remoteId, token, type, url, noCredential } =
options;
const image = images.find((i) => i.type === imageType);
if (!image) return null;
if (!imageRemoteUrl) return null;
if (type === ServerType.JELLYFIN) { if (type === ServerType.JELLYFIN) {
return imageUrl(type, url, remoteId); return imageUrl(type, imageType, url, remoteId);
} }
if (type === ServerType.SUBSONIC || type === ServerType.NAVIDROME) { if (type === ServerType.SUBSONIC) {
return imageUrl(type, url, imageRemoteUrl, token); if (noCredential) {
return imageUrl(type, imageType, url, image.remoteUrl);
}
return imageUrl(type, imageType, url, image.remoteUrl, token);
}
if (type === ServerType.NAVIDROME) {
const [_ndToken, ssToken] = token!.split('||');
if (noCredential) {
return imageUrl(type, imageType, url, image.remoteUrl);
}
return imageUrl(type, imageType, url, image.remoteUrl, ssToken);
} }
return null; return null;
@@ -244,7 +302,7 @@ const image = (
type DbSong = Song & DbSongInclude; type DbSong = Song & DbSongInclude;
type DbSongInclude = { type DbSongInclude = {
album: Album; album: Album & { images: Image[] };
artists: Artist[]; artists: Artist[];
externals: External[]; externals: External[];
genres: Genre[]; genres: Genre[];
@@ -263,21 +321,45 @@ const songs = (
type: ServerType; type: ServerType;
url: string; url: string;
userId: string; userId: string;
} },
noCredential: boolean
) => { ) => {
return ( return (
items?.map((item) => { items?.map((item) => {
const customUrl = item.server.serverUrls[0].url; const customUrl = item.server.serverUrls[0]?.url;
const baseUrl = customUrl ? customUrl : options.url; const baseUrl = customUrl ? customUrl : options.url;
const stream = streamUrl(options.type, { const streamUrl = buildStreamUrl(options.type, {
deviceId: options.deviceId, deviceId: options.deviceId,
noCredential,
remoteId: item.remoteId, remoteId: item.remoteId,
token: options.token, token: options.token,
url: baseUrl, url: baseUrl,
userId: options.userId, userId: options.userId,
}); });
let imageUrl = buildImageUrl({
imageType: ImageType.PRIMARY,
images: item.images,
noCredential,
remoteId: item.remoteId,
token: options.token,
type: options.type,
url: baseUrl,
});
if (!imageUrl) {
imageUrl = buildImageUrl({
imageType: ImageType.PRIMARY,
images: item.album.images,
noCredential,
remoteId: item.remoteId,
token: options.token,
type: options.type,
url: baseUrl,
});
}
return { return {
/* eslint-disable sort-keys-fix/sort-keys-fix */ /* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id, id: item.id,
@@ -292,20 +374,14 @@ const songs = (
discNumber: item.discNumber, discNumber: item.discNumber,
duration: item.duration, duration: item.duration,
genres: relatedGenres(item.genres), genres: relatedGenres(item.genres),
imageUrl: image( imageUrl,
item.images,
options.type,
ImageType.PRIMARY,
baseUrl,
item.remoteId
),
releaseDate: item.releaseDate, releaseDate: item.releaseDate,
releaseYear: item.releaseYear, releaseYear: item.releaseYear,
remoteCreatedAt: item.remoteCreatedAt, remoteCreatedAt: item.remoteCreatedAt,
remoteId: item.remoteId, remoteId: item.remoteId,
// serverFolderId: item.serverFolderId, // serverFolderId: item.serverFolderId,
serverId: item.serverId, serverId: item.serverId,
streamUrl: stream, streamUrl,
trackNumber: item.trackNumber, trackNumber: item.trackNumber,
updatedAt: item.updatedAt, updatedAt: item.updatedAt,
/* eslint-enable sort-keys-fix/sort-keys-fix */ /* eslint-enable sort-keys-fix/sort-keys-fix */
@@ -338,9 +414,33 @@ const albums = (options: {
const { items, serverUrl, user } = options; const { items, serverUrl, user } = options;
return ( return (
items?.map((item) => { items?.map((item) => {
const { type, token, remoteUserId } = item.server; const { type, token, remoteUserId, noCredential } = item.server;
const url = serverUrl || item.server.url; const url = serverUrl || item.server.url;
// Jellyfin does not require credentials for image url
const shouldBuildImage = type === ServerType.JELLYFIN || !noCredential;
const tokenForImage = shouldBuildImage ? token : undefined;
const imageUrl = buildImageUrl({
imageType: ImageType.PRIMARY,
images: item.images,
noCredential,
remoteId: item.remoteId,
token,
type,
url,
});
const backdropImageUrl = buildImageUrl({
imageType: ImageType.BACKDROP,
images: item.images,
noCredential,
remoteId: item.remoteId,
token,
type,
url,
});
return { return {
/* eslint-disable sort-keys-fix/sort-keys-fix */ /* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id, id: item.id,
@@ -352,20 +452,8 @@ const albums = (options: {
rating: rating(item.ratings), rating: rating(item.ratings),
songCount: item._count.songs, songCount: item._count.songs,
type, type,
imageUrl: image( imageUrl,
item.images, backdropImageUrl: backdropImageUrl,
type,
ImageType.PRIMARY,
url,
item.remoteId
),
backdropImageUrl: image(
item.images,
type,
ImageType.BACKDROP,
url,
item.remoteId
),
deleted: item.deleted, deleted: item.deleted,
remoteId: item.remoteId, remoteId: item.remoteId,
remoteCreatedAt: item.remoteCreatedAt, remoteCreatedAt: item.remoteCreatedAt,
@@ -379,13 +467,20 @@ const albums = (options: {
serverFolders: relatedServerFolders(item.serverFolders), serverFolders: relatedServerFolders(item.serverFolders),
songs: songs:
item.songs && item.songs &&
songs(item.songs, { songs(
deviceId: user.deviceId, item?.songs?.map((s: any) => ({
token, ...s,
type, album: { images: item?.images },
url, })),
userId: remoteUserId, {
}), deviceId: user.deviceId,
token,
type,
url,
userId: remoteUserId,
},
noCredential
),
/* eslint-enable sort-keys-fix/sort-keys-fix */ /* eslint-enable sort-keys-fix/sort-keys-fix */
}; };
}) || [] }) || []
@@ -440,6 +535,7 @@ const servers = (
name: item.name, name: item.name,
url: item.url, url: item.url,
type: item.type, type: item.type,
noCredential: item.noCredential,
username: item.username, username: item.username,
createdAt: item.createdAt, createdAt: item.createdAt,
updatedAt: item.updatedAt, updatedAt: item.updatedAt,
+2 -1
View File
@@ -49,7 +49,7 @@ const getAvailableServerFolderIds = async (
if (user.isAdmin) { if (user.isAdmin) {
const serverFoldersWithAccess = await prisma.serverFolder.findMany({ const serverFoldersWithAccess = await prisma.serverFolder.findMany({
where: { serverId }, where: { enabled: true, serverId },
}); });
const serverFoldersWithAccessIds = serverFoldersWithAccess.map( const serverFoldersWithAccessIds = serverFoldersWithAccess.map(
@@ -65,6 +65,7 @@ const getAvailableServerFolderIds = async (
{ {
AND: [ AND: [
{ {
enabled: true,
serverFolderPermissions: { serverFolderPermissions: {
some: { userId: { equals: user.id } }, some: { userId: { equals: user.id } },
}, },
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Server" ADD COLUMN "noCredential" BOOLEAN NOT NULL DEFAULT true;
+1
View File
@@ -99,6 +99,7 @@ model Server {
remoteUserId String remoteUserId String
username String username String
token String token String
noCredential Boolean @default(true)
type ServerType type ServerType
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+13 -17
View File
@@ -104,12 +104,13 @@ const findById = async (user: AuthUser, options: { id: string }) => {
return server; return server;
}; };
const findMany = async (user: AuthUser) => { const findMany = async (user: AuthUser, options?: { enabled?: boolean }) => {
if (user.isAdmin) { if (user.isAdmin) {
return prisma.server.findMany({ return prisma.server.findMany({
include: { include: {
serverFolders: { serverFolders: {
orderBy: { name: SortOrder.ASC }, orderBy: { name: SortOrder.ASC },
where: { enabled: options?.enabled ? true : undefined },
}, },
serverPermissions: { serverPermissions: {
orderBy: { createdAt: SortOrder.ASC }, orderBy: { createdAt: SortOrder.ASC },
@@ -131,7 +132,12 @@ const findMany = async (user: AuthUser) => {
include: { include: {
serverFolders: { serverFolders: {
orderBy: { name: SortOrder.ASC }, orderBy: { name: SortOrder.ASC },
where: { id: { in: user.flatServerFolderPermissions } }, where: {
AND: [
{ id: { in: user.flatServerFolderPermissions } },
{ enabled: options?.enabled ? true : undefined },
],
},
}, },
serverPermissions: { serverPermissions: {
orderBy: { createdAt: SortOrder.ASC }, orderBy: { createdAt: SortOrder.ASC },
@@ -178,6 +184,7 @@ const create = async (options: {
if (!serverFoldersRes) { if (!serverFoldersRes) {
throw ApiError.badRequest('Server is inaccessible.'); throw ApiError.badRequest('Server is inaccessible.');
} }
const serverFoldersCreate = serverFoldersRes.map((folder) => { const serverFoldersCreate = serverFoldersRes.map((folder) => {
return { return {
name: folder.name, name: folder.name,
@@ -193,19 +200,6 @@ const create = async (options: {
}, },
}); });
// for (const serverFolder of serverFolders) {
// await prisma.serverFolder.upsert({
// create: serverFolder,
// update: { name: serverFolder.name },
// where: {
// uniqueServerFolderId: {
// remoteId: serverFolder.remoteId,
// serverId: serverFolder.serverId,
// },
// },
// });
// }
return server; return server;
} }
@@ -219,6 +213,8 @@ const create = async (options: {
throw ApiError.badRequest('Server is inaccessible.'); throw ApiError.badRequest('Server is inaccessible.');
} }
const navidromeToken = options.token + '||' + options?.altToken;
const serverFoldersCreate = serverFoldersRes.map((folder) => { const serverFoldersCreate = serverFoldersRes.map((folder) => {
return { return {
name: folder.name, name: folder.name,
@@ -232,7 +228,7 @@ const create = async (options: {
remoteUserId: options.remoteUserId, remoteUserId: options.remoteUserId,
serverFolders: { create: serverFoldersCreate }, serverFolders: { create: serverFoldersCreate },
serverUrls: { create: { url: options.url } }, serverUrls: { create: { url: options.url } },
token: options.token, token: navidromeToken,
type: options.type, type: options.type,
url: options.url, url: options.url,
username: options.username, username: options.username,
@@ -295,8 +291,8 @@ const create = async (options: {
const update = async ( const update = async (
options: { id: string }, options: { id: string },
data: { data: {
altToken?: string; // Used for Navidrome only
name?: string; name?: string;
noCredential?: boolean;
remoteUserId?: string; remoteUserId?: string;
token?: string; token?: string;
type?: ServerType; type?: ServerType;
+3 -1
View File
@@ -11,7 +11,7 @@ const detail = {
const list = { const list = {
body: z.object({}), body: z.object({}),
params: z.object({}), params: z.object({}),
query: z.object({}), query: z.object({ enabled: z.string().optional() }),
}; };
const deleteServer = { const deleteServer = {
@@ -24,6 +24,7 @@ const update = {
body: z.object({ body: z.object({
legacy: z.boolean().optional(), legacy: z.boolean().optional(),
name: z.string().optional(), name: z.string().optional(),
noCredential: z.boolean().optional(),
password: z.string().optional(), password: z.string().optional(),
type: z.nativeEnum(ServerType), type: z.nativeEnum(ServerType),
url: z.string().optional(), url: z.string().optional(),
@@ -37,6 +38,7 @@ const create = {
body: z.object({ body: z.object({
legacy: z.boolean().optional(), legacy: z.boolean().optional(),
name: z.string(), name: z.string(),
noCredential: z.boolean().optional(),
password: z.string(), password: z.string(),
type: z.enum([ type: z.enum([
ServerType.JELLYFIN, ServerType.JELLYFIN,