From 828e2364a7b9275725d143fa587a6395568a9aaa Mon Sep 17 00:00:00 2001 From: emeric Date: Fri, 24 Sep 2021 12:37:54 +0200 Subject: [PATCH] Subsonic API: better compability with clients --- README.md | 6 +- conf/lms.conf | 4 + src/libs/subsonic/CMakeLists.txt | 1 + src/libs/subsonic/impl/ClientInfo.hpp | 34 ++ src/libs/subsonic/impl/ProtocolVersion.cpp | 56 ++++ src/libs/subsonic/impl/ProtocolVersion.hpp | 40 +++ src/libs/subsonic/impl/RequestContext.hpp | 5 +- src/libs/subsonic/impl/Scan.cpp | 4 +- src/libs/subsonic/impl/SubsonicResource.cpp | 295 +++++++++--------- src/libs/subsonic/impl/SubsonicResource.hpp | 55 ++++ src/libs/subsonic/impl/SubsonicResponse.cpp | 27 +- src/libs/subsonic/impl/SubsonicResponse.hpp | 11 +- .../include/subsonic/SubsonicResource.hpp | 18 +- src/libs/utils/impl/Config.cpp | 35 ++- src/libs/utils/impl/Config.hpp | 1 + src/libs/utils/include/utils/IConfig.hpp | 10 +- src/lms/main.cpp | 7 +- 17 files changed, 405 insertions(+), 204 deletions(-) create mode 100644 src/libs/subsonic/impl/ClientInfo.hpp create mode 100644 src/libs/subsonic/impl/ProtocolVersion.cpp create mode 100644 src/libs/subsonic/impl/ProtocolVersion.hpp create mode 100644 src/libs/subsonic/impl/SubsonicResource.hpp diff --git a/README.md b/README.md index a20d26d8..b765f0ac 100644 --- a/README.md +++ b/README.md @@ -48,13 +48,13 @@ __Notes on the self-organizing map__: * to enable the audio similarity source, you have to enable it first in the administration panel. ## Subsonic API -The API version implemented is 1.12.0 and has been tested on _Android_ using the official application, _Ultrasonic_ and _DSub_. +The API version implemented is 1.16.0 and has been tested on _Android_ using _Subsonic Player_, _Ultrasonic_ and _DSub_. -Since _LMS_ uses metadata tags to organize music, a compatibility mode is used to navigate through the collection using the directory browsing commands. +Since _LMS_ uses metadata tags to organize music, a compatibility mode is used to navigate through the collection when using the directory browsing commands. The Subsonic API is enabled by default. -__Note__: since _LMS_ may store hashed and salted passwords or may forward authentication requests to external services, it cannot handle the __token authentication__ method defined from version 1.13.0. +__Note__: since _LMS_ may store hashed and salted passwords or may forward authentication requests to external services, it cannot handle the __token authentication__ method. You may need to check your client to make sure to use the __password__ authentication method. ## About tags _LMS_ relies exclusively on tags to organize your music collection. diff --git a/conf/lms.conf b/conf/lms.conf index 550db81c..ab0ba1c9 100644 --- a/conf/lms.conf +++ b/conf/lms.conf @@ -55,6 +55,10 @@ login-throttler-max-entries = 10000; # API api-subsonic = true; +# Use this list to make the reported server version to 1.12.0 depending on the client's name +# Main usage is to make auto detections for the 'p' (password) parameter work +api-subsonic-report-old-server-protocol = ("DSub"); + # Turn on this option to allow the demo account creation/use demo = false; diff --git a/src/libs/subsonic/CMakeLists.txt b/src/libs/subsonic/CMakeLists.txt index 4c87d7cd..98093601 100644 --- a/src/libs/subsonic/CMakeLists.txt +++ b/src/libs/subsonic/CMakeLists.txt @@ -1,5 +1,6 @@ add_library(lmssubsonic SHARED + impl/ProtocolVersion.cpp impl/Scan.cpp impl/Stream.cpp impl/SubsonicId.cpp diff --git a/src/libs/subsonic/impl/ClientInfo.hpp b/src/libs/subsonic/impl/ClientInfo.hpp new file mode 100644 index 00000000..9c2dafd0 --- /dev/null +++ b/src/libs/subsonic/impl/ClientInfo.hpp @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2021 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include "ProtocolVersion.hpp" + +namespace API::Subsonic +{ + struct ClientInfo + { + std::string name; + std::string user; + std::string password; + ProtocolVersion version; + }; +} diff --git a/src/libs/subsonic/impl/ProtocolVersion.cpp b/src/libs/subsonic/impl/ProtocolVersion.cpp new file mode 100644 index 00000000..4a02a4e3 --- /dev/null +++ b/src/libs/subsonic/impl/ProtocolVersion.cpp @@ -0,0 +1,56 @@ +/* + * copyright (c) 2021 emeric poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "ProtocolVersion.hpp" + +namespace StringUtils +{ + template<> + std::optional + readAs(std::string_view str) + { + // Expects "X.Y.Z" + const auto numbers {StringUtils::splitString(str, ".")}; + if (numbers.size() < 2 || numbers.size() > 3) + return std::nullopt; + + API::Subsonic::ProtocolVersion version; + + auto number {StringUtils::readAs(numbers[0])}; + if (!number) + return std::nullopt; + version.major = *number; + + number = {StringUtils::readAs(numbers[1])}; + if (!number) + return std::nullopt; + version.minor = *number; + + if (numbers.size() == 3) + { + number = {StringUtils::readAs(numbers[2])}; + if (!number) + return std::nullopt; + version.patch = *number; + } + + return version; + } +} + diff --git a/src/libs/subsonic/impl/ProtocolVersion.hpp b/src/libs/subsonic/impl/ProtocolVersion.hpp new file mode 100644 index 00000000..fc046e22 --- /dev/null +++ b/src/libs/subsonic/impl/ProtocolVersion.hpp @@ -0,0 +1,40 @@ +/* + * copyright (c) 2021 emeric poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include "utils/String.hpp" + +namespace API::Subsonic +{ + struct ProtocolVersion + { + unsigned major {}; + unsigned minor {}; + unsigned patch {}; + }; + + static inline constexpr ProtocolVersion defaultServerProtocolVersion {1, 16, 0}; +} + +namespace StringUtils +{ + template<> std::optional readAs(std::string_view str); +} + diff --git a/src/libs/subsonic/impl/RequestContext.hpp b/src/libs/subsonic/impl/RequestContext.hpp index d2949a8b..9d73bcb4 100644 --- a/src/libs/subsonic/impl/RequestContext.hpp +++ b/src/libs/subsonic/impl/RequestContext.hpp @@ -24,6 +24,8 @@ #include #include "database/Types.hpp" +#include "ClientInfo.hpp" +#include "ProtocolVersion.hpp" namespace Database { @@ -37,7 +39,8 @@ namespace API::Subsonic const Wt::Http::ParameterMap& parameters; Database::Session& dbSession; Database::UserId userId; - std::string clientName; + ClientInfo clientInfo; + ProtocolVersion serverProtocolVersion; }; } diff --git a/src/libs/subsonic/impl/Scan.cpp b/src/libs/subsonic/impl/Scan.cpp index 9d57fb17..dee3c082 100644 --- a/src/libs/subsonic/impl/Scan.cpp +++ b/src/libs/subsonic/impl/Scan.cpp @@ -52,7 +52,7 @@ namespace API::Subsonic::Scan Response handleGetScanStatus(RequestContext& context) { - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; response.addNode("scanStatus", createStatusResponseNode()); return response; @@ -63,7 +63,7 @@ namespace API::Subsonic::Scan { Service::get()->requestImmediateScan(false); - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; response.addNode("scanStatus", createStatusResponseNode()); return response; diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index a3f8b3f4..dc9a904d 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -16,7 +16,8 @@ * You should have received a copy of the GNU General Public License * along with LMS. If not, see . */ -#include "subsonic/SubsonicResource.hpp" + +#include "SubsonicResource.hpp" #include #include @@ -39,12 +40,14 @@ #include "database/User.hpp" #include "recommendation/IEngine.hpp" #include "scrobbling/IScrobbling.hpp" +#include "utils/IConfig.hpp" #include "utils/Logger.hpp" #include "utils/Random.hpp" #include "utils/Service.hpp" #include "utils/String.hpp" #include "utils/Utils.hpp" #include "ParameterParsing.hpp" +#include "ProtocolVersion.hpp" #include "RequestContext.hpp" #include "Scan.hpp" #include "Stream.hpp" @@ -58,56 +61,18 @@ static const std::string reportedStarredDate {"2000-01-01T00:00:00"}; static const std::string reportedDummyDate {"2000-01-01T00:00:00"}; static const unsigned long long reportedDummyDateULong {946684800000ULL}; // 2000-01-01T00:00:00 UTC -namespace API::Subsonic -{ - struct ClientVersion - { - unsigned major {}; - unsigned minor {}; - unsigned patch {}; - }; -} -namespace StringUtils -{ - template<> - std::optional - readAs(std::string_view str) - { - // Expects "X.Y.Z" - const auto numbers {StringUtils::splitString(str, ".")}; - if (numbers.size() < 2 || numbers.size() > 3) - return std::nullopt; - - API::Subsonic::ClientVersion version; - - auto number {StringUtils::readAs(numbers[0])}; - if (!number) - return std::nullopt; - version.major = *number; - - number = {StringUtils::readAs(numbers[1])}; - if (!number) - return std::nullopt; - version.minor = *number; - - if (numbers.size() == 3) - { - number = {StringUtils::readAs(numbers[2])}; - if (!number) - return std::nullopt; - version.patch = *number; - } - - return version; - } - -} namespace API::Subsonic { +std::unique_ptr +createSubsonicResource(Database::Db& db) +{ + return std::make_unique(db); +} + static void checkSetPasswordImplemented() @@ -140,38 +105,24 @@ decodePasswordIfNeeded(const std::string& password) return password; } -struct ClientInfo -{ - std::string name; - std::string user; - std::string password; - ClientVersion version; -}; - static -ClientInfo -getClientInfo(const Wt::Http::ParameterMap& parameters) +std::unordered_map +readConfigProtocolVersions() { - ClientInfo res; + std::unordered_map res; - // Mandatory parameters - res.name = getMandatoryParameterAs(parameters, "c"); - res.version = getMandatoryParameterAs(parameters, "v"); - if (res.version.major > API_VERSION_MAJOR) - throw ServerMustUpgradeError {}; - if (res.version.major < API_VERSION_MAJOR) - throw ClientMustUpgradeError {}; - if (res.version.minor > Response::getAPIMinorVersion(res.name)) - throw ServerMustUpgradeError {}; - - res.user = getMandatoryParameterAs(parameters, "u"); - res.password = decodePasswordIfNeeded(getMandatoryParameterAs(parameters, "p")); + Service::get()->visitStrings("api-subsonic-report-old-server-protocol", + [&](std::string_view client) + { + res.emplace(std::string {client}, ProtocolVersion {1, 12, 0}); + }, {"DSub"}); return res; } SubsonicResource::SubsonicResource(Db& db) -: _db {db} +: _serverProtocolVersionsByClient {readConfigProtocolVersions()} +, _db {db} { } @@ -530,7 +481,7 @@ static Response handlePingRequest(RequestContext& context) { - return Response::createOkResponse(context); + return Response::createOkResponse(context.serverProtocolVersion); } static @@ -570,7 +521,7 @@ handleChangePassword(RequestContext& context) throw UserNotAuthorizedError {}; } - return Response::createOkResponse(context); + return Response::createOkResponse(context.serverProtocolVersion); } static @@ -620,7 +571,7 @@ handleCreatePlaylistRequest(RequestContext& context) TrackListEntry::create(context.dbSession, track, tracklist ); } - return Response::createOkResponse(context); + return Response::createOkResponse(context.serverProtocolVersion); } static @@ -671,7 +622,7 @@ handleCreateUserRequest(RequestContext& context) throw UserNotAuthorizedError {}; } - return Response::createOkResponse(context); + return Response::createOkResponse(context.serverProtocolVersion); } static @@ -696,7 +647,7 @@ handleDeletePlaylistRequest(RequestContext& context) tracklist.remove(); - return Response::createOkResponse(context); + return Response::createOkResponse(context.serverProtocolVersion); } static @@ -717,14 +668,14 @@ handleDeleteUserRequest(RequestContext& context) user.remove(); - return Response::createOkResponse(context); + return Response::createOkResponse(context.serverProtocolVersion); } static Response handleGetLicenseRequest(RequestContext& context) { - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node& licenseNode {response.createNode("license")}; licenseNode.setAttribute("licenseExpires", "2025-09-03T14:46:43"); @@ -750,7 +701,7 @@ handleGetRandomSongsRequest(RequestContext& context) auto tracks {Track::getAllRandom(context.dbSession, {}, size)}; - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node& randomSongsNode {response.createNode("randomSongs")}; for (const Track::pointer& track : tracks) @@ -839,7 +790,7 @@ handleGetAlbumListRequestCommon(const RequestContext& context, bool id3) else throw NotImplementedGenericError {}; - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node& albumListNode {response.createNode(id3 ? "albumList2" : "albumList")}; for (const Release::pointer& release : releases) @@ -879,7 +830,7 @@ handleGetAlbumRequest(RequestContext& context) if (!user) throw UserNotAuthorizedError {}; - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node releaseNode {releaseToResponseNode(release, context.dbSession, user, true /* id3 */)}; auto tracks {release->getTracks()}; @@ -908,7 +859,7 @@ handleGetArtistRequest(RequestContext& context) if (!user) throw UserNotAuthorizedError {}; - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node artistNode {artistToResponseNode(user, artist, true /* id3 */)}; auto releases {artist->getReleases()}; @@ -930,7 +881,7 @@ handleGetArtistInfoRequestCommon(RequestContext& context, bool id3) // Optional params std::size_t count {getParameterAs(context.parameters, "count").value_or(20)}; - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node& artistInfoNode {response.createNode(id3 ? "artistInfo2" : "artistInfo")}; { @@ -986,7 +937,7 @@ static Response handleGetArtistsRequest(RequestContext& context) { - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node& artistsNode {response.createNode("artists")}; artistsNode.setAttribute("ignoredArticles", ""); @@ -1040,7 +991,7 @@ handleGetMusicDirectoryRequest(RequestContext& context) if (!root && !artistId && !releaseId && !trackId) throw BadParameterGenericError {"id"}; - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node& directoryNode {response.createNode("directory")}; auto transaction {context.dbSession.createSharedTransaction()}; @@ -1097,7 +1048,7 @@ static Response handleGetMusicFoldersRequest(RequestContext& context) { - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node& musicFoldersNode {response.createNode("musicFolders")}; Response::Node& musicFolderNode {musicFoldersNode.createArrayChild("musicFolder")}; @@ -1111,7 +1062,7 @@ static Response handleGetGenresRequest(RequestContext& context) { - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node& genresNode {response.createNode("genres")}; @@ -1133,7 +1084,7 @@ static Response handleGetIndexesRequest(RequestContext& context) { - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node& artistsNode {response.createNode("indexes")}; artistsNode.setAttribute("ignoredArticles", ""); @@ -1216,7 +1167,7 @@ handleGetSimilarSongsRequestCommon(RequestContext& context, bool id3) Random::shuffleContainer(tracks); - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node& similarSongsNode {response.createNode(id3 ? "similarSongs2" : "similarSongs")}; for (const Track::pointer& track : tracks) similarSongsNode.addArrayChild("song", trackToResponseNode(track, context.dbSession, user)); @@ -1248,7 +1199,7 @@ handleGetStarredRequestCommon(RequestContext& context, bool id3) if (!user) throw UserNotAuthorizedError {}; - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node& starredNode {response.createNode(id3 ? "starred2" : "starred")}; { @@ -1324,7 +1275,7 @@ handleGetPlaylistRequest(RequestContext& context) if (!tracklist) throw RequestedDataNotFoundError {}; - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node playlistNode {tracklistToResponseNode(tracklist, context.dbSession)}; auto entries {tracklist->getEntries()}; @@ -1346,7 +1297,7 @@ handleGetPlaylistsRequest(RequestContext& context) if (!user) throw UserNotAuthorizedError {}; - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node& playlistsNode {response.createNode("playlists")}; auto tracklists {TrackList::getAll(context.dbSession, user, TrackList::Type::Playlist)}; @@ -1383,7 +1334,7 @@ handleGetSongsByGenreRequest(RequestContext& context) if (!user) throw UserNotAuthorizedError {}; - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node& songsByGenreNode {response.createNode("songsByGenre")}; bool more; @@ -1408,7 +1359,7 @@ handleGetUserRequest(RequestContext& context) if (!user) throw RequestedDataNotFoundError {}; - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; response.addNode("user", userToResponseNode(user)); return response; @@ -1420,7 +1371,7 @@ handleGetUsersRequest(RequestContext& context) { auto transaction {context.dbSession.createSharedTransaction()}; - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node& usersNode {response.createNode("users")}; const auto users {User::getAll(context.dbSession)}; @@ -1453,7 +1404,7 @@ handleSearchRequestCommon(RequestContext& context, bool id3) if (!user) throw UserNotAuthorizedError {}; - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node& searchResult2Node {response.createNode(id3 ? "searchResult3" : "searchResult2")}; bool more; @@ -1538,7 +1489,7 @@ handleStarRequest(RequestContext& context) user.modify()->starTrack(track); } - return Response::createOkResponse(context); + return Response::createOkResponse(context.serverProtocolVersion); } static @@ -1595,7 +1546,7 @@ handleUnstarRequest(RequestContext& context) } - return Response::createOkResponse(context); + return Response::createOkResponse(context.serverProtocolVersion); } static @@ -1639,7 +1590,7 @@ handleScrobble(RequestContext& context) } } - return Response::createOkResponse(context); + return Response::createOkResponse(context.serverProtocolVersion); } static @@ -1682,7 +1633,7 @@ handleUpdateUserRequest(RequestContext& context) } } - return Response::createOkResponse(context); + return Response::createOkResponse(context.serverProtocolVersion); } static @@ -1741,7 +1692,7 @@ handleUpdatePlaylistRequest(RequestContext& context) TrackListEntry::create(context.dbSession, track, tracklist); } - return Response::createOkResponse(context); + return Response::createOkResponse(context.serverProtocolVersion); } static @@ -1756,7 +1707,7 @@ handleGetBookmarks(RequestContext& context) const auto bookmarks {TrackBookmark::getByUser(context.dbSession, user)}; - Response response {Response::createOkResponse(context)}; + Response response {Response::createOkResponse(context.serverProtocolVersion)}; Response::Node& bookmarksNode {response.createNode("bookmarks")}; for (const TrackBookmark::pointer& bookmark : bookmarks) @@ -1798,7 +1749,7 @@ handleCreateBookmark(RequestContext& context) if (comment) bookmark.modify()->setComment(*comment); - return Response::createOkResponse(context); + return Response::createOkResponse(context.serverProtocolVersion); } static @@ -1824,7 +1775,7 @@ handleDeleteBookmark(RequestContext& context) bookmark.remove(); - return Response::createOkResponse(context); + return Response::createOkResponse(context.serverProtocolVersion); } static @@ -1867,7 +1818,7 @@ struct RequestEntryPointInfo CheckImplementedFunc checkFunc {}; }; -static std::unordered_map requestEntryPoints +static const std::unordered_map requestEntryPoints { // System {"ping", {handlePingRequest}}, @@ -1981,38 +1932,6 @@ static std::unordered_map mediaRetrieval {"getCoverArt", handleGetCoverArt}, }; -static -Database::UserId -authenticateUser(const Wt::Http::Request &request, const ClientInfo& clientInfo, Session& dbSession) -{ - if (auto *authEnvService {Service<::Auth::IEnvService>::get()}) - { - const auto checkResult {authEnvService->processRequest(dbSession, request)}; - if (checkResult.state != ::Auth::IEnvService::CheckResult::State::Granted) - throw UserNotAuthorizedError {}; - - return *checkResult.userId; - } - else if (auto *authPasswordService {Service<::Auth::IPasswordService>::get()}) - { - const auto checkResult {authPasswordService->checkUserPassword(dbSession, - boost::asio::ip::address::from_string(request.clientAddress()), - clientInfo.user, clientInfo.password)}; - - switch (checkResult.state) - { - case Auth::IPasswordService::CheckResult::State::Granted: - return *checkResult.userId; - break; - case Auth::IPasswordService::CheckResult::State::Denied: - throw WrongUsernameOrPasswordError {}; - case Auth::IPasswordService::CheckResult::State::Throttled: - throw LoginThrottledGenericError {}; - } - } - - throw InternalErrorGenericError {"No service avalaible to authenticate user"}; -} void SubsonicResource::handleRequest(const Wt::Http::Request &request, Wt::Http::Response &response) @@ -2027,24 +1946,16 @@ SubsonicResource::handleRequest(const Wt::Http::Request &request, Wt::Http::Resp if (StringUtils::stringEndsWith(requestPath, ".view")) requestPath.resize(requestPath.length() - 5); - const Wt::Http::ParameterMap& parameters {request.getParameterMap()}; - // Optional parameters - const ResponseFormat format {getParameterAs(parameters, "f").value_or("xml") == "json" ? ResponseFormat::json : ResponseFormat::xml}; + const ResponseFormat format {getParameterAs(request.getParameterMap(), "f").value_or("xml") == "json" ? ResponseFormat::json : ResponseFormat::xml}; - std::string clientName; + ProtocolVersion protocolVersion {defaultServerProtocolVersion}; try { - // Mandatory parameters - const ClientInfo clientInfo {getClientInfo(parameters)}; - - clientName = clientInfo.name; - - Session& dbSession {_db.getTLSSession()}; - - const Database::UserId userId {authenticateUser(request, clientInfo, dbSession)}; - RequestContext requestContext {parameters, dbSession, userId, clientInfo.name}; + // We need to parse client a soon as possible to make sure to answer with the right protocol version + protocolVersion = getServerProtocolVersion(getMandatoryParameterAs(request.getParameterMap(), "c")); + RequestContext requestContext {buildRequestContext(request)}; auto itEntryPoint {requestEntryPoints.find(requestPath)}; if (itEntryPoint != requestEntryPoints.end()) @@ -2079,11 +1990,97 @@ SubsonicResource::handleRequest(const Wt::Http::Request &request, Wt::Http::Resp LMS_LOG(API_SUBSONIC, ERROR) << "Error while processing request '" << requestPath << "'" << ", params = [" << parameterMapToDebugString(request.getParameterMap()) << "]" << ", code = " << static_cast(e.getCode()) << ", msg = '" << e.getMessage() << "'"; - Response resp {Response::createFailedResponse(clientName, e)}; + Response resp {Response::createFailedResponse(protocolVersion, e)}; resp.write(response.out(), format); response.setMimeType(ResponseFormatToMimeType(format)); } } +ProtocolVersion +SubsonicResource::getServerProtocolVersion(const std::string& clientName) const +{ + auto it {_serverProtocolVersionsByClient.find(clientName)}; + if (it == std::cend(_serverProtocolVersionsByClient)) + return defaultServerProtocolVersion; + + return it->second; +} + +void +SubsonicResource::checkProtocolVersion(ProtocolVersion client, ProtocolVersion server) +{ + if (client.major > server.major) + throw ServerMustUpgradeError {}; + if (client.major < server.major) + throw ClientMustUpgradeError {}; + if (client.minor > server.minor) + throw ServerMustUpgradeError {}; + else if (client.minor == server.minor) + { + if (client.patch > server.patch) + throw ServerMustUpgradeError {}; + } +} + +ClientInfo +SubsonicResource::getClientInfo(const Wt::Http::ParameterMap& parameters) +{ + ClientInfo res; + + // Mandatory parameters + res.name = getMandatoryParameterAs(parameters, "c"); + res.version = getMandatoryParameterAs(parameters, "v"); + res.user = getMandatoryParameterAs(parameters, "u"); + res.password = decodePasswordIfNeeded(getMandatoryParameterAs(parameters, "p")); + + return res; +} + +RequestContext +SubsonicResource::buildRequestContext(const Wt::Http::Request& request) +{ + const Wt::Http::ParameterMap& parameters {request.getParameterMap()}; + + const ClientInfo clientInfo {getClientInfo(parameters)}; + + Session& dbSession {_db.getTLSSession()}; + + const Database::UserId userId {authenticateUser(request, clientInfo, dbSession)}; + + return {parameters, dbSession, userId, clientInfo, getServerProtocolVersion(clientInfo.name)}; +} + +Database::UserId +SubsonicResource::authenticateUser(const Wt::Http::Request& request, const ClientInfo& clientInfo, Session& dbSession) +{ + if (auto *authEnvService {Service<::Auth::IEnvService>::get()}) + { + const auto checkResult {authEnvService->processRequest(dbSession, request)}; + if (checkResult.state != ::Auth::IEnvService::CheckResult::State::Granted) + throw UserNotAuthorizedError {}; + + return *checkResult.userId; + } + else if (auto *authPasswordService {Service<::Auth::IPasswordService>::get()}) + { + const auto checkResult {authPasswordService->checkUserPassword(dbSession, + boost::asio::ip::address::from_string(request.clientAddress()), + clientInfo.user, clientInfo.password)}; + + switch (checkResult.state) + { + case Auth::IPasswordService::CheckResult::State::Granted: + return *checkResult.userId; + break; + case Auth::IPasswordService::CheckResult::State::Denied: + throw WrongUsernameOrPasswordError {}; + case Auth::IPasswordService::CheckResult::State::Throttled: + throw LoginThrottledGenericError {}; + } + } + + throw InternalErrorGenericError {"No service avalaible to authenticate user"}; +} + } // namespace api::subsonic diff --git a/src/libs/subsonic/impl/SubsonicResource.hpp b/src/libs/subsonic/impl/SubsonicResource.hpp new file mode 100644 index 00000000..fd53029b --- /dev/null +++ b/src/libs/subsonic/impl/SubsonicResource.hpp @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2019 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ +#pragma once + +#include +#include +#include + +#include "database/Types.hpp" +#include "ClientInfo.hpp" +#include "RequestContext.hpp" + +namespace Database +{ + class Db; +} + +namespace API::Subsonic +{ + + class SubsonicResource final : public Wt::WResource + { + public: + SubsonicResource(Database::Db& db); + + private: + void handleRequest(const Wt::Http::Request &request, Wt::Http::Response &response) override; + ProtocolVersion getServerProtocolVersion(const std::string& clientName) const; + + static void checkProtocolVersion(ProtocolVersion client, ProtocolVersion server); + ClientInfo getClientInfo(const Wt::Http::ParameterMap& parameters); + RequestContext buildRequestContext(const Wt::Http::Request& request); + Database::UserId authenticateUser(const Wt::Http::Request& request, const ClientInfo& clientInfo, Database::Session& dbSession); + + const std::unordered_map _serverProtocolVersionsByClient; + Database::Db& _db; + }; + +} // namespace diff --git a/src/libs/subsonic/impl/SubsonicResponse.cpp b/src/libs/subsonic/impl/SubsonicResponse.cpp index 47c47fcb..38f6b631 100644 --- a/src/libs/subsonic/impl/SubsonicResponse.cpp +++ b/src/libs/subsonic/impl/SubsonicResponse.cpp @@ -29,6 +29,7 @@ #include "utils/Exception.hpp" #include "utils/String.hpp" +#include "ProtocolVersion.hpp" namespace API::Subsonic { @@ -102,26 +103,32 @@ Response::Node::createArrayChild(const std::string& key) return _childrenArrays[key].back(); } +void +Response::Node::setVersionAttribute(ProtocolVersion protocolVersion) +{ + setAttribute("version", std::to_string(protocolVersion.major) + "." + std::to_string(protocolVersion.minor) + "." + std::to_string(protocolVersion.patch)); +} + Response -Response::createOkResponse(const RequestContext& context) +Response::createOkResponse(ProtocolVersion protocolVersion) { Response response; Node& responseNode {response._root.createChild("subsonic-response")}; responseNode.setAttribute("status", "ok"); - responseNode.setAttribute("version", std::string {QUOTEME(API_VERSION_MAJOR) "."} + std::to_string(getAPIMinorVersion(context.clientName)) + ".0"); + responseNode.setVersionAttribute(protocolVersion); return response; } Response -Response::createFailedResponse(std::string_view clientName, const Error& error) +Response::createFailedResponse(ProtocolVersion protocolVersion, const Error& error) { Response response; Node& responseNode {response._root.createChild("subsonic-response")}; responseNode.setAttribute("status", "failed"); - responseNode.setAttribute("version", std::string {QUOTEME(API_VERSION_MAJOR) "."} + std::to_string(getAPIMinorVersion(clientName)) + ".0"); + responseNode.setVersionAttribute(protocolVersion); Node& errorNode {responseNode.createChild("error")}; errorNode.setAttribute("code", std::to_string(static_cast(error.getCode()))); @@ -214,18 +221,6 @@ Response::writeXML(std::ostream& os) boost::property_tree::write_xml(os, root); } -unsigned -Response::getAPIMinorVersion(std::string_view clientName) -{ - // Some clients do not rely on version to enable the clear text password auth scheme - if (clientName == "Audinaut") - return 16; - else if (clientName == "Sublime Music") - return 16; - else - return 12; -} - void Response::writeJSON(std::ostream& os) { diff --git a/src/libs/subsonic/impl/SubsonicResponse.hpp b/src/libs/subsonic/impl/SubsonicResponse.hpp index dfc6b19b..c0d49b1e 100644 --- a/src/libs/subsonic/impl/SubsonicResponse.hpp +++ b/src/libs/subsonic/impl/SubsonicResponse.hpp @@ -27,8 +27,6 @@ #include "RequestContext.hpp" -#define API_VERSION_MAJOR 1 - namespace API::Subsonic { @@ -205,6 +203,9 @@ class Response void addArrayChild(const std::string& key, Node node); private: + + void setVersionAttribute(ProtocolVersion version); + friend class Response; using Value = std::variant; std::map _attributes; @@ -213,8 +214,8 @@ class Response std::map> _childrenArrays; }; - static Response createOkResponse(const RequestContext& context); - static Response createFailedResponse(std::string_view clientName, const Error& error); + static Response createOkResponse(ProtocolVersion protocolVersion); + static Response createFailedResponse(ProtocolVersion protocolVersion, const Error& error); virtual ~Response() {} Response(const Response&) = delete; @@ -228,9 +229,7 @@ class Response void write(std::ostream& os, ResponseFormat format); - static unsigned getAPIMinorVersion(std::string_view clientName); private: - void writeJSON(std::ostream& os); void writeXML(std::ostream& os); diff --git a/src/libs/subsonic/include/subsonic/SubsonicResource.hpp b/src/libs/subsonic/include/subsonic/SubsonicResource.hpp index 6d3e14b9..2354f787 100644 --- a/src/libs/subsonic/include/subsonic/SubsonicResource.hpp +++ b/src/libs/subsonic/include/subsonic/SubsonicResource.hpp @@ -18,8 +18,9 @@ */ #pragma once +#include + #include -#include namespace Database { @@ -28,18 +29,5 @@ namespace Database namespace API::Subsonic { - -class SubsonicResource final : public Wt::WResource -{ - public: - SubsonicResource(Database::Db& db); - - static std::string getPath() { return "rest/"; } - private: - - void handleRequest(const Wt::Http::Request &request, Wt::Http::Response &response) override; - - Database::Db& _db; -}; - + std::unique_ptr createSubsonicResource(Database::Db& db); } // namespace diff --git a/src/libs/utils/impl/Config.cpp b/src/libs/utils/impl/Config.cpp index ba19cc0a..e53b90c5 100644 --- a/src/libs/utils/impl/Config.cpp +++ b/src/libs/utils/impl/Config.cpp @@ -50,7 +50,8 @@ Config::Config(const std::filesystem::path& p) std::string_view Config::getString(std::string_view setting, std::string_view def) { - try { + try + { return static_cast(_config.lookup(std::string {setting})); } catch (libconfig::ConfigException&) @@ -59,10 +60,30 @@ Config::getString(std::string_view setting, std::string_view def) } } +void +Config::visitStrings(std::string_view setting, std::function _func, std::initializer_list defs) +{ + try + { + const libconfig::Setting& values {_config.lookup(std::string {setting})}; + for (int i {}; i < values.getLength(); ++i) + _func(static_cast(values[i])); + } + catch (const libconfig::SettingNotFoundException&) + { + for (std::string_view def : defs) + _func(def); + } + catch (libconfig::ConfigException&) + { + } +} + std::filesystem::path Config::getPath(std::string_view setting, const std::filesystem::path& path) { - try { + try + { const char* res {_config.lookup(std::string {setting})}; return std::filesystem::path {std::string(res)}; } @@ -75,7 +96,8 @@ Config::getPath(std::string_view setting, const std::filesystem::path& path) unsigned long Config::getULong(std::string_view setting, unsigned long def) { - try { + try + { return static_cast(_config.lookup(std::string {setting})); } catch (libconfig::ConfigException&) @@ -87,7 +109,8 @@ Config::getULong(std::string_view setting, unsigned long def) long Config::getLong(std::string_view setting, long def) { - try { + try + { return _config.lookup(std::string {setting}); } catch (libconfig::ConfigException&) @@ -99,7 +122,8 @@ Config::getLong(std::string_view setting, long def) bool Config::getBool(std::string_view setting, bool def) { - try { + try + { return _config.lookup(std::string {setting}); } catch (libconfig::ConfigException&) @@ -108,4 +132,3 @@ Config::getBool(std::string_view setting, bool def) } } - diff --git a/src/libs/utils/impl/Config.hpp b/src/libs/utils/impl/Config.hpp index 7dce0423..cbbb2542 100644 --- a/src/libs/utils/impl/Config.hpp +++ b/src/libs/utils/impl/Config.hpp @@ -36,6 +36,7 @@ class Config final : public IConfig // Default values are returned in case of setting not found std::string_view getString(std::string_view setting, std::string_view def = "") override; + void visitStrings(std::string_view setting, std::function _func, std::initializer_list defs) override; std::filesystem::path getPath(std::string_view setting, const std::filesystem::path& def = std::filesystem::path()) override; unsigned long getULong(std::string_view setting, unsigned long def = 0) override; long getLong(std::string_view setting, long def = 0) override; diff --git a/src/libs/utils/include/utils/IConfig.hpp b/src/libs/utils/include/utils/IConfig.hpp index 409d3702..975e5d6f 100644 --- a/src/libs/utils/include/utils/IConfig.hpp +++ b/src/libs/utils/include/utils/IConfig.hpp @@ -18,8 +18,9 @@ */ #pragma once -#include #include +#include +#include // Used to get config values from configuration files class IConfig @@ -30,10 +31,11 @@ class IConfig // Default values are returned in case of setting not found virtual std::string_view getString(std::string_view setting, std::string_view def = "") = 0; + virtual void visitStrings(std::string_view setting, std::function _func, std::initializer_list def = {}) = 0; virtual std::filesystem::path getPath(std::string_view setting, const std::filesystem::path& def = std::filesystem::path()) = 0; - virtual unsigned long getULong(std::string_view setting, unsigned long def = 0) = 0; - virtual long getLong(std::string_view setting, long def = 0) = 0; - virtual bool getBool(std::string_view setting, bool def = false) = 0; + virtual unsigned long getULong(std::string_view setting, unsigned long def = 0) = 0; + virtual long getLong(std::string_view setting, long def = 0) = 0; + virtual bool getBool(std::string_view setting, bool def = false) = 0; }; diff --git a/src/lms/main.cpp b/src/lms/main.cpp index ae7b34e1..98c0cec6 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -267,11 +267,14 @@ int main(int argc, char* argv[]) Service scrobblingService {Scrobbling::createScrobbling(ioContext, database)}; - API::Subsonic::SubsonicResource subsonicResource {database}; + std::unique_ptr subsonicResource; // bind API resources if (config->getBool("api-subsonic", true)) - server.addResource(&subsonicResource, subsonicResource.getPath()); + { + subsonicResource = API::Subsonic::createSubsonicResource(database); + server.addResource(subsonicResource.get(), "rest/"); + } // bind UI entry point server.addEntryPoint(Wt::EntryPointType::Application,