Extracted user management
This commit is contained in:
@@ -1,16 +1,20 @@
|
|||||||
|
|
||||||
add_library(lmssubsonic SHARED
|
add_library(lmssubsonic SHARED
|
||||||
|
impl/entrypoints/Bookmark.cpp
|
||||||
|
impl/entrypoints/Scan.cpp
|
||||||
|
impl/entrypoints/Stream.cpp
|
||||||
|
impl/entrypoints/UserManagement.cpp
|
||||||
impl/responses/Album.cpp
|
impl/responses/Album.cpp
|
||||||
impl/responses/Artist.cpp
|
impl/responses/Artist.cpp
|
||||||
impl/responses/Bookmark.cpp
|
impl/responses/Bookmark.cpp
|
||||||
impl/responses/Song.cpp
|
impl/responses/Song.cpp
|
||||||
|
impl/responses/User.cpp
|
||||||
impl/ProtocolVersion.cpp
|
impl/ProtocolVersion.cpp
|
||||||
impl/Bookmark.cpp
|
impl/ParameterParsing.cpp
|
||||||
impl/Scan.cpp
|
|
||||||
impl/Stream.cpp
|
|
||||||
impl/SubsonicId.cpp
|
impl/SubsonicId.cpp
|
||||||
impl/SubsonicResource.cpp
|
impl/SubsonicResource.cpp
|
||||||
impl/SubsonicResponse.cpp
|
impl/SubsonicResponse.cpp
|
||||||
|
impl/Utils.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(lmssubsonic INTERFACE
|
target_include_directories(lmssubsonic INTERFACE
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2023 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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "ParameterParsing.hpp"
|
||||||
|
|
||||||
|
namespace API::Subsonic
|
||||||
|
{
|
||||||
|
bool hasParameter(const Wt::Http::ParameterMap& parameterMap, const std::string& param)
|
||||||
|
{
|
||||||
|
return parameterMap.find(param) != std::cend(parameterMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string decodePasswordIfNeeded(const std::string& password)
|
||||||
|
{
|
||||||
|
if (password.find("enc:") == 0)
|
||||||
|
{
|
||||||
|
auto decodedPassword{ StringUtils::stringFromHex(password.substr(4)) };
|
||||||
|
if (!decodedPassword)
|
||||||
|
return password; // fallback on plain password
|
||||||
|
|
||||||
|
return *decodedPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,10 +16,15 @@
|
|||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <Wt/Http/Request.h>
|
#include <Wt/Http/Request.h>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <vector>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
#include "services/database/Types.hpp"
|
#include "services/database/Types.hpp"
|
||||||
#include "utils/String.hpp"
|
#include "utils/String.hpp"
|
||||||
#include "SubsonicResponse.hpp"
|
#include "SubsonicResponse.hpp"
|
||||||
@@ -28,8 +33,7 @@ namespace API::Subsonic
|
|||||||
{
|
{
|
||||||
|
|
||||||
template<typename T>
|
template<typename T>
|
||||||
std::vector<T>
|
std::vector<T> getMultiParametersAs(const Wt::Http::ParameterMap& parameterMap, const std::string& paramName)
|
||||||
getMultiParametersAs(const Wt::Http::ParameterMap& parameterMap, const std::string& paramName)
|
|
||||||
{
|
{
|
||||||
std::vector<T> res;
|
std::vector<T> res;
|
||||||
|
|
||||||
@@ -39,7 +43,7 @@ namespace API::Subsonic
|
|||||||
|
|
||||||
for (const std::string& param : it->second)
|
for (const std::string& param : it->second)
|
||||||
{
|
{
|
||||||
auto value {StringUtils::readAs<T>(param)};
|
auto value{ StringUtils::readAs<T>(param) };
|
||||||
if (value)
|
if (value)
|
||||||
res.emplace_back(std::move(*value));
|
res.emplace_back(std::move(*value));
|
||||||
}
|
}
|
||||||
@@ -48,44 +52,37 @@ namespace API::Subsonic
|
|||||||
}
|
}
|
||||||
|
|
||||||
template<typename T>
|
template<typename T>
|
||||||
std::vector<T>
|
std::vector<T> getMandatoryMultiParametersAs(const Wt::Http::ParameterMap& parameterMap, const std::string& param)
|
||||||
getMandatoryMultiParametersAs(const Wt::Http::ParameterMap& parameterMap, const std::string& param)
|
|
||||||
{
|
{
|
||||||
std::vector<T> res {getMultiParametersAs<T>(parameterMap, param)};
|
std::vector<T> res{ getMultiParametersAs<T>(parameterMap, param) };
|
||||||
if (res.empty())
|
if (res.empty())
|
||||||
throw RequiredParameterMissingError {param};
|
throw RequiredParameterMissingError{ param };
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
template<typename T>
|
template<typename T>
|
||||||
std::optional<T>
|
std::optional<T> getParameterAs(const Wt::Http::ParameterMap& parameterMap, const std::string& param)
|
||||||
getParameterAs(const Wt::Http::ParameterMap& parameterMap, const std::string& param)
|
|
||||||
{
|
{
|
||||||
std::vector<T> params {getMultiParametersAs<T>(parameterMap, param)};
|
std::vector<T> params{ getMultiParametersAs<T>(parameterMap, param) };
|
||||||
|
|
||||||
if (params.size() != 1)
|
if (params.size() != 1)
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
|
|
||||||
return T { std::move(params.front()) };
|
return T{ std::move(params.front()) };
|
||||||
}
|
}
|
||||||
|
|
||||||
template<typename T>
|
template<typename T>
|
||||||
T
|
T getMandatoryParameterAs(const Wt::Http::ParameterMap& parameterMap, const std::string& param)
|
||||||
getMandatoryParameterAs(const Wt::Http::ParameterMap& parameterMap, const std::string& param)
|
|
||||||
{
|
{
|
||||||
auto res {getParameterAs<T>(parameterMap, param)};
|
auto res{ getParameterAs<T>(parameterMap, param) };
|
||||||
if (!res)
|
if (!res)
|
||||||
throw RequiredParameterMissingError {param};
|
throw RequiredParameterMissingError{ param };
|
||||||
|
|
||||||
return *res;
|
return *res;
|
||||||
}
|
}
|
||||||
|
|
||||||
inline
|
bool hasParameter(const Wt::Http::ParameterMap& parameterMap, const std::string& param);
|
||||||
bool
|
std::string decodePasswordIfNeeded(const std::string& password);
|
||||||
hasParameter(const Wt::Http::ParameterMap& parameterMap, const std::string& param)
|
|
||||||
{
|
|
||||||
return parameterMap.find(param) != std::cend(parameterMap);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,17 +47,20 @@
|
|||||||
#include "utils/String.hpp"
|
#include "utils/String.hpp"
|
||||||
#include "utils/Utils.hpp"
|
#include "utils/Utils.hpp"
|
||||||
|
|
||||||
|
#include "entrypoints/Bookmark.hpp"
|
||||||
|
#include "entrypoints/Scan.hpp"
|
||||||
|
#include "entrypoints/Stream.hpp"
|
||||||
|
#include "entrypoints/UserManagement.hpp"
|
||||||
#include "responses/Artist.hpp"
|
#include "responses/Artist.hpp"
|
||||||
#include "responses/Album.hpp"
|
#include "responses/Album.hpp"
|
||||||
#include "responses/Song.hpp"
|
#include "responses/Song.hpp"
|
||||||
#include "Bookmark.hpp"
|
|
||||||
#include "ParameterParsing.hpp"
|
#include "ParameterParsing.hpp"
|
||||||
#include "ProtocolVersion.hpp"
|
#include "ProtocolVersion.hpp"
|
||||||
#include "RequestContext.hpp"
|
#include "RequestContext.hpp"
|
||||||
#include "Scan.hpp"
|
|
||||||
#include "Stream.hpp"
|
|
||||||
#include "SubsonicId.hpp"
|
#include "SubsonicId.hpp"
|
||||||
#include "SubsonicResponse.hpp"
|
#include "SubsonicResponse.hpp"
|
||||||
|
#include "Utils.hpp"
|
||||||
|
|
||||||
|
|
||||||
using namespace Database;
|
using namespace Database;
|
||||||
|
|
||||||
@@ -75,14 +78,6 @@ createSubsonicResource(Database::Db& db)
|
|||||||
return std::make_unique<SubsonicResource>(db);
|
return std::make_unique<SubsonicResource>(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
static
|
|
||||||
void
|
|
||||||
checkSetPasswordImplemented()
|
|
||||||
{
|
|
||||||
Auth::IPasswordService* passwordService {Service<Auth::IPasswordService>::get()};
|
|
||||||
if (!passwordService || !passwordService->canSetPasswords())
|
|
||||||
throw NotImplementedGenericError {};
|
|
||||||
}
|
|
||||||
|
|
||||||
static
|
static
|
||||||
std::string
|
std::string
|
||||||
@@ -91,22 +86,6 @@ makeNameFilesystemCompatible(const std::string& name)
|
|||||||
return StringUtils::replaceInString(name, "/", "_");
|
return StringUtils::replaceInString(name, "/", "_");
|
||||||
}
|
}
|
||||||
|
|
||||||
static
|
|
||||||
std::string
|
|
||||||
decodePasswordIfNeeded(const std::string& password)
|
|
||||||
{
|
|
||||||
if (password.find("enc:") == 0)
|
|
||||||
{
|
|
||||||
auto decodedPassword {StringUtils::stringFromHex(password.substr(4))};
|
|
||||||
if (!decodedPassword)
|
|
||||||
return password; // fallback on plain password
|
|
||||||
|
|
||||||
return *decodedPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
return password;
|
|
||||||
}
|
|
||||||
|
|
||||||
static
|
static
|
||||||
std::unordered_map<std::string, ProtocolVersion>
|
std::unordered_map<std::string, ProtocolVersion>
|
||||||
readConfigProtocolVersions()
|
readConfigProtocolVersions()
|
||||||
@@ -161,18 +140,6 @@ std::string parameterMapToDebugString(const Wt::Http::ParameterMap& parameterMap
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
static
|
|
||||||
void
|
|
||||||
checkUserIsMySelfOrAdmin(RequestContext& context, const std::string& username)
|
|
||||||
{
|
|
||||||
User::pointer currentUser {User::find(context.dbSession, context.userId)};
|
|
||||||
if (!currentUser)
|
|
||||||
throw RequestedDataNotFoundError {};
|
|
||||||
|
|
||||||
if (currentUser->getLoginName() != username && !currentUser->isAdmin())
|
|
||||||
throw UserNotAuthorizedError {};
|
|
||||||
}
|
|
||||||
|
|
||||||
static
|
static
|
||||||
void
|
void
|
||||||
checkUserTypeIsAllowed(RequestContext& context, EnumSet<Database::UserType> allowedUserTypes)
|
checkUserTypeIsAllowed(RequestContext& context, EnumSet<Database::UserType> allowedUserTypes)
|
||||||
@@ -200,33 +167,6 @@ clusterToResponseNode(const Cluster::pointer& cluster)
|
|||||||
return clusterNode;
|
return clusterNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
static
|
|
||||||
Response::Node
|
|
||||||
userToResponseNode(const User::pointer& user)
|
|
||||||
{
|
|
||||||
Response::Node userNode;
|
|
||||||
|
|
||||||
userNode.setAttribute("username", user->getLoginName());
|
|
||||||
userNode.setAttribute("scrobblingEnabled", true);
|
|
||||||
userNode.setAttribute("adminRole", user->isAdmin());
|
|
||||||
userNode.setAttribute("settingsRole", true);
|
|
||||||
userNode.setAttribute("downloadRole", true);
|
|
||||||
userNode.setAttribute("uploadRole", false);
|
|
||||||
userNode.setAttribute("playlistRole", true);
|
|
||||||
userNode.setAttribute("coverArtRole", false);
|
|
||||||
userNode.setAttribute("commentRole", false);
|
|
||||||
userNode.setAttribute("podcastRole", false);
|
|
||||||
userNode.setAttribute("streamRole", true);
|
|
||||||
userNode.setAttribute("jukeboxRole", false);
|
|
||||||
userNode.setAttribute("shareRole", false);
|
|
||||||
|
|
||||||
Response::Node folder;
|
|
||||||
folder.setValue("0");
|
|
||||||
userNode.addArrayChild("folder", std::move(folder));
|
|
||||||
|
|
||||||
return userNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
static
|
static
|
||||||
Response
|
Response
|
||||||
handlePingRequest(RequestContext& context)
|
handlePingRequest(RequestContext& context)
|
||||||
@@ -234,46 +174,6 @@ handlePingRequest(RequestContext& context)
|
|||||||
return Response::createOkResponse(context.serverProtocolVersion);
|
return Response::createOkResponse(context.serverProtocolVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
static
|
|
||||||
Response
|
|
||||||
handleChangePassword(RequestContext& context)
|
|
||||||
{
|
|
||||||
std::string username {getMandatoryParameterAs<std::string>(context.parameters, "username")};
|
|
||||||
std::string password {decodePasswordIfNeeded(getMandatoryParameterAs<std::string>(context.parameters, "password"))};
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Database::UserId userId;
|
|
||||||
{
|
|
||||||
auto transaction {context.dbSession.createSharedTransaction()};
|
|
||||||
|
|
||||||
checkUserIsMySelfOrAdmin(context, username);
|
|
||||||
|
|
||||||
User::pointer user {User::find(context.dbSession, username)};
|
|
||||||
if (!user)
|
|
||||||
throw UserNotAuthorizedError {};
|
|
||||||
|
|
||||||
userId = user->getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
Service<Auth::IPasswordService>::get()->setPassword(userId, password);
|
|
||||||
}
|
|
||||||
catch (const Auth::PasswordMustMatchLoginNameException&)
|
|
||||||
{
|
|
||||||
throw PasswordMustMatchLoginNameGenericError {};
|
|
||||||
}
|
|
||||||
catch (const Auth::PasswordTooWeakException&)
|
|
||||||
{
|
|
||||||
throw PasswordTooWeakGenericError {};
|
|
||||||
}
|
|
||||||
catch (const Auth::Exception& authException)
|
|
||||||
{
|
|
||||||
throw UserNotAuthorizedError {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response::createOkResponse(context.serverProtocolVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
static
|
static
|
||||||
Response
|
Response
|
||||||
handleCreatePlaylistRequest(RequestContext& context)
|
handleCreatePlaylistRequest(RequestContext& context)
|
||||||
@@ -324,57 +224,6 @@ handleCreatePlaylistRequest(RequestContext& context)
|
|||||||
return Response::createOkResponse(context.serverProtocolVersion);
|
return Response::createOkResponse(context.serverProtocolVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
static
|
|
||||||
Response
|
|
||||||
handleCreateUserRequest(RequestContext& context)
|
|
||||||
{
|
|
||||||
std::string username {getMandatoryParameterAs<std::string>(context.parameters, "username")};
|
|
||||||
std::string password {decodePasswordIfNeeded(getMandatoryParameterAs<std::string>(context.parameters, "password"))};
|
|
||||||
// Just ignore all the other fields as we don't handle them
|
|
||||||
|
|
||||||
Database::UserId userId;
|
|
||||||
{
|
|
||||||
auto transaction {context.dbSession.createUniqueTransaction()};
|
|
||||||
|
|
||||||
User::pointer user {User::find(context.dbSession, username)};
|
|
||||||
if (user)
|
|
||||||
throw UserAlreadyExistsGenericError {};
|
|
||||||
|
|
||||||
user = context.dbSession.create<User>(username);
|
|
||||||
userId = user->getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
auto removeCreatedUser {[&]()
|
|
||||||
{
|
|
||||||
auto transaction {context.dbSession.createUniqueTransaction()};
|
|
||||||
User::pointer user {User::find(context.dbSession, userId)};
|
|
||||||
if (user)
|
|
||||||
user.remove();
|
|
||||||
}};
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Service<Auth::IPasswordService>::get()->setPassword(userId, password);
|
|
||||||
}
|
|
||||||
catch (const Auth::PasswordMustMatchLoginNameException&)
|
|
||||||
{
|
|
||||||
removeCreatedUser();
|
|
||||||
throw PasswordMustMatchLoginNameGenericError {};
|
|
||||||
}
|
|
||||||
catch (const Auth::PasswordTooWeakException&)
|
|
||||||
{
|
|
||||||
removeCreatedUser();
|
|
||||||
throw PasswordTooWeakGenericError {};
|
|
||||||
}
|
|
||||||
catch (const Auth::Exception& exception)
|
|
||||||
{
|
|
||||||
removeCreatedUser();
|
|
||||||
throw UserNotAuthorizedError {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response::createOkResponse(context.serverProtocolVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
static
|
static
|
||||||
Response
|
Response
|
||||||
handleDeletePlaylistRequest(RequestContext& context)
|
handleDeletePlaylistRequest(RequestContext& context)
|
||||||
@@ -400,27 +249,6 @@ handleDeletePlaylistRequest(RequestContext& context)
|
|||||||
return Response::createOkResponse(context.serverProtocolVersion);
|
return Response::createOkResponse(context.serverProtocolVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
static
|
|
||||||
Response
|
|
||||||
handleDeleteUserRequest(RequestContext& context)
|
|
||||||
{
|
|
||||||
std::string username {getMandatoryParameterAs<std::string>(context.parameters, "username")};
|
|
||||||
|
|
||||||
auto transaction {context.dbSession.createUniqueTransaction()};
|
|
||||||
|
|
||||||
User::pointer user {User::find(context.dbSession, username)};
|
|
||||||
if (!user)
|
|
||||||
throw RequestedDataNotFoundError {};
|
|
||||||
|
|
||||||
// cannot delete ourself
|
|
||||||
if (user->getId() == context.userId)
|
|
||||||
throw UserNotAuthorizedError {};
|
|
||||||
|
|
||||||
user.remove();
|
|
||||||
|
|
||||||
return Response::createOkResponse(context.serverProtocolVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
static
|
static
|
||||||
Response
|
Response
|
||||||
handleGetLicenseRequest(RequestContext& context)
|
handleGetLicenseRequest(RequestContext& context)
|
||||||
@@ -1201,45 +1029,6 @@ handleGetSongsByGenreRequest(RequestContext& context)
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
static
|
|
||||||
Response
|
|
||||||
handleGetUserRequest(RequestContext& context)
|
|
||||||
{
|
|
||||||
std::string username {getMandatoryParameterAs<std::string>(context.parameters, "username")};
|
|
||||||
|
|
||||||
auto transaction {context.dbSession.createSharedTransaction()};
|
|
||||||
|
|
||||||
checkUserIsMySelfOrAdmin(context, username);
|
|
||||||
|
|
||||||
const User::pointer user {User::find(context.dbSession, username)};
|
|
||||||
if (!user)
|
|
||||||
throw RequestedDataNotFoundError {};
|
|
||||||
|
|
||||||
Response response {Response::createOkResponse(context.serverProtocolVersion)};
|
|
||||||
response.addNode("user", userToResponseNode(user));
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
static
|
|
||||||
Response
|
|
||||||
handleGetUsersRequest(RequestContext& context)
|
|
||||||
{
|
|
||||||
auto transaction {context.dbSession.createSharedTransaction()};
|
|
||||||
|
|
||||||
Response response {Response::createOkResponse(context.serverProtocolVersion)};
|
|
||||||
Response::Node& usersNode {response.createNode("users")};
|
|
||||||
|
|
||||||
const auto userIds {User::find(context.dbSession, User::FindParameters {})};
|
|
||||||
for (const UserId userId : userIds.results)
|
|
||||||
{
|
|
||||||
const User::pointer user {User::find(context.dbSession, userId)};
|
|
||||||
usersNode.addArrayChild("user", userToResponseNode(user));
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
static
|
static
|
||||||
Response
|
Response
|
||||||
handleSearchRequestCommon(RequestContext& context, bool id3)
|
handleSearchRequestCommon(RequestContext& context, bool id3)
|
||||||
@@ -1433,49 +1222,6 @@ handleScrobble(RequestContext& context)
|
|||||||
return Response::createOkResponse(context.serverProtocolVersion);
|
return Response::createOkResponse(context.serverProtocolVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
static
|
|
||||||
Response
|
|
||||||
handleUpdateUserRequest(RequestContext& context)
|
|
||||||
{
|
|
||||||
std::string username {getMandatoryParameterAs<std::string>(context.parameters, "username")};
|
|
||||||
std::optional<std::string> password {getParameterAs<std::string>(context.parameters, "password")};
|
|
||||||
|
|
||||||
UserId userId;
|
|
||||||
{
|
|
||||||
auto transaction {context.dbSession.createSharedTransaction()};
|
|
||||||
|
|
||||||
User::pointer user {User::find(context.dbSession, username)};
|
|
||||||
if (!user)
|
|
||||||
throw RequestedDataNotFoundError {};
|
|
||||||
|
|
||||||
userId = user->getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password)
|
|
||||||
{
|
|
||||||
checkSetPasswordImplemented();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Service<::Auth::IPasswordService>()->setPassword(userId, decodePasswordIfNeeded(*password));
|
|
||||||
}
|
|
||||||
catch (const Auth::PasswordMustMatchLoginNameException&)
|
|
||||||
{
|
|
||||||
throw PasswordMustMatchLoginNameGenericError {};
|
|
||||||
}
|
|
||||||
catch (const Auth::PasswordTooWeakException&)
|
|
||||||
{
|
|
||||||
throw PasswordTooWeakGenericError {};
|
|
||||||
}
|
|
||||||
catch (const Auth::Exception&)
|
|
||||||
{
|
|
||||||
throw UserNotAuthorizedError {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response::createOkResponse(context.serverProtocolVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
static
|
static
|
||||||
Response
|
Response
|
||||||
handleUpdatePlaylistRequest(RequestContext& context)
|
handleUpdatePlaylistRequest(RequestContext& context)
|
||||||
@@ -1554,7 +1300,7 @@ handleGetCoverArt(RequestContext& context, const Wt::Http::Request& /*request*/,
|
|||||||
throw BadParameterGenericError {"id"};
|
throw BadParameterGenericError {"id"};
|
||||||
|
|
||||||
std::size_t size {getParameterAs<std::size_t>(context.parameters, "size").value_or(1024)};
|
std::size_t size {getParameterAs<std::size_t>(context.parameters, "size").value_or(1024)};
|
||||||
size = Utils::clamp(size, std::size_t {32}, std::size_t {2048});
|
size = ::Utils::clamp(size, std::size_t {32}, std::size_t {2048});
|
||||||
|
|
||||||
std::shared_ptr<Image::IEncodedImage> cover;
|
std::shared_ptr<Image::IEncodedImage> cover;
|
||||||
if (trackId)
|
if (trackId)
|
||||||
@@ -1663,10 +1409,10 @@ static const std::unordered_map<std::string_view, RequestEntryPointInfo> request
|
|||||||
// User management
|
// User management
|
||||||
{"/getUser", {handleGetUserRequest}},
|
{"/getUser", {handleGetUserRequest}},
|
||||||
{"/getUsers", {handleGetUsersRequest, {UserType::ADMIN}}},
|
{"/getUsers", {handleGetUsersRequest, {UserType::ADMIN}}},
|
||||||
{"/createUser", {handleCreateUserRequest, {UserType::ADMIN}, &checkSetPasswordImplemented}},
|
{"/createUser", {handleCreateUserRequest, {UserType::ADMIN}, &Utils::checkSetPasswordImplemented}},
|
||||||
{"/updateUser", {handleUpdateUserRequest, {UserType::ADMIN}}},
|
{"/updateUser", {handleUpdateUserRequest, {UserType::ADMIN}}},
|
||||||
{"/deleteUser", {handleDeleteUserRequest, {UserType::ADMIN}}},
|
{"/deleteUser", {handleDeleteUserRequest, {UserType::ADMIN}}},
|
||||||
{"/changePassword", {handleChangePassword, {UserType::REGULAR, UserType::ADMIN}, &checkSetPasswordImplemented}},
|
{"/changePassword", {handleChangePassword, {UserType::REGULAR, UserType::ADMIN}, &Utils::checkSetPasswordImplemented}},
|
||||||
|
|
||||||
// Bookmarks
|
// Bookmarks
|
||||||
{"/getBookmarks", {handleGetBookmarks}},
|
{"/getBookmarks", {handleGetBookmarks}},
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2023 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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "Utils.hpp"
|
||||||
|
|
||||||
|
#include "utils/Service.hpp"
|
||||||
|
#include "services/auth/IPasswordService.hpp"
|
||||||
|
#include "SubsonicResponse.hpp"
|
||||||
|
|
||||||
|
namespace API::Subsonic::Utils
|
||||||
|
{
|
||||||
|
void checkSetPasswordImplemented()
|
||||||
|
{
|
||||||
|
Auth::IPasswordService* passwordService{ Service<Auth::IPasswordService>::get() };
|
||||||
|
if (!passwordService || !passwordService->canSetPasswords())
|
||||||
|
throw NotImplementedGenericError{};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2023 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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace API::Subsonic::Utils
|
||||||
|
{
|
||||||
|
void checkSetPasswordImplemented();
|
||||||
|
}
|
||||||
@@ -19,8 +19,6 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include "RequestContext.hpp"
|
#include "RequestContext.hpp"
|
||||||
#include "SubsonicResponse.hpp"
|
#include "SubsonicResponse.hpp"
|
||||||
|
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
#include "UserManagement.hpp"
|
||||||
|
|
||||||
|
#include "services/database/Session.hpp"
|
||||||
|
#include "services/database/User.hpp"
|
||||||
|
#include "services/auth/IPasswordService.hpp"
|
||||||
|
#include "utils/Service.hpp"
|
||||||
|
#include "responses/User.hpp"
|
||||||
|
#include "ParameterParsing.hpp"
|
||||||
|
#include "Utils.hpp"
|
||||||
|
|
||||||
|
namespace API::Subsonic
|
||||||
|
{
|
||||||
|
using namespace Database;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
void checkUserIsMySelfOrAdmin(RequestContext& context, const std::string& username)
|
||||||
|
{
|
||||||
|
User::pointer currentUser{ User::find(context.dbSession, context.userId) };
|
||||||
|
if (!currentUser)
|
||||||
|
throw RequestedDataNotFoundError{};
|
||||||
|
|
||||||
|
if (currentUser->getLoginName() != username && !currentUser->isAdmin())
|
||||||
|
throw UserNotAuthorizedError{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Response handleGetUserRequest(RequestContext& context)
|
||||||
|
{
|
||||||
|
std::string username{ getMandatoryParameterAs<std::string>(context.parameters, "username") };
|
||||||
|
|
||||||
|
auto transaction{ context.dbSession.createSharedTransaction() };
|
||||||
|
|
||||||
|
checkUserIsMySelfOrAdmin(context, username);
|
||||||
|
|
||||||
|
const User::pointer user{ User::find(context.dbSession, username) };
|
||||||
|
if (!user)
|
||||||
|
throw RequestedDataNotFoundError{};
|
||||||
|
|
||||||
|
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
|
||||||
|
response.addNode("user", createUserNode(user));
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
Response handleGetUsersRequest(RequestContext& context)
|
||||||
|
{
|
||||||
|
auto transaction{ context.dbSession.createSharedTransaction() };
|
||||||
|
|
||||||
|
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
|
||||||
|
Response::Node& usersNode{ response.createNode("users") };
|
||||||
|
|
||||||
|
const auto userIds{ User::find(context.dbSession, User::FindParameters {}) };
|
||||||
|
for (const UserId userId : userIds.results)
|
||||||
|
{
|
||||||
|
const User::pointer user{ User::find(context.dbSession, userId) };
|
||||||
|
usersNode.addArrayChild("user", createUserNode(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
Response handleCreateUserRequest(RequestContext& context)
|
||||||
|
{
|
||||||
|
std::string username{ getMandatoryParameterAs<std::string>(context.parameters, "username") };
|
||||||
|
std::string password{ decodePasswordIfNeeded(getMandatoryParameterAs<std::string>(context.parameters, "password")) };
|
||||||
|
// Just ignore all the other fields as we don't handle them
|
||||||
|
|
||||||
|
Database::UserId userId;
|
||||||
|
{
|
||||||
|
auto transaction{ context.dbSession.createUniqueTransaction() };
|
||||||
|
|
||||||
|
User::pointer user{ User::find(context.dbSession, username) };
|
||||||
|
if (user)
|
||||||
|
throw UserAlreadyExistsGenericError{};
|
||||||
|
|
||||||
|
user = context.dbSession.create<User>(username);
|
||||||
|
userId = user->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto removeCreatedUser{ [&]()
|
||||||
|
{
|
||||||
|
auto transaction {context.dbSession.createUniqueTransaction()};
|
||||||
|
User::pointer user {User::find(context.dbSession, userId)};
|
||||||
|
if (user)
|
||||||
|
user.remove();
|
||||||
|
} };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Service<Auth::IPasswordService>::get()->setPassword(userId, password);
|
||||||
|
}
|
||||||
|
catch (const Auth::PasswordMustMatchLoginNameException&)
|
||||||
|
{
|
||||||
|
removeCreatedUser();
|
||||||
|
throw PasswordMustMatchLoginNameGenericError{};
|
||||||
|
}
|
||||||
|
catch (const Auth::PasswordTooWeakException&)
|
||||||
|
{
|
||||||
|
removeCreatedUser();
|
||||||
|
throw PasswordTooWeakGenericError{};
|
||||||
|
}
|
||||||
|
catch (const Auth::Exception& exception)
|
||||||
|
{
|
||||||
|
removeCreatedUser();
|
||||||
|
throw UserNotAuthorizedError{};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::createOkResponse(context.serverProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
Response handleDeleteUserRequest(RequestContext& context)
|
||||||
|
{
|
||||||
|
std::string username{ getMandatoryParameterAs<std::string>(context.parameters, "username") };
|
||||||
|
|
||||||
|
auto transaction{ context.dbSession.createUniqueTransaction() };
|
||||||
|
|
||||||
|
User::pointer user{ User::find(context.dbSession, username) };
|
||||||
|
if (!user)
|
||||||
|
throw RequestedDataNotFoundError{};
|
||||||
|
|
||||||
|
// cannot delete ourself
|
||||||
|
if (user->getId() == context.userId)
|
||||||
|
throw UserNotAuthorizedError{};
|
||||||
|
|
||||||
|
user.remove();
|
||||||
|
|
||||||
|
return Response::createOkResponse(context.serverProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
Response handleUpdateUserRequest(RequestContext& context)
|
||||||
|
{
|
||||||
|
std::string username{ getMandatoryParameterAs<std::string>(context.parameters, "username") };
|
||||||
|
std::optional<std::string> password{ getParameterAs<std::string>(context.parameters, "password") };
|
||||||
|
|
||||||
|
UserId userId;
|
||||||
|
{
|
||||||
|
auto transaction{ context.dbSession.createSharedTransaction() };
|
||||||
|
|
||||||
|
User::pointer user{ User::find(context.dbSession, username) };
|
||||||
|
if (!user)
|
||||||
|
throw RequestedDataNotFoundError{};
|
||||||
|
|
||||||
|
userId = user->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password)
|
||||||
|
{
|
||||||
|
Utils::checkSetPasswordImplemented();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Service<::Auth::IPasswordService>()->setPassword(userId, decodePasswordIfNeeded(*password));
|
||||||
|
}
|
||||||
|
catch (const Auth::PasswordMustMatchLoginNameException&)
|
||||||
|
{
|
||||||
|
throw PasswordMustMatchLoginNameGenericError{};
|
||||||
|
}
|
||||||
|
catch (const Auth::PasswordTooWeakException&)
|
||||||
|
{
|
||||||
|
throw PasswordTooWeakGenericError{};
|
||||||
|
}
|
||||||
|
catch (const Auth::Exception&)
|
||||||
|
{
|
||||||
|
throw UserNotAuthorizedError{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::createOkResponse(context.serverProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
Response handleChangePassword(RequestContext& context)
|
||||||
|
{
|
||||||
|
std::string username{ getMandatoryParameterAs<std::string>(context.parameters, "username") };
|
||||||
|
std::string password{ decodePasswordIfNeeded(getMandatoryParameterAs<std::string>(context.parameters, "password")) };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Database::UserId userId;
|
||||||
|
{
|
||||||
|
auto transaction{ context.dbSession.createSharedTransaction() };
|
||||||
|
|
||||||
|
checkUserIsMySelfOrAdmin(context, username);
|
||||||
|
|
||||||
|
User::pointer user{ User::find(context.dbSession, username) };
|
||||||
|
if (!user)
|
||||||
|
throw UserNotAuthorizedError{};
|
||||||
|
|
||||||
|
userId = user->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
Service<Auth::IPasswordService>::get()->setPassword(userId, password);
|
||||||
|
}
|
||||||
|
catch (const Auth::PasswordMustMatchLoginNameException&)
|
||||||
|
{
|
||||||
|
throw PasswordMustMatchLoginNameGenericError{};
|
||||||
|
}
|
||||||
|
catch (const Auth::PasswordTooWeakException&)
|
||||||
|
{
|
||||||
|
throw PasswordTooWeakGenericError{};
|
||||||
|
}
|
||||||
|
catch (const Auth::Exception& authException)
|
||||||
|
{
|
||||||
|
throw UserNotAuthorizedError{};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::createOkResponse(context.serverProtocolVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2023 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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "RequestContext.hpp"
|
||||||
|
#include "SubsonicResponse.hpp"
|
||||||
|
|
||||||
|
namespace API::Subsonic
|
||||||
|
{
|
||||||
|
Response handleGetUserRequest(RequestContext& context);
|
||||||
|
Response handleGetUsersRequest(RequestContext& context);
|
||||||
|
Response handleCreateUserRequest(RequestContext& context);
|
||||||
|
Response handleUpdateUserRequest(RequestContext& context);
|
||||||
|
Response handleDeleteUserRequest(RequestContext& context);
|
||||||
|
Response handleChangePassword(RequestContext& context);
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ namespace API::Subsonic
|
|||||||
}
|
}
|
||||||
else if (!artists.empty())
|
else if (!artists.empty())
|
||||||
{
|
{
|
||||||
albumNode.setAttribute("artist", utils::joinArtistNames(artists));
|
albumNode.setAttribute("artist", Utils::joinArtistNames(artists));
|
||||||
|
|
||||||
if (artists.size() == 1)
|
if (artists.size() == 1)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ namespace API::Subsonic
|
|||||||
|
|
||||||
using namespace Database;
|
using namespace Database;
|
||||||
|
|
||||||
namespace utils
|
namespace Utils
|
||||||
{
|
{
|
||||||
std::string joinArtistNames(const std::vector<Artist::pointer>& artists)
|
std::string joinArtistNames(const std::vector<Artist::pointer>& artists)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ namespace Database
|
|||||||
|
|
||||||
namespace API::Subsonic
|
namespace API::Subsonic
|
||||||
{
|
{
|
||||||
namespace utils
|
namespace Utils
|
||||||
{
|
{
|
||||||
std::string joinArtistNames(const std::vector<Database::ObjectPtr<Database::Artist>>& artists);
|
std::string joinArtistNames(const std::vector<Database::ObjectPtr<Database::Artist>>& artists);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ namespace API::Subsonic
|
|||||||
const std::vector<Artist::pointer>& artists{ track->getArtists({TrackArtistLinkType::Artist}) };
|
const std::vector<Artist::pointer>& artists{ track->getArtists({TrackArtistLinkType::Artist}) };
|
||||||
if (!artists.empty())
|
if (!artists.empty())
|
||||||
{
|
{
|
||||||
trackResponse.setAttribute("artist", utils::joinArtistNames(artists));
|
trackResponse.setAttribute("artist", Utils::joinArtistNames(artists));
|
||||||
|
|
||||||
if (artists.size() == 1)
|
if (artists.size() == 1)
|
||||||
trackResponse.setAttribute("artistId", idToString(artists.front()->getId()));
|
trackResponse.setAttribute("artistId", idToString(artists.front()->getId()));
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2023 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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "responses/User.hpp"
|
||||||
|
|
||||||
|
#include "services/database/User.hpp"
|
||||||
|
|
||||||
|
namespace API::Subsonic
|
||||||
|
{
|
||||||
|
using namespace Database;
|
||||||
|
|
||||||
|
Response::Node createUserNode(const User::pointer& user)
|
||||||
|
{
|
||||||
|
Response::Node userNode;
|
||||||
|
|
||||||
|
userNode.setAttribute("username", user->getLoginName());
|
||||||
|
userNode.setAttribute("scrobblingEnabled", true);
|
||||||
|
userNode.setAttribute("adminRole", user->isAdmin());
|
||||||
|
userNode.setAttribute("settingsRole", true);
|
||||||
|
userNode.setAttribute("downloadRole", true);
|
||||||
|
userNode.setAttribute("uploadRole", false);
|
||||||
|
userNode.setAttribute("playlistRole", true);
|
||||||
|
userNode.setAttribute("coverArtRole", false);
|
||||||
|
userNode.setAttribute("commentRole", false);
|
||||||
|
userNode.setAttribute("podcastRole", false);
|
||||||
|
userNode.setAttribute("streamRole", true);
|
||||||
|
userNode.setAttribute("jukeboxRole", false);
|
||||||
|
userNode.setAttribute("shareRole", false);
|
||||||
|
|
||||||
|
Response::Node folder;
|
||||||
|
folder.setValue("0");
|
||||||
|
userNode.addArrayChild("folder", std::move(folder));
|
||||||
|
|
||||||
|
return userNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2023 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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "services/database/Object.hpp"
|
||||||
|
#include "SubsonicResponse.hpp"
|
||||||
|
|
||||||
|
namespace Database
|
||||||
|
{
|
||||||
|
class User;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace API::Subsonic
|
||||||
|
{
|
||||||
|
Response::Node createUserNode(const Database::ObjectPtr<Database::User>& user);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user