diff --git a/src/libs/database/CMakeLists.txt b/src/libs/database/CMakeLists.txt index ca023ffb..6360c809 100644 --- a/src/libs/database/CMakeLists.txt +++ b/src/libs/database/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(lmsdatabase STATIC impl/objects/RatedTrack.cpp impl/objects/Release.cpp impl/objects/ScanSettings.cpp + impl/objects/ServerInfo.cpp impl/objects/StarredArtist.cpp impl/objects/StarredRelease.cpp impl/objects/StarredTrack.cpp diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index 2611dac0..eec0d484 100644 --- a/src/libs/database/impl/Migration.cpp +++ b/src/libs/database/impl/Migration.cpp @@ -36,7 +36,7 @@ namespace lms::db { namespace { - static constexpr Version LMS_DATABASE_VERSION{ 109 }; + static constexpr Version LMS_DATABASE_VERSION{ 110 }; } VersionInfo::VersionInfo() @@ -1956,6 +1956,16 @@ CREATE TABLE IF NOT EXISTS "track_movement" ( utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET audio_scan_version = audio_scan_version + 1"); } + void migrateFromV109(Session& session) + { + utils::executeCommand(*session.getDboSession(), R"( +CREATE TABLE IF NOT EXISTS "server_info" ( + "id" integer primary key autoincrement, + "version" integer not null, + "instance_id" blob not null +))"); + } + bool doDbMigration(Session& session) { constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" }; @@ -2041,6 +2051,7 @@ CREATE TABLE IF NOT EXISTS "track_movement" ( { 106, migrateFromV106 }, { 107, migrateFromV107 }, { 108, migrateFromV108 }, + { 109, migrateFromV109 }, }; LMS_SCOPED_TRACE_OVERVIEW("Database", "Migration"); diff --git a/src/libs/database/impl/Session.cpp b/src/libs/database/impl/Session.cpp index a38822e6..a72f6565 100644 --- a/src/libs/database/impl/Session.cpp +++ b/src/libs/database/impl/Session.cpp @@ -49,6 +49,7 @@ #include "database/objects/Release.hpp" #include "database/objects/ReleaseArtistLink.hpp" #include "database/objects/ScanSettings.hpp" +#include "database/objects/ServerInfo.hpp" #include "database/objects/StarredArtist.hpp" #include "database/objects/StarredRelease.hpp" #include "database/objects/StarredTrack.hpp" @@ -125,6 +126,7 @@ namespace lms::db _session.mapClass("ui_state"); _session.mapClass("work"); _session.mapClass("user"); + _session.mapClass("server_info"); _session.mapClass("version_info"); } @@ -191,6 +193,13 @@ namespace lms::db create().modify()->setRecommendationEngineType(defaultRecommendationEngineType); } + void Session::createServerInfoIfNeeded() + { + auto uniqueTransaction{ createWriteTransaction() }; + + ServerInfo::getOrCreate(*this); + } + void Session::createIndexesIfNeeded() { LMS_SCOPED_TRACE_OVERVIEW("Database", "IndexCreation"); diff --git a/src/libs/database/impl/objects/ServerInfo.cpp b/src/libs/database/impl/objects/ServerInfo.cpp new file mode 100644 index 00000000..cffa8dcb --- /dev/null +++ b/src/libs/database/impl/objects/ServerInfo.cpp @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2026 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 "database/objects/ServerInfo.hpp" + +#include +#include + +#include "database/Session.hpp" + +#include "Utils.hpp" +#include "traits/UUIDTraits.hpp" + +DBO_INSTANTIATE_TEMPLATES(lms::db::ServerInfo) + +namespace lms::db +{ + ServerInfo::ServerInfo(core::UUID instanceId) + : _instanceId{ instanceId } + { + } + + ServerInfo::pointer ServerInfo::getOrCreate(Session& session) + { + session.checkWriteTransaction(); + + pointer serverInfo{ utils::fetchQuerySingleResult(session.getDboSession()->find()) }; + if (!serverInfo) + return session.getDboSession()->add(std::unique_ptr{ new ServerInfo{ core::UUID::generate() } }); + + return serverInfo; + } + + ServerInfo::pointer ServerInfo::get(Session& session) + { + session.checkReadTransaction(); + + return utils::fetchQuerySingleResult(session.getDboSession()->find()); + } +} // namespace lms::db diff --git a/src/libs/database/include/database/Session.hpp b/src/libs/database/include/database/Session.hpp index 47bd2125..ac6ebd32 100644 --- a/src/libs/database/include/database/Session.hpp +++ b/src/libs/database/include/database/Session.hpp @@ -60,6 +60,7 @@ namespace lms::db void prepareTablesIfNeeded(); // need to run only once at startup bool migrateSchemaIfNeeded(); // returns true if migration was performed void createScanSettingsIfNeeded(RecommendationEngineType defaultRecommendationEngineType = RecommendationEngineType::Clusters); + void createServerInfoIfNeeded(); void createIndexesIfNeeded(); void vacuumIfNeeded(); void vacuum(); diff --git a/src/libs/database/include/database/objects/ServerInfo.hpp b/src/libs/database/include/database/objects/ServerInfo.hpp new file mode 100644 index 00000000..8b1005c2 --- /dev/null +++ b/src/libs/database/include/database/objects/ServerInfo.hpp @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2026 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 "core/UUID.hpp" + +namespace lms::db +{ + class Session; + + // Singleton row holding server-level metadata (not tied to any particular schema version) + class ServerInfo + { + public: + using pointer = Wt::Dbo::ptr; + + ServerInfo() = default; + + static pointer getOrCreate(Session& session); + static pointer get(Session& session); + + core::UUID getInstanceId() const { return _instanceId; } + + template + void persist(Action& a) + { + Wt::Dbo::field(a, _instanceId, "instance_id"); + } + + private: + explicit ServerInfo(core::UUID instanceId); + + core::UUID _instanceId; + }; +} // namespace lms::db diff --git a/src/libs/database/test/Migration.cpp b/src/libs/database/test/Migration.cpp index c1be13d2..d1484076 100644 --- a/src/libs/database/test/Migration.cpp +++ b/src/libs/database/test/Migration.cpp @@ -37,6 +37,7 @@ #include "database/objects/RatedTrack.hpp" #include "database/objects/ReleaseArtistLink.hpp" #include "database/objects/ScanSettings.hpp" +#include "database/objects/ServerInfo.hpp" #include "database/objects/StarredArtist.hpp" #include "database/objects/StarredRelease.hpp" #include "database/objects/StarredTrack.hpp" @@ -345,6 +346,7 @@ VALUES // Now perform full migration db.getTLSSession().migrateSchemaIfNeeded(); db.getTLSSession().createScanSettingsIfNeeded(); + db.getTLSSession().createServerInfoIfNeeded(); // Now perform some dummy finds to ensure all fields are correctly mapped { @@ -374,6 +376,7 @@ VALUES EXPECT_FALSE(ReleaseArtistLink::find(session, ReleaseArtistLinkId{})); EXPECT_FALSE(ReleaseType::find(session, ReleaseTypeId{})); EXPECT_FALSE(ScanSettings::find(session, ScanSettingsId{})); + EXPECT_NE(ServerInfo::get(session)->getInstanceId(), core::UUID{}); EXPECT_FALSE(StarredArtist::find(session, StarredArtistId{})); EXPECT_FALSE(StarredRelease::find(session, StarredReleaseId{})); EXPECT_FALSE(StarredTrack::find(session, StarredTrackId{})); diff --git a/src/lms/main.cpp b/src/lms/main.cpp index 3fd5fff4..82b7b04e 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -34,11 +34,13 @@ #include "core/Service.hpp" #include "core/String.hpp" #include "core/SystemPaths.hpp" +#include "core/UUID.hpp" #include "audio/IAudioOutput.hpp" #include "audio/IMusicNNEmbeddingExtractor.hpp" #include "database/IDb.hpp" #include "database/Session.hpp" +#include "database/objects/ServerInfo.hpp" #include "database/profiling/IQueryProfiler.hpp" #include "image/Image.hpp" #include "services/artwork/IArtworkService.hpp" @@ -111,6 +113,12 @@ namespace lms throw core::LmsException{ "Invalid config value for 'jukebox-audio-backend'" }; } + core::UUID getServerInstanceId(db::Session& session) + { + auto transaction{ session.createReadTransaction() }; + return db::ServerInfo::get(session)->getInstanceId(); + } + std::error_code checkDirectoryAccessible(const std::filesystem::path& dir) { std::error_code ec; @@ -433,13 +441,17 @@ namespace lms // Connection pool size must be twice the number of threads: we have at least 2 io pools with getThreadCount() each and they all may access the database auto database{ db::createDb(config->getPath("working-dir", "/var/lms") / "lms.db", getThreadCount() * 2) }; + core::UUID serverInstanceId; { db::Session session{ *database }; session.prepareTablesIfNeeded(); bool migrationPerformed{ session.migrateSchemaIfNeeded() }; session.createScanSettingsIfNeeded(audio::canExtractMusicNNEmbeddings() ? db::RecommendationEngineType::AudioSimilarity : db::RecommendationEngineType::Clusters); + session.createServerInfoIfNeeded(); session.createIndexesIfNeeded(); + serverInstanceId = getServerInstanceId(session); + // As this may be quite long, we only do it during startup if (migrationPerformed) session.vacuum(); @@ -517,8 +529,8 @@ namespace lms // bind UI entry point server.addEntryPoint(Wt::EntryPointType::Application, - [&database, &appManager, uiAuthenticationBackend](const Wt::WEnvironment& env) { - return ui::LmsApplication::create(env, *database, appManager, uiAuthenticationBackend); + [&database, &appManager, uiAuthenticationBackend, serverInstanceId](const Wt::WEnvironment& env) { + return ui::LmsApplication::create(env, *database, appManager, uiAuthenticationBackend, serverInstanceId); }); proxyScannerEventsToApplication(*scannerService, server); diff --git a/src/lms/ui/Auth.cpp b/src/lms/ui/Auth.cpp index f8cfb114..c2dc5c6e 100644 --- a/src/lms/ui/Auth.cpp +++ b/src/lms/ui/Auth.cpp @@ -44,16 +44,26 @@ namespace lms::ui namespace { static constexpr core::LiteralString authTokenDomain{ "ui" }; - static const std::string authCookieName{ "LmsAuth" }; - static const std::string authCookieSalt{ Wt::Auth::SHA1HashFunction{}.compute(authCookieName, authTokenDomain.c_str()) }; // changing this will invalidate existing tokens + + // Scoped per-instance so several LMS instances on the same host don't collide + std::string getAuthCookieName() + { + return "LmsAuth-" + LmsApp->getServerInstanceId().toString(); + } + + // changing the instance id, or this salt computation, invalidates existing "remember me" tokens + std::string getAuthCookieSalt() + { + return Wt::Auth::SHA1HashFunction{}.compute(LmsApp->getServerInstanceId().toString(), authTokenDomain.c_str()); + } void createAuthToken(db::UserId userId, const Wt::WDateTime& expiry) { const std::string authCookie{ Wt::WRandom::generateId(64) }; - const std::string hashedAuthCookie{ Wt::Auth::SHA1HashFunction{}.compute(authCookie, authCookieSalt) }; + const std::string hashedAuthCookie{ Wt::Auth::SHA1HashFunction{}.compute(authCookie, getAuthCookieSalt()) }; core::Service::get()->createAuthToken(authTokenDomain, userId, hashedAuthCookie); - LmsApp->setCookie(authCookieName, + LmsApp->setCookie(getAuthCookieName(), authCookie, expiry.toTime_t() - Wt::WDateTime::currentDateTime().toTime_t(), "", @@ -147,18 +157,18 @@ namespace lms::ui db::UserId processAuthToken(const Wt::WEnvironment& env) { - const std::string* authCookie{ env.getCookie(authCookieName) }; + const std::string* authCookie{ env.getCookie(getAuthCookieName()) }; if (!authCookie) return db::UserId{}; - const std::string hashedCookie{ Wt::Auth::SHA1HashFunction{}.compute(*authCookie, authCookieSalt) }; + const std::string hashedCookie{ Wt::Auth::SHA1HashFunction{}.compute(*authCookie, getAuthCookieSalt()) }; const auto res{ core::Service::get()->processAuthToken(authTokenDomain, boost::asio::ip::make_address(env.clientAddress()), hashedCookie) }; switch (res.state) { case auth::IAuthTokenService::AuthTokenProcessResult::State::Denied: case auth::IAuthTokenService::AuthTokenProcessResult::State::Throttled: - LmsApp->setCookie(authCookieName, std::string{}, 0, "", "", env.urlScheme() == "https"); + LmsApp->setCookie(getAuthCookieName(), std::string{}, 0, "", "", env.urlScheme() == "https"); return db::UserId{}; case auth::IAuthTokenService::AuthTokenProcessResult::State::Granted: diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp index a2523e88..df480d05 100644 --- a/src/lms/ui/LmsApplication.cpp +++ b/src/lms/ui/LmsApplication.cpp @@ -145,9 +145,9 @@ namespace lms::ui } // namespace - std::unique_ptr LmsApplication::create(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend) + std::unique_ptr LmsApplication::create(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend, const core::UUID& serverInstanceId) { - return std::make_unique(env, db, appManager, authBackend); + return std::make_unique(env, db, appManager, authBackend, serverInstanceId); } LmsApplication* LmsApplication::instance() @@ -155,11 +155,12 @@ namespace lms::ui return static_cast(Wt::WApplication::instance()); } - LmsApplication::LmsApplication(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend) + LmsApplication::LmsApplication(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend, const core::UUID& serverInstanceId) : Wt::WApplication{ env } , _db{ db } , _appManager{ appManager } , _authBackend{ authBackend } + , _serverInstanceId{ serverInstanceId } , _areDownloadsEnabled(core::Service::get()->getBool("ui-allow-downloads", true)) { try diff --git a/src/lms/ui/LmsApplication.hpp b/src/lms/ui/LmsApplication.hpp index 3294498c..54762743 100644 --- a/src/lms/ui/LmsApplication.hpp +++ b/src/lms/ui/LmsApplication.hpp @@ -25,6 +25,8 @@ #include +#include "core/UUID.hpp" + #include "database/Object.hpp" #include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" @@ -53,10 +55,10 @@ namespace lms::ui class LmsApplication : public Wt::WApplication { public: - LmsApplication(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend); - ~LmsApplication(); + LmsApplication(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend, const core::UUID& serverInstanceId); + ~LmsApplication() override; - static std::unique_ptr create(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend); + static std::unique_ptr create(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend, const core::UUID& serverInstanceId); static LmsApplication* instance(); // Session application data @@ -76,6 +78,7 @@ namespace lms::ui scanner::Events& getScannerEvents() { return _scannerEvents; } AuthenticationBackend getAuthBackend() const { return _authBackend; } + core::UUID getServerInstanceId() const { return _serverInstanceId; } // Utils static void post(const std::string& sessionId, const std::function& func); @@ -112,6 +115,7 @@ namespace lms::ui Wt::Signal<> _preQuit; LmsApplicationManager& _appManager; const AuthenticationBackend _authBackend; + const core::UUID _serverInstanceId; const bool _areDownloadsEnabled; scanner::Events _scannerEvents; struct UserAuthInfo