From ca664d9430c0b016c078596c51fd65df873a6719 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Thu, 27 Oct 2022 20:33:42 -0700 Subject: [PATCH] Add server-side credential requirement --- server/controllers/servers.controller.ts | 28 +- server/helpers/albums.helpers.ts | 2 +- server/helpers/api-model.ts | 256 ++++++++++++------ server/helpers/shared.helpers.ts | 3 +- .../migration.sql | 2 + server/prisma/schema.prisma | 1 + server/services/servers.service.ts | 30 +- server/validations/servers.validation.ts | 4 +- 8 files changed, 222 insertions(+), 104 deletions(-) create mode 100644 server/prisma/migrations/20221028032722_require_credential/migration.sql diff --git a/server/controllers/servers.controller.ts b/server/controllers/servers.controller.ts index 0a325f3d8..ebd86f102 100644 --- a/server/controllers/servers.controller.ts +++ b/server/controllers/servers.controller.ts @@ -1,3 +1,4 @@ +import { ServerType } from '@prisma/client'; import { Response } from 'express'; import { ApiSuccess, getSuccessResponse } from '@/utils'; import { toApiModel } from '@helpers/api-model'; @@ -18,7 +19,10 @@ const getServerList = async ( req: TypedRequest, 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) }); return res.status(success.statusCode).json(getSuccessResponse(success)); }; @@ -55,7 +59,8 @@ const updateServer = async ( res: Response ) => { 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) { const remoteServerLoginRes = await service.servers.remoteServerLogin({ @@ -68,14 +73,27 @@ const updateServer = async ( const data = await service.servers.update( { 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] }); 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] }); return res.status(success.statusCode).json(getSuccessResponse(success)); }; @@ -98,6 +116,8 @@ const scanServer = async ( const { serverId } = req.params; const { serverFolderId } = req.body; + // TODO: Check that server is accessible first with the saved token, otherwise throw error + const data = await service.servers.fullScan({ id: serverId, serverFolderId, diff --git a/server/helpers/albums.helpers.ts b/server/helpers/albums.helpers.ts index b188d35a4..11f08cad8 100644 --- a/server/helpers/albums.helpers.ts +++ b/server/helpers/albums.helpers.ts @@ -33,7 +33,7 @@ const include = (user: AuthUser, options: { songs?: boolean }) => { }, }, server: true, - serverFolders: true, + serverFolders: { where: { enabled: true } }, songs: options?.songs && songHelpers.findMany(user), }; diff --git a/server/helpers/api-model.ts b/server/helpers/api-model.ts index 155d2773d..088c9498a 100644 --- a/server/helpers/api-model.ts +++ b/server/helpers/api-model.ts @@ -22,28 +22,30 @@ import { UserServerUrl, } from '@prisma/client'; -const getSubsonicStreamUrl = ( - remoteId: string, - url: string, - token: string, - deviceId: string -) => { +const getSubsonicStreamUrl = (options: { + deviceId: string; + remoteId: string; + token?: string; + url: string; +}) => { + const { deviceId, remoteId, token, url } = options; return ( `${url}/rest/stream.view` + `?id=${remoteId}` + - `&${token}` + `&v=1.13.0` + - `&c=Feishin_${deviceId}` + `&c=Feishin_${deviceId}` + + `&${token ? `${token}` : ''}` ); }; -const getJellyfinStreamUrl = ( - remoteId: string, - url: string, - token: string, - userId: string, - deviceId: string -) => { +const getJellyfinStreamUrl = (options: { + deviceId: string; + remoteId: string; + token?: string; + url: string; + userId: string; +}) => { + const { deviceId, remoteId, token, url, userId } = options; return ( `${url}/audio` + `/${remoteId}/universal` + @@ -54,14 +56,15 @@ const getJellyfinStreamUrl = ( `&transcodingProtocol=hls` + `&deviceId=Feishin_${deviceId}` + `&playSessionId=${deviceId}` + - `&api_key=${token}` + `&api_key=${token ? `${token}` : ''}` ); }; -const streamUrl = ( +const buildStreamUrl = ( type: ServerType, - args: { + options: { deviceId: string; + noCredential: boolean; remoteId: string; token: string; url: string; @@ -69,33 +72,69 @@ const streamUrl = ( } ) => { if (type === ServerType.JELLYFIN) { - return getJellyfinStreamUrl( - args.remoteId, - args.url, - args.token, - args.userId || '', - args.deviceId - ); + return getJellyfinStreamUrl({ + deviceId: options.deviceId, + remoteId: options.remoteId, + token: options.noCredential ? undefined : options.token, + url: options.url, + userId: options.userId || '', + }); } - return getSubsonicStreamUrl( - args.remoteId, - args.url, - args.token, - args.deviceId - ); + + if (type === ServerType.SUBSONIC) { + return getSubsonicStreamUrl({ + deviceId: options.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 = ( type: ServerType, + imageType: ImageType, baseUrl: string, imageId: string, token?: string ) => { if (type === ServerType.JELLYFIN) { + if (imageType === ImageType.PRIMARY) { + return ( + `${baseUrl}/Items` + + `/${imageId}` + + `/Images/Primary` + + '?fillHeight=250' + + `&fillWidth=250` + + '&quality=90' + ); + } + return ( `${baseUrl}/Items` + `/${imageId}` + - `/Images/Primary` + + `/Images/Backdrop` + '?fillHeight=250' + `&fillWidth=250` + '&quality=90' @@ -109,7 +148,7 @@ const imageUrl = ( `&size=250` + `&v=1.13.0` + `&c=Feishin` + - `&${token}` + `&${token ? `${token}` : ''}` ); } @@ -219,23 +258,42 @@ const rating = ( return null; }; -const image = ( - images: Image[], - type: ServerType, - imageType: ImageType, - url: string, - remoteId: string, - token?: string -) => { - const imageRemoteUrl = images.find((i) => i.type === imageType)?.remoteUrl; +const buildImageUrl = (options: { + imageType: ImageType; + images: Image[]; + noCredential?: boolean; + remoteId: string; + token?: string; + type: ServerType; + url: string; +}) => { + 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) { - return imageUrl(type, url, remoteId); + return imageUrl(type, imageType, url, remoteId); } - if (type === ServerType.SUBSONIC || type === ServerType.NAVIDROME) { - return imageUrl(type, url, imageRemoteUrl, token); + if (type === ServerType.SUBSONIC) { + 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; @@ -244,7 +302,7 @@ const image = ( type DbSong = Song & DbSongInclude; type DbSongInclude = { - album: Album; + album: Album & { images: Image[] }; artists: Artist[]; externals: External[]; genres: Genre[]; @@ -263,21 +321,45 @@ const songs = ( type: ServerType; url: string; userId: string; - } + }, + noCredential: boolean ) => { return ( items?.map((item) => { - const customUrl = item.server.serverUrls[0].url; + const customUrl = item.server.serverUrls[0]?.url; const baseUrl = customUrl ? customUrl : options.url; - const stream = streamUrl(options.type, { + const streamUrl = buildStreamUrl(options.type, { deviceId: options.deviceId, + noCredential, remoteId: item.remoteId, token: options.token, url: baseUrl, 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 { /* eslint-disable sort-keys-fix/sort-keys-fix */ id: item.id, @@ -292,20 +374,14 @@ const songs = ( discNumber: item.discNumber, duration: item.duration, genres: relatedGenres(item.genres), - imageUrl: image( - item.images, - options.type, - ImageType.PRIMARY, - baseUrl, - item.remoteId - ), + imageUrl, releaseDate: item.releaseDate, releaseYear: item.releaseYear, remoteCreatedAt: item.remoteCreatedAt, remoteId: item.remoteId, // serverFolderId: item.serverFolderId, serverId: item.serverId, - streamUrl: stream, + streamUrl, trackNumber: item.trackNumber, updatedAt: item.updatedAt, /* eslint-enable sort-keys-fix/sort-keys-fix */ @@ -338,9 +414,33 @@ const albums = (options: { const { items, serverUrl, user } = options; return ( items?.map((item) => { - const { type, token, remoteUserId } = item.server; + const { type, token, remoteUserId, noCredential } = item.server; 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 { /* eslint-disable sort-keys-fix/sort-keys-fix */ id: item.id, @@ -352,20 +452,8 @@ const albums = (options: { rating: rating(item.ratings), songCount: item._count.songs, type, - imageUrl: image( - item.images, - type, - ImageType.PRIMARY, - url, - item.remoteId - ), - backdropImageUrl: image( - item.images, - type, - ImageType.BACKDROP, - url, - item.remoteId - ), + imageUrl, + backdropImageUrl: backdropImageUrl, deleted: item.deleted, remoteId: item.remoteId, remoteCreatedAt: item.remoteCreatedAt, @@ -379,13 +467,20 @@ const albums = (options: { serverFolders: relatedServerFolders(item.serverFolders), songs: item.songs && - songs(item.songs, { - deviceId: user.deviceId, - token, - type, - url, - userId: remoteUserId, - }), + songs( + item?.songs?.map((s: any) => ({ + ...s, + album: { images: item?.images }, + })), + { + deviceId: user.deviceId, + token, + type, + url, + userId: remoteUserId, + }, + noCredential + ), /* eslint-enable sort-keys-fix/sort-keys-fix */ }; }) || [] @@ -440,6 +535,7 @@ const servers = ( name: item.name, url: item.url, type: item.type, + noCredential: item.noCredential, username: item.username, createdAt: item.createdAt, updatedAt: item.updatedAt, diff --git a/server/helpers/shared.helpers.ts b/server/helpers/shared.helpers.ts index 01c60b4fd..e4e8fbd5a 100644 --- a/server/helpers/shared.helpers.ts +++ b/server/helpers/shared.helpers.ts @@ -49,7 +49,7 @@ const getAvailableServerFolderIds = async ( if (user.isAdmin) { const serverFoldersWithAccess = await prisma.serverFolder.findMany({ - where: { serverId }, + where: { enabled: true, serverId }, }); const serverFoldersWithAccessIds = serverFoldersWithAccess.map( @@ -65,6 +65,7 @@ const getAvailableServerFolderIds = async ( { AND: [ { + enabled: true, serverFolderPermissions: { some: { userId: { equals: user.id } }, }, diff --git a/server/prisma/migrations/20221028032722_require_credential/migration.sql b/server/prisma/migrations/20221028032722_require_credential/migration.sql new file mode 100644 index 000000000..4b3f529b8 --- /dev/null +++ b/server/prisma/migrations/20221028032722_require_credential/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Server" ADD COLUMN "noCredential" BOOLEAN NOT NULL DEFAULT true; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 93d05f2b8..5ea657b39 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -99,6 +99,7 @@ model Server { remoteUserId String username String token String + noCredential Boolean @default(true) type ServerType createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/server/services/servers.service.ts b/server/services/servers.service.ts index 07a8a8475..8c5e320d7 100644 --- a/server/services/servers.service.ts +++ b/server/services/servers.service.ts @@ -104,12 +104,13 @@ const findById = async (user: AuthUser, options: { id: string }) => { return server; }; -const findMany = async (user: AuthUser) => { +const findMany = async (user: AuthUser, options?: { enabled?: boolean }) => { if (user.isAdmin) { return prisma.server.findMany({ include: { serverFolders: { orderBy: { name: SortOrder.ASC }, + where: { enabled: options?.enabled ? true : undefined }, }, serverPermissions: { orderBy: { createdAt: SortOrder.ASC }, @@ -131,7 +132,12 @@ const findMany = async (user: AuthUser) => { include: { serverFolders: { orderBy: { name: SortOrder.ASC }, - where: { id: { in: user.flatServerFolderPermissions } }, + where: { + AND: [ + { id: { in: user.flatServerFolderPermissions } }, + { enabled: options?.enabled ? true : undefined }, + ], + }, }, serverPermissions: { orderBy: { createdAt: SortOrder.ASC }, @@ -178,6 +184,7 @@ const create = async (options: { if (!serverFoldersRes) { throw ApiError.badRequest('Server is inaccessible.'); } + const serverFoldersCreate = serverFoldersRes.map((folder) => { return { 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; } @@ -219,6 +213,8 @@ const create = async (options: { throw ApiError.badRequest('Server is inaccessible.'); } + const navidromeToken = options.token + '||' + options?.altToken; + const serverFoldersCreate = serverFoldersRes.map((folder) => { return { name: folder.name, @@ -232,7 +228,7 @@ const create = async (options: { remoteUserId: options.remoteUserId, serverFolders: { create: serverFoldersCreate }, serverUrls: { create: { url: options.url } }, - token: options.token, + token: navidromeToken, type: options.type, url: options.url, username: options.username, @@ -295,8 +291,8 @@ const create = async (options: { const update = async ( options: { id: string }, data: { - altToken?: string; // Used for Navidrome only name?: string; + noCredential?: boolean; remoteUserId?: string; token?: string; type?: ServerType; diff --git a/server/validations/servers.validation.ts b/server/validations/servers.validation.ts index 720c1ba82..344c65afd 100644 --- a/server/validations/servers.validation.ts +++ b/server/validations/servers.validation.ts @@ -11,7 +11,7 @@ const detail = { const list = { body: z.object({}), params: z.object({}), - query: z.object({}), + query: z.object({ enabled: z.string().optional() }), }; const deleteServer = { @@ -24,6 +24,7 @@ const update = { body: z.object({ legacy: z.boolean().optional(), name: z.string().optional(), + noCredential: z.boolean().optional(), password: z.string().optional(), type: z.nativeEnum(ServerType), url: z.string().optional(), @@ -37,6 +38,7 @@ const create = { body: z.object({ legacy: z.boolean().optional(), name: z.string(), + noCredential: z.boolean().optional(), password: z.string(), type: z.enum([ ServerType.JELLYFIN,