Merge branch 'develop' for release v3.61.0
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
Checks: '-*,bugprone-*,concurrency-*,cppcoreguidelines-*,misc-*,-misc-include-cleaner,-misc-no-recursion,performance*,portability-*,readability-*,-readability-braces-around-statements,-readability-identifier-length,-readability-implicit-bool-conversion'
|
||||
WarningsAsErrors: ''
|
||||
HeaderFilterRegex: ''
|
||||
AnalyzeTemporaryDtors: false
|
||||
FormatStyle: none
|
||||
User: ''
|
||||
CheckOptions:
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ FROM alpine:3.19 AS build
|
||||
|
||||
WORKDIR /tmp/workdir
|
||||
|
||||
ARG PREFIX="/tmp/install"
|
||||
ENV PREFIX="/tmp/install"
|
||||
|
||||
ARG BUILD_PACKAGES=" \
|
||||
ca-certificates \
|
||||
@@ -118,7 +118,7 @@ COPY . /tmp/lms/
|
||||
RUN \
|
||||
DIR=/tmp/lms/build && mkdir -p ${DIR} && cd ${DIR} && \
|
||||
PKG_CONFIG_PATH=/tmp/install/lib/pkgconfig CXXFLAGS="-I${PREFIX}/include" LDFLAGS="-L${PREFIX}/lib -Wl,--rpath-link=${PREFIX}/lib" cmake /tmp/lms/ -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${PREFIX} -DCMAKE_PREFIX_PATH=${PREFIX} && \
|
||||
LD_LIBRARY_PATH=${PREFIX}/lib VERBOSE=1 make -j$(nproc) && \
|
||||
LD_LIBRARY_PATH=${PREFIX}/lib make -j$(nproc) && \
|
||||
LD_LIBRARY_PATH=${PREFIX}/lib make test && \
|
||||
make install && \
|
||||
mkdir -p ${PREFIX}/etc/ && \
|
||||
|
||||
+13
-3
@@ -1,16 +1,24 @@
|
||||
# Subsonic API
|
||||
The API version implemented is 1.16.0 and has been tested on _Android_ using _Subsonic Player_, _Ultrasonic_, _Symfonium_, and _DSub_.
|
||||
The API version implemented is 1.16.0 and has been tested on _Android_ using _DSub_, _Subsonic Player_, _Symfonium_, _Tempo_ and _Ultrasonic_.
|
||||
|
||||
Folder navigation commands are supported. However, since _LMS_ does not store information for each folder, it is not possible to star/unstar folders considered as artists.
|
||||
Given the API limitations of folder navigation commands, it is recommended to place all tracks of an album in the same folder and not to mix multiple albums in the same folder.
|
||||
|
||||
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. You may need to check your client to make sure to use the __password__ authentication method. Since logins/passwords are passed in plain text through URLs, it is highly recommended to use a unique password when using the Subsonic API. Note that this may affect the use of authentication via PAM. In any case, ensure the web server logs (and proxy logs, if applicable) are properly secured.
|
||||
|
||||
# OpenSubsonic API
|
||||
OpenSubsonic is an initiative to patch and extend the legacy Subsonic API. You'll find more details in the [official documentation](https://opensubsonic.netlify.app/)
|
||||
|
||||
## Authentication
|
||||
_LMS_ supports the [API Key Authentication](https://opensubsonic.netlify.app/docs/extensions/apikeyauth/) method. Each user has to generate their own API key on the settings page to use the Subsonic API.
|
||||
|
||||
By default, API keys can also be used as passwords, provided the `user` parameter matches the API key owner. To disable this fallback authentication method, set the following in `lms.conf`:
|
||||
```
|
||||
api-subsonic-support-user-password-auth = false;
|
||||
```
|
||||
|
||||
__Note__: the token+salt authentication method is not supported.
|
||||
|
||||
## Extra fields
|
||||
The following extra fields are implemented:
|
||||
* `Album` response:
|
||||
@@ -50,5 +58,7 @@ The following extra fields are implemented:
|
||||
* `roles`
|
||||
|
||||
## Supported extensions
|
||||
* [API Key Authentication](https://opensubsonic.netlify.app/docs/extensions/apikeyauth/)
|
||||
* [HTTP form POST](https://opensubsonic.netlify.app/docs/extensions/formpost/)
|
||||
* [Transcode offset](https://opensubsonic.netlify.app/docs/extensions/transcodeoffset/)
|
||||
* [Song Lyrics](https://opensubsonic.netlify.app/docs/extensions/songlyrics/)
|
||||
|
||||
+10
-7
@@ -289,7 +289,7 @@
|
||||
<!--Settings-->
|
||||
<message id="Lms.Settings.artist-release-sort-method">Method to sort artist's albums</message>
|
||||
<message id="Lms.Settings.audio">Audio</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">These audio settings are local to your browser!</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">These audio settings are local to your browser</message>
|
||||
<message id="Lms.Settings.backend.internal">Internal</message>
|
||||
<message id="Lms.Settings.backend.listenbrainz">ListenBrainz</message>
|
||||
<message id="Lms.Settings.backend.listenbrainz-token">ListenBrainz API token</message>
|
||||
@@ -307,6 +307,7 @@
|
||||
<message id="Lms.Settings.original-date-desc">Original Release Date (Descending)</message>
|
||||
<message id="Lms.Settings.password-bad">Bad password</message>
|
||||
<message id="Lms.Settings.password-must-fill-old-password">Old password must be filled in</message>
|
||||
<message id="Lms.Settings.regen-token">Generate</message>
|
||||
<message id="Lms.Settings.replaygain-mode">ReplayGain mode</message>
|
||||
<message id="Lms.Settings.replaygain-mode.none">No ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.auto">Auto</message>
|
||||
@@ -317,13 +318,19 @@
|
||||
<message id="Lms.Settings.scrobbling">Scrobbling</message>
|
||||
<message id="Lms.Settings.services">Services</message>
|
||||
<message id="Lms.Settings.settings">Settings</message>
|
||||
<message id="Lms.Settings.settings-saved">New settings saved!</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode">Artist list mode</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.all-artists">All artists</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.release-artists">Album artists</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.track-artists">Track artists</message>
|
||||
<message id="Lms.Settings.subsonic-api">Subsonic API</message>
|
||||
<message id="Lms.Settings.user-interface">User Interface</message>
|
||||
<message id="Lms.Settings.subsonic-token">OpenSubsonic API key</message>
|
||||
<message id="Lms.Settings.subsonic-token-usage">Use this API key as the password for clients that do not support the 'API Key Authentication' extension</message>
|
||||
<message id="Lms.Settings.transcoding">Transcoding</message>
|
||||
<message id="Lms.Settings.transcoding-mode">Enable transcoding</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">Always</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Only when format is not supported by the browser</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">Never</message>
|
||||
<message id="Lms.Settings.transcoding-output-bitrate">Transcoding output bitrate</message>
|
||||
<message id="Lms.Settings.transcoding-output-format">Transcoding output format</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.matroska_opus">Matroska/Opus</message>
|
||||
@@ -331,10 +338,6 @@
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_opus">Ogg/Opus</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_vorbis">Ogg/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.webm_vorbis">WebM/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-mode">Enable transcoding</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">Always</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Only when format is not supported by the browser</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">Never</message>
|
||||
<message id="Lms.Settings.settings-saved">New settings saved!</message>
|
||||
<message id="Lms.Settings.user-interface">User Interface</message>
|
||||
|
||||
</messages>
|
||||
|
||||
+10
-7
@@ -289,7 +289,7 @@
|
||||
<!--Settings-->
|
||||
<message id="Lms.Settings.artist-release-sort-method">Méthode pour trier les albums de l'artiste</message>
|
||||
<message id="Lms.Settings.audio">Audio</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">Ces paramètres audio sont locaux à votre navigateur !</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">Ces paramètres audio sont locaux à votre navigateur</message>
|
||||
<message id="Lms.Settings.backend.internal">Interne</message>
|
||||
<message id="Lms.Settings.backend.listenbrainz">ListenBrainz</message>
|
||||
<message id="Lms.Settings.backend.listenbrainz-token">Jeton d'API ListenBrainz</message>
|
||||
@@ -307,6 +307,7 @@
|
||||
<message id="Lms.Settings.original-date-desc">Date de sortie originale (Décroissant)</message>
|
||||
<message id="Lms.Settings.password-bad">Mauvais mot de passe</message>
|
||||
<message id="Lms.Settings.password-must-fill-old-password">L'ancien mot de passe doit être renseigné</message>
|
||||
<message id="Lms.Settings.regen-token">Regénérer</message>
|
||||
<message id="Lms.Settings.replaygain-mode">Mode ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.none">Pas de ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.auto">Automatique</message>
|
||||
@@ -317,13 +318,19 @@
|
||||
<message id="Lms.Settings.scrobbling">Scrobbling</message>
|
||||
<message id="Lms.Settings.services">Services</message>
|
||||
<message id="Lms.Settings.settings">Paramètres</message>
|
||||
<message id="Lms.Settings.settings-saved">Paramètres sauvegardés !</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode">Mode de listage des artistes</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.all-artists">Tous les artistes</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.release-artists">Tous les artistes d'album</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.track-artists">Tous les artistes de piste</message>
|
||||
<message id="Lms.Settings.subsonic-api">API Subsonic</message>
|
||||
<message id="Lms.Settings.user-interface">Interface utilisateur</message>
|
||||
<message id="Lms.Settings.subsonic-token">Clef pour l'API OpenSubsonic</message>
|
||||
<message id="Lms.Settings.subsonic-token-usage">Utilisez cette clé d'API comme mot de passe pour les clients qui ne prennent pas en charge l'extension 'API Key Authentication'</message>
|
||||
<message id="Lms.Settings.transcoding">Transcodage</message>
|
||||
<message id="Lms.Settings.transcoding-mode">Appliquer le transcodage</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">Toujours</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Seulement lorsque le format n'est pas pris en charge par votre navigateur</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">Jamais</message>
|
||||
<message id="Lms.Settings.transcoding-output-bitrate">Bitrate du transcodage</message>
|
||||
<message id="Lms.Settings.transcoding-output-format">Format du transcodage</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.matroska_opus">Matroska/Opus</message>
|
||||
@@ -331,11 +338,7 @@
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_opus">Ogg/Opus</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_vorbis">Ogg/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.webm_vorbis">WebM/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-mode">Appliquer le transcodage</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">Toujours</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Seulement lorsque le format n'est pas pris en charge par votre navigateur</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">Jamais</message>
|
||||
<message id="Lms.Settings.settings-saved">Paramètres sauvegardés !</message>
|
||||
<message id="Lms.Settings.user-interface">Interface utilisateur</message>
|
||||
|
||||
<!--Wt-->
|
||||
<message id="Wt.WDateTime.LessThanASecond">moins d'une seconde</message>
|
||||
|
||||
+12
-9
@@ -46,7 +46,7 @@
|
||||
<message id="Lms.Error.error-occurred">C'è stato un errore!</message>
|
||||
<message id="Lms.Error.go-home">Ritorna alla home</message>
|
||||
<message id="Lms.Error.release-not-found">Album non trovato</message>
|
||||
|
||||
<message id="Lms.Error.tracklist-not-found">Playlist non trovata</message>
|
||||
<message id="Lms.Error.user-not-allowed">Non sei autorizzato ad eseguire questa operazione</message>
|
||||
<message id="Lms.Error.user-not-found">Utente non trovato</message>
|
||||
|
||||
@@ -289,7 +289,7 @@
|
||||
<!--Settings-->
|
||||
<message id="Lms.Settings.artist-release-sort-method">Metodo per ordinare gli album dell'artista</message>
|
||||
<message id="Lms.Settings.audio">Audio</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">Queste impostazioni sono salvate localmente su questo dispositivo!</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">Queste impostazioni sono salvate localmente su questo dispositivo</message>
|
||||
<message id="Lms.Settings.backend.internal">Interno</message>
|
||||
<message id="Lms.Settings.backend.listenbrainz">ListenBrainz</message>
|
||||
<message id="Lms.Settings.backend.listenbrainz-token">Token API ListenBrainz</message>
|
||||
@@ -307,6 +307,7 @@
|
||||
<message id="Lms.Settings.original-date-desc">Data di uscita originale (Decrescente)</message>
|
||||
<message id="Lms.Settings.password-bad">Password errata</message>
|
||||
<message id="Lms.Settings.password-must-fill-old-password">Devi inserire la password attuale</message>
|
||||
<message id="Lms.Settings.regen-token">Genera</message>
|
||||
<message id="Lms.Settings.replaygain-mode">Modalità ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.none">No ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.auto">Automatica</message>
|
||||
@@ -317,13 +318,19 @@
|
||||
<message id="Lms.Settings.scrobbling">Scrobbling</message>
|
||||
<message id="Lms.Settings.services">Servizi</message>
|
||||
<message id="Lms.Settings.settings">Impostazioni</message>
|
||||
<message id="Lms.Settings.settings-saved">Nuove impostazioni salvate!</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode">Modalità di elencazione artisti</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.all-artists">Tutti gli artisti</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.release-artists">Artisti album</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.track-artists">Artisti tracce</message>
|
||||
<message id="Lms.Settings.subsonic-api">Subsonic API</message>
|
||||
<message id="Lms.Settings.user-interface">Interfaccia utente</message>
|
||||
<message id="Lms.Settings.subsonic-api">API Subsonic</message>
|
||||
<message id="Lms.Settings.subsonic-token">Chiave API OpenSubsonic</message>
|
||||
<message id="Lms.Settings.subsonic-token-usage">Utilizza questa chiave API come password per i client che non supportano l'estensione 'Autenticazione con chiave API'</message>
|
||||
<message id="Lms.Settings.transcoding">Transcodifica</message>
|
||||
<message id="Lms.Settings.transcoding-mode">Abilita transcodifica</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">Sempre</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Quando il formato non è supportato</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">Mai</message>
|
||||
<message id="Lms.Settings.transcoding-output-bitrate">Bitrate transcodifica</message>
|
||||
<message id="Lms.Settings.transcoding-output-format">Formato di transcodifica</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.matroska_opus">Matroska/Opus</message>
|
||||
@@ -331,11 +338,7 @@
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_opus">Ogg/Opus</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_vorbis">Ogg/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.webm_vorbis">WebM/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-mode">Abilita transcodifica</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">Sempre</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Quando il formato non è supportato</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">Mai</message>
|
||||
<message id="Lms.Settings.settings-saved">Nuove impostazioni salvate!</message>
|
||||
<message id="Lms.Settings.user-interface">Interfaccia utente</message>
|
||||
|
||||
<!--Wt-->
|
||||
<message id="Wt.WDateTime.LessThanASecond">meno di un secondo</message>
|
||||
|
||||
+11
-8
@@ -315,7 +315,7 @@
|
||||
<!--Settings-->
|
||||
<message id="Lms.Settings.artist-release-sort-method">Metoda sortowania albumów artysty</message>
|
||||
<message id="Lms.Settings.audio">Dźwięk</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">Te ustawienia dotyczą wyłącznie tej przeglądarki!</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">Te ustawienia dotyczą wyłącznie tej przeglądarki</message>
|
||||
<message id="Lms.Settings.backend.internal">Wewnętrzny</message>
|
||||
<message id="Lms.Settings.backend.listenbrainz">ListenBrainz</message>
|
||||
<message id="Lms.Settings.backend.listenbrainz-token">ListenBrainz API token</message>
|
||||
@@ -333,6 +333,7 @@
|
||||
<message id="Lms.Settings.original-date-desc">Oryginalna data wydania (Malejąco)</message>
|
||||
<message id="Lms.Settings.password-bad">Błędne hasło</message>
|
||||
<message id="Lms.Settings.password-must-fill-old-password">Należy podać stare hasło</message>
|
||||
<message id="Lms.Settings.regen-token">Generuj</message>
|
||||
<message id="Lms.Settings.replaygain-mode">Tryb ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.none">Bez ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.auto">Auto</message>
|
||||
@@ -343,13 +344,19 @@
|
||||
<message id="Lms.Settings.scrobbling">Scrobbling</message>
|
||||
<message id="Lms.Settings.services">Usługi</message>
|
||||
<message id="Lms.Settings.settings">Ustawienia</message>
|
||||
<message id="Lms.Settings.settings-saved">Ustawienia zapisane!</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode">Tryb listy artystów</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.all-artists">Wszyscy artyści</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.release-artists">Artyści z albumów</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.track-artists">Artyści ze ścieżek</message>
|
||||
<message id="Lms.Settings.subsonic-api">Subsonic API</message>
|
||||
<message id="Lms.Settings.user-interface">Interfejs użytkownika</message>
|
||||
<message id="Lms.Settings.subsonic-api">API Subsonic</message>
|
||||
<message id="Lms.Settings.subsonic-token">Klucz API OpenSubsonic</message>
|
||||
<message id="Lms.Settings.subsonic-token-usage">Użyj tego klucza API jako hasła dla klientów, którzy nie obsługują rozszerzenia 'Uwierzytelnianie za pomocą klucza API'</message>
|
||||
<message id="Lms.Settings.transcoding">Transkodowanie</message>
|
||||
<message id="Lms.Settings.transcoding-mode">Aktywuj transkodowanie</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">Zawsze</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Tylko gdy przeglądarka nie wspiera formatu</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">Nigdy</message>
|
||||
<message id="Lms.Settings.transcoding-output-bitrate">Przepływność transkodowania</message>
|
||||
<message id="Lms.Settings.transcoding-output-format">Format wyjściowy transkodowania</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.matroska_opus">Matroska/Opus</message>
|
||||
@@ -357,11 +364,7 @@
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_opus">Ogg/Opus</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_vorbis">Ogg/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.webm_vorbis">WebM/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-mode">Aktywuj transkodowanie</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">Zawsze</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Tylko gdy przeglądarka nie wspiera formatu</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">Nigdy</message>
|
||||
<message id="Lms.Settings.settings-saved">Ustawienia zapisane!</message>
|
||||
<message id="Lms.Settings.user-interface">Interfejs użytkownika</message>
|
||||
|
||||
<!--Wt-->
|
||||
<message id="Wt.WDateTime.LessThanASecond">mniej niż sekunda</message>
|
||||
|
||||
@@ -289,7 +289,7 @@
|
||||
<!--Settings-->
|
||||
|
||||
<message id="Lms.Settings.audio">音频</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">这些音频设置仅保存在您的本地浏览器!</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">这些音频设置仅保存在您的本地浏览器</message>
|
||||
|
||||
|
||||
|
||||
@@ -307,6 +307,7 @@
|
||||
|
||||
<message id="Lms.Settings.password-bad">无效密码</message>
|
||||
<message id="Lms.Settings.password-must-fill-old-password">必须填写旧密码</message>
|
||||
|
||||
<message id="Lms.Settings.replaygain-mode">ReplayGain 模式</message>
|
||||
<message id="Lms.Settings.replaygain-mode.none">没有 ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.auto">自动模式</message>
|
||||
@@ -317,13 +318,19 @@
|
||||
|
||||
|
||||
|
||||
<message id="Lms.Settings.settings-saved">新设置已保存!</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode">歌手列表模式</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.all-artists">所有歌手</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.release-artists">专辑歌手</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.track-artists">音轨歌手</message>
|
||||
<message id="Lms.Settings.subsonic-api">Subsonic 应用程序接口</message>
|
||||
|
||||
|
||||
<message id="Lms.Settings.transcoding">转码</message>
|
||||
<message id="Lms.Settings.transcoding-mode">启动转码</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">总是</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">仅当格式不支持时使用</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">从不</message>
|
||||
<message id="Lms.Settings.transcoding-output-bitrate">转码比特率</message>
|
||||
<message id="Lms.Settings.transcoding-output-format">转码格式</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.matroska_opus">Matroska/Opus</message>
|
||||
@@ -331,11 +338,6 @@
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_opus">Ogg/Opus</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_vorbis">Ogg/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.webm_vorbis">WebM/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-mode">启动转码</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">总是</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">仅当格式不支持时使用</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">从不</message>
|
||||
<message id="Lms.Settings.settings-saved">新设置已保存!</message>
|
||||
|
||||
<!--Wt-->
|
||||
<message id="Wt.WMessageBox.Yes">是</message>
|
||||
|
||||
@@ -19,5 +19,6 @@
|
||||
<message id="Lms.template.edit-btn"><i class="fa fa-fw fa-edit"></i></message>
|
||||
<message id="Lms.template.more-btn"><i class="fa fa-fw fa-ellipsis-v"></i></message>
|
||||
<message id="Lms.template.play-btn"><i class="fa fa-fw fa-play"></i></message>
|
||||
<message id="Lms.template.toggle-visibility-btn"><i class="fa fa-fw fa-eye"></i></message>
|
||||
|
||||
</messages>
|
||||
|
||||
+25
-3
@@ -85,6 +85,25 @@
|
||||
</div>
|
||||
${<if-has-subsonic-api>}
|
||||
<legend>${tr:Lms.Settings.subsonic-api}</legend>
|
||||
<div class="col-lg-12">
|
||||
<label class="form-label" for="${id:subsonic-token}">
|
||||
${tr:Lms.Settings.subsonic-token}
|
||||
${<if-has-subsonic-token-usage>}
|
||||
<button type="button" class="btn btn-sm p-0" data-bs-toggle="tooltip" data-bs-placement="right" title="${tr:Lms.Settings.subsonic-token-usage}">
|
||||
<i class="fa fa-fw fa-info-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
${</if-has-subsonic-token-usage>}
|
||||
</label>
|
||||
<div class="input-group mb-3">
|
||||
${subsonic-token-regen-btn class="btn btn-outline-secondary"}
|
||||
${subsonic-token-del-btn class="btn btn-outline-warning"}
|
||||
${subsonic-token class="form-control"}
|
||||
${subsonic-token-visibility-btn class="btn btn-outline-secondary"}
|
||||
<div class="invalid-feedback">
|
||||
${subsonic-token-info class="help-block"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
${subsonic-enable-transcoding-by-default class="form-check-input"}
|
||||
@@ -150,9 +169,12 @@
|
||||
<label class="form-label" for="${id:listenbrainz-token}">
|
||||
${tr:Lms.Settings.backend.listenbrainz-token}
|
||||
</label>
|
||||
${listenbrainz-token class="form-control"}
|
||||
<div class="invalid-feedback">
|
||||
${listenbrainz-token-info class="help-block"}
|
||||
<div class="input-group mb-3">
|
||||
${listenbrainz-token class="form-control"}
|
||||
${listenbrainz-token-visibility-btn class="btn btn-outline-secondary"}
|
||||
<div class="invalid-feedback">
|
||||
${listenbrainz-token-info class="help-block"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${<if-has-change-password>}
|
||||
|
||||
+8
-1
@@ -60,6 +60,9 @@ acousticbrainz-api-base-url = "https://acousticbrainz.org";
|
||||
# Authentication
|
||||
# Available backends: "internal", "PAM", "http-headers"
|
||||
authentication-backend = "internal";
|
||||
# The number of bcrypt rounds to be used when backend is set to "internal". The higher the more secure
|
||||
internal-password-bcrypt-round = 12;
|
||||
# The header to be used to read the authentication user when backend is set to "http-headers"
|
||||
http-headers-login-field = "X-Forwarded-User";
|
||||
|
||||
# Max entries in the login throttler (1 entry per IP address. For IPv6, the whole /64 block is used)
|
||||
@@ -68,6 +71,10 @@ login-throttler-max-entries = 10000;
|
||||
# API
|
||||
api-subsonic = true;
|
||||
|
||||
# Enable or disable user/password authentication for the Subsonic API.
|
||||
# Note: Since token/salt authentication is always disabled, setting this to 'false' means only API keys can be used to access the Subsonic API.
|
||||
api-subsonic-support-user-password-auth = 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-old-server-protocol-clients = ("DSub");
|
||||
@@ -107,7 +114,7 @@ tracing-buffer-size = 16;
|
||||
# Set to true if you want to hide duplicate tracks
|
||||
scanner-skip-duplicate-mbid = false;
|
||||
|
||||
# Scanner read style for metadata, maybe be 'fast', 'average' or 'accurate'
|
||||
# Scanner read style for metadata, may be 'fast', 'average' or 'accurate'
|
||||
scanner-parser-read-style = "average";
|
||||
|
||||
# Number of threads to use for scanning file metadata (0 means number of logical CPUs / 2)
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
|
||||
namespace lms::core
|
||||
{
|
||||
template<typename Class>
|
||||
// Tag can be used if you have multiple services sharing the same interface
|
||||
template<typename Class, typename Tag = Class>
|
||||
class Service
|
||||
{
|
||||
public:
|
||||
@@ -46,12 +47,12 @@ namespace lms::core
|
||||
|
||||
Class* operator->() const
|
||||
{
|
||||
return Service<Class>::get();
|
||||
return Service<Class, Tag>::get();
|
||||
}
|
||||
|
||||
Class& operator*() const
|
||||
{
|
||||
return *Service<Class>::get();
|
||||
return *Service<Class, Tag>::get();
|
||||
}
|
||||
|
||||
static Class* get() { return _service.get(); }
|
||||
|
||||
@@ -5,6 +5,7 @@ add_executable(test-core
|
||||
LiteralString.cpp
|
||||
Path.cpp
|
||||
RecursiveSharedMutex.cpp
|
||||
Service.cpp
|
||||
String.cpp
|
||||
TraceLogger.cpp
|
||||
Utils.cpp
|
||||
|
||||
@@ -93,6 +93,8 @@ namespace lms::core::pathUtils::tests
|
||||
{ "/root/", "/root/", true },
|
||||
{ "/folder/file.txt", "/root", false },
|
||||
{ "/folder/file.txt", "/root/", false },
|
||||
{ "/file.txt", "/root", false },
|
||||
{ "/file.txt", "/root/", false },
|
||||
{ "", "/root", false },
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "core/Service.hpp"
|
||||
|
||||
namespace lms::core::tests
|
||||
{
|
||||
class IMyService
|
||||
{
|
||||
};
|
||||
|
||||
class MyService : public IMyService
|
||||
{
|
||||
};
|
||||
|
||||
class MyOtherService : public IMyService
|
||||
{
|
||||
};
|
||||
|
||||
class MyServiceTag
|
||||
{
|
||||
};
|
||||
class MyOtherServiceTag
|
||||
{
|
||||
};
|
||||
|
||||
TEST(Service, ctr)
|
||||
{
|
||||
EXPECT_FALSE(Service<IMyService>().exists());
|
||||
EXPECT_EQ(Service<IMyService>().get(), nullptr);
|
||||
|
||||
Service<IMyService> myService{ std::make_unique<MyService>() };
|
||||
|
||||
EXPECT_TRUE(Service<IMyService>().exists());
|
||||
EXPECT_EQ(Service<IMyService>().get(), myService.get());
|
||||
}
|
||||
|
||||
TEST(Service, tags)
|
||||
{
|
||||
Service<IMyService, MyServiceTag> myService{ std::make_unique<MyService>() };
|
||||
Service<IMyService, MyOtherServiceTag> myOtherService{ std::make_unique<MyOtherService>() };
|
||||
|
||||
EXPECT_FALSE(Service<IMyService>().exists());
|
||||
EXPECT_EQ(Service<IMyService>().get(), nullptr);
|
||||
|
||||
EXPECT_TRUE((Service<IMyService, MyServiceTag>().exists()));
|
||||
EXPECT_TRUE((Service<IMyService, MyOtherServiceTag>().exists()));
|
||||
EXPECT_EQ((Service<IMyService, MyServiceTag>().get()), myService.get());
|
||||
EXPECT_EQ((Service<IMyService, MyOtherServiceTag>().get()), myOtherService.get());
|
||||
}
|
||||
} // namespace lms::core::tests
|
||||
@@ -8,6 +8,7 @@ add_library(lmsdatabase SHARED
|
||||
impl/Listen.cpp
|
||||
impl/MediaLibrary.cpp
|
||||
impl/Migration.cpp
|
||||
impl/PlayQueue.cpp
|
||||
impl/TrackArtistLink.cpp
|
||||
impl/TrackFeatures.cpp
|
||||
impl/TrackList.cpp
|
||||
|
||||
@@ -30,29 +30,65 @@
|
||||
|
||||
namespace lms::db
|
||||
{
|
||||
AuthToken::AuthToken(std::string_view value, const Wt::WDateTime& expiry, ObjectPtr<User> user)
|
||||
: _value{ value }
|
||||
AuthToken::AuthToken(std::string_view domain, std::string_view value, const Wt::WDateTime& expiry, std::optional<long> maxUseCount, ObjectPtr<User> user)
|
||||
: _domain{ domain }
|
||||
, _value{ value }
|
||||
, _expiry{ expiry }
|
||||
, _maxUseCount{ maxUseCount }
|
||||
, _user{ getDboPtr(user) }
|
||||
{
|
||||
}
|
||||
|
||||
AuthToken::pointer AuthToken::create(Session& session, std::string_view value, const Wt::WDateTime& expiry, ObjectPtr<User> user)
|
||||
AuthToken::pointer AuthToken::create(Session& session, std::string_view domain, std::string_view value, const Wt::WDateTime& expiry, std::optional<long> maxUseCount, ObjectPtr<User> user)
|
||||
{
|
||||
return session.getDboSession()->add(std::unique_ptr<AuthToken>{ new AuthToken{ value, expiry, user } });
|
||||
return session.getDboSession()->add(std::unique_ptr<AuthToken>{ new AuthToken{ domain, value, expiry, maxUseCount, user } });
|
||||
}
|
||||
|
||||
void AuthToken::removeExpiredTokens(Session& session, const Wt::WDateTime& now)
|
||||
{
|
||||
session.checkWriteTransaction();
|
||||
|
||||
utils::executeCommand(*session.getDboSession(), "DELETE FROM auth_token WHERE expiry < ?", now);
|
||||
}
|
||||
|
||||
AuthToken::pointer AuthToken::find(Session& session, std::string_view value)
|
||||
std::size_t AuthToken::getCount(Session& session)
|
||||
{
|
||||
session.checkReadTransaction();
|
||||
|
||||
return utils::fetchQuerySingleResult(session.getDboSession()->find<AuthToken>().where("value = ?").bind(value));
|
||||
return utils::fetchQuerySingleResult(session.getDboSession()->query<int>("SELECT COUNT(*) FROM auth_token"));
|
||||
}
|
||||
|
||||
AuthToken::pointer AuthToken::find(Session& session, AuthTokenId id)
|
||||
{
|
||||
return utils::fetchQuerySingleResult(session.getDboSession()->query<Wt::Dbo::ptr<AuthToken>>("SELECT a_t from auth_token a_t").where("a_t.id = ?").bind(id));
|
||||
}
|
||||
|
||||
AuthToken::pointer AuthToken::find(Session& session, std::string_view domain, std::string_view value)
|
||||
{
|
||||
session.checkReadTransaction();
|
||||
|
||||
auto query{ session.getDboSession()->find<AuthToken>() };
|
||||
query.where("domain = ?").bind(domain);
|
||||
query.where("value = ?").bind(value);
|
||||
|
||||
return utils::fetchQuerySingleResult(query);
|
||||
}
|
||||
|
||||
void AuthToken::find(Session& session, std::string_view domain, UserId userId, std::function<void(const AuthToken::pointer&)> visitor)
|
||||
{
|
||||
session.checkReadTransaction();
|
||||
|
||||
auto query{ session.getDboSession()->find<AuthToken>() };
|
||||
query.where("domain = ?").bind(domain);
|
||||
query.where("user_id = ?").bind(userId);
|
||||
|
||||
utils::forEachQueryResult(query, visitor);
|
||||
}
|
||||
|
||||
void AuthToken::removeExpiredTokens(Session& session, std::string_view domain, const Wt::WDateTime& now)
|
||||
{
|
||||
session.checkWriteTransaction();
|
||||
|
||||
utils::executeCommand(*session.getDboSession(), "DELETE FROM auth_token WHERE expiry < ? AND domain = ?", now, domain);
|
||||
}
|
||||
|
||||
void AuthToken::clearUserTokens(Session& session, std::string_view domain, UserId user)
|
||||
{
|
||||
session.checkWriteTransaction();
|
||||
|
||||
utils::executeCommand(*session.getDboSession(), "DELETE FROM auth_token WHERE user_id = ? AND domain = ?", user, domain);
|
||||
}
|
||||
} // namespace lms::db
|
||||
|
||||
@@ -30,15 +30,15 @@
|
||||
|
||||
namespace lms::db
|
||||
{
|
||||
MediaLibrary::MediaLibrary(const std::filesystem::path& p, std::string_view name)
|
||||
: _path{ p }
|
||||
, _name{ std::string{ name, 0, maxNameLength } }
|
||||
MediaLibrary::MediaLibrary(std::string_view name, const std::filesystem::path& p)
|
||||
: _name{ std::string{ name, 0, maxNameLength } }
|
||||
{
|
||||
setPath(p);
|
||||
}
|
||||
|
||||
MediaLibrary::pointer MediaLibrary::create(Session& session, const std::filesystem::path& p, std::string_view name)
|
||||
MediaLibrary::pointer MediaLibrary::create(Session& session, std::string_view name, const std::filesystem::path& p)
|
||||
{
|
||||
return session.getDboSession()->add(std::unique_ptr<MediaLibrary>{ new MediaLibrary{ p, name } });
|
||||
return session.getDboSession()->add(std::unique_ptr<MediaLibrary>{ new MediaLibrary{ name, p } });
|
||||
}
|
||||
|
||||
std::size_t MediaLibrary::getCount(Session& session)
|
||||
@@ -77,4 +77,13 @@ namespace lms::db
|
||||
func(mediaLibrary);
|
||||
});
|
||||
}
|
||||
|
||||
void MediaLibrary::setPath(const std::filesystem::path& p)
|
||||
{
|
||||
assert(p.is_absolute());
|
||||
if (!p.has_filename() && p.has_parent_path())
|
||||
_path = p.parent_path();
|
||||
else
|
||||
_path = p;
|
||||
}
|
||||
} // namespace lms::db
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace lms::db
|
||||
{
|
||||
namespace
|
||||
{
|
||||
static constexpr Version LMS_DATABASE_VERSION{ 71 };
|
||||
static constexpr Version LMS_DATABASE_VERSION{ 76 };
|
||||
}
|
||||
|
||||
VersionInfo::VersionInfo()
|
||||
@@ -907,6 +907,67 @@ SELECT
|
||||
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
|
||||
}
|
||||
|
||||
void migrateFromV71(Session& session)
|
||||
{
|
||||
// Add a file name/stem in tracks
|
||||
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "playqueue" (
|
||||
"id" integer primary key autoincrement,
|
||||
"version" integer not null,
|
||||
"name" text not null,
|
||||
"current_index" integer not null,
|
||||
"current_position_in_track" integer,
|
||||
"last_modified_date_time" text,
|
||||
"user_id" bigint,
|
||||
constraint "fk_playqueue_user" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred
|
||||
))");
|
||||
|
||||
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "playqueue_track" (
|
||||
"playqueue_id" bigint,
|
||||
"track_id" bigint not null,
|
||||
primary key ("playqueue_id", "track_id"),
|
||||
constraint "fk_playqueue_track_key1" foreign key ("playqueue_id") references "playqueue" ("id") on delete cascade deferrable initially deferred,
|
||||
constraint "fk_playqueue_track_key2" foreign key ("track_id") references "track" ("id") on delete cascade deferrable initially deferred
|
||||
))");
|
||||
utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX "playqueue_track_playqueue" on "playqueue_track" ("playqueue_id"))");
|
||||
utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX "playqueue_track_track" on "playqueue_track" ("track_id"))");
|
||||
}
|
||||
|
||||
void migrateFromV72(Session& session)
|
||||
{
|
||||
// Add catalog number
|
||||
utils::executeCommand(*session.getDboSession(), "ALTER TABLE release ADD barcode TEXT NOT NULL DEFAULT ''");
|
||||
|
||||
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
|
||||
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1");
|
||||
}
|
||||
|
||||
void migrateFromV73(Session& session)
|
||||
{
|
||||
// Remove any trailing '/' in library paths
|
||||
utils::executeCommand(*session.getDboSession(), "UPDATE media_library SET path = rtrim(path, '/') WHERE path LIKE '%/'");
|
||||
}
|
||||
|
||||
void migrateFromV74(Session& session)
|
||||
{
|
||||
// New auth token authentication for Subsonic API
|
||||
// Previous tokens are not usable any more, no problem since they are just used for the ui's "remember me" feature
|
||||
utils::executeCommand(*session.getDboSession(), "DELETE FROM auth_token");
|
||||
utils::executeCommand(*session.getDboSession(), "ALTER TABLE auth_token ADD domain TEXT NOT NULL");
|
||||
utils::executeCommand(*session.getDboSession(), "ALTER TABLE auth_token ADD use_count INTEGER NOT NULL");
|
||||
utils::executeCommand(*session.getDboSession(), "ALTER TABLE auth_token ADD last_used TEXT");
|
||||
utils::executeCommand(*session.getDboSession(), "ALTER TABLE auth_token ADD max_use_count INTEGER");
|
||||
|
||||
utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS auth_token_user_idx");
|
||||
utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS auth_token_expiry_idx");
|
||||
utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS auth_token_value_idx");
|
||||
}
|
||||
|
||||
void migrateFromV75(Session& session)
|
||||
{
|
||||
// Added a new option to set the bcrypt count to be use to hash user's passwords
|
||||
utils::executeCommand(*session.getDboSession(), "ALTER TABLE user ADD bcrypt_round_count INTEGER NOT NULL DEFAULT(7)");
|
||||
}
|
||||
|
||||
bool doDbMigration(Session& session)
|
||||
{
|
||||
constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" };
|
||||
@@ -954,6 +1015,11 @@ SELECT
|
||||
{ 68, migrateFromV68 },
|
||||
{ 69, migrateFromV69 },
|
||||
{ 70, migrateFromV70 },
|
||||
{ 71, migrateFromV71 },
|
||||
{ 72, migrateFromV72 },
|
||||
{ 73, migrateFromV73 },
|
||||
{ 74, migrateFromV74 },
|
||||
{ 75, migrateFromV75 },
|
||||
};
|
||||
|
||||
bool migrationPerformed{};
|
||||
@@ -985,7 +1051,9 @@ SELECT
|
||||
LMS_LOG(DB, INFO, "Migrating database from version " << version << " to " << version + 1 << "...");
|
||||
|
||||
auto itMigrationFunc{ migrationFunctions.find(version) };
|
||||
assert(itMigrationFunc != std::cend(migrationFunctions));
|
||||
if (itMigrationFunc == std::cend(migrationFunctions))
|
||||
throw core::LmsException{ "No code found to upgrade database!" };
|
||||
|
||||
itMigrationFunc->second(session);
|
||||
|
||||
VersionInfo::get(session).modify()->setVersion(++version);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (C) 2024 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 "database/PlayQueue.hpp"
|
||||
|
||||
#include <Wt/Dbo/SqlTraits.h>
|
||||
|
||||
#include "database/Directory.hpp"
|
||||
#include "database/MediaLibrary.hpp"
|
||||
#include "database/Release.hpp"
|
||||
#include "database/Session.hpp"
|
||||
#include "database/Track.hpp"
|
||||
#include "database/User.hpp"
|
||||
|
||||
#include "IdTypeTraits.hpp"
|
||||
#include "StringViewTraits.hpp"
|
||||
#include "Utils.hpp"
|
||||
|
||||
namespace lms::db
|
||||
{
|
||||
PlayQueue::PlayQueue(const ObjectPtr<User>& user, std::string_view name)
|
||||
: _name{ name }
|
||||
, _user{ getDboPtr(user) }
|
||||
{
|
||||
}
|
||||
|
||||
PlayQueue::pointer PlayQueue::create(Session& session, const ObjectPtr<User>& user, std::string_view name)
|
||||
{
|
||||
return session.getDboSession()->add(std::unique_ptr<PlayQueue>{ new PlayQueue{ user, name } });
|
||||
}
|
||||
|
||||
std::size_t PlayQueue::getCount(Session& session)
|
||||
{
|
||||
session.checkReadTransaction();
|
||||
|
||||
return utils::fetchQuerySingleResult(session.getDboSession()->query<int>("SELECT COUNT(*) FROM playqueue"));
|
||||
}
|
||||
|
||||
PlayQueue::pointer PlayQueue::find(Session& session, PlayQueueId id)
|
||||
{
|
||||
session.checkReadTransaction();
|
||||
|
||||
return utils::fetchQuerySingleResult(session.getDboSession()->query<Wt::Dbo::ptr<PlayQueue>>("SELECT p from playqueue p").where("p.id = ?").bind(id));
|
||||
}
|
||||
|
||||
PlayQueue::pointer PlayQueue::find(Session& session, UserId userId, std::string_view name)
|
||||
{
|
||||
session.checkReadTransaction();
|
||||
|
||||
auto query{ session.getDboSession()->query<Wt::Dbo::ptr<PlayQueue>>("SELECT p from playqueue p") };
|
||||
query.where("p.user_id = ?").bind(userId);
|
||||
query.where("p.name = ?").bind(name);
|
||||
|
||||
return utils::fetchQuerySingleResult(query);
|
||||
}
|
||||
|
||||
void PlayQueue::clear()
|
||||
{
|
||||
_tracks.clear();
|
||||
}
|
||||
|
||||
void PlayQueue::addTrack(const ObjectPtr<Track>& track)
|
||||
{
|
||||
_tracks.insert(getDboPtr(track));
|
||||
}
|
||||
|
||||
Track::pointer PlayQueue::getTrackAtCurrentIndex() const
|
||||
{
|
||||
auto query{ _tracks.find() };
|
||||
query.offset(_currentIndex);
|
||||
query.limit(1);
|
||||
|
||||
return utils::fetchQuerySingleResult(query);
|
||||
}
|
||||
|
||||
void PlayQueue::visitTracks(const std::function<void(const ObjectPtr<Track>& track)>& visitor) const
|
||||
{
|
||||
utils::forEachQueryResult(_tracks.find(), visitor);
|
||||
}
|
||||
|
||||
} // namespace lms::db
|
||||
@@ -30,6 +30,7 @@
|
||||
#include "database/Image.hpp"
|
||||
#include "database/Listen.hpp"
|
||||
#include "database/MediaLibrary.hpp"
|
||||
#include "database/PlayQueue.hpp"
|
||||
#include "database/RatedArtist.hpp"
|
||||
#include "database/RatedRelease.hpp"
|
||||
#include "database/RatedTrack.hpp"
|
||||
@@ -104,6 +105,7 @@ namespace lms::db
|
||||
_session.mapClass<Label>("label");
|
||||
_session.mapClass<Listen>("listen");
|
||||
_session.mapClass<MediaLibrary>("media_library");
|
||||
_session.mapClass<PlayQueue>("playqueue");
|
||||
_session.mapClass<RatedArtist>("rated_artist");
|
||||
_session.mapClass<RatedRelease>("rated_release");
|
||||
_session.mapClass<RatedTrack>("rated_track");
|
||||
@@ -186,9 +188,9 @@ namespace lms::db
|
||||
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_sort_name_nocase_idx ON artist(sort_name COLLATE NOCASE)");
|
||||
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_mbid_idx ON artist(mbid)");
|
||||
|
||||
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_user_idx ON auth_token(user_id)");
|
||||
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_expiry_idx ON auth_token(expiry)");
|
||||
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_value_idx ON auth_token(value)");
|
||||
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_user_domain_idx ON auth_token(user_id, domain)");
|
||||
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_domain_expiry_idx ON auth_token(domain, expiry)");
|
||||
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_domain_value_idx ON auth_token(domain, value)");
|
||||
|
||||
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS cluster_cluster_type_idx ON cluster(cluster_type_id)");
|
||||
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS cluster_type_name_idx ON cluster_type(name)");
|
||||
|
||||
@@ -97,10 +97,4 @@ namespace lms::db
|
||||
assert(isAudioBitrateAllowed(bitrate));
|
||||
_subsonicDefaultTranscodingOutputBitrate = bitrate;
|
||||
}
|
||||
|
||||
void User::clearAuthTokens()
|
||||
{
|
||||
_authTokens.clear();
|
||||
}
|
||||
|
||||
} // namespace lms::db
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
#include <Wt/Dbo/Dbo.h>
|
||||
@@ -26,41 +27,62 @@
|
||||
|
||||
#include "database/AuthTokenId.hpp"
|
||||
#include "database/Object.hpp"
|
||||
#include "database/Types.hpp"
|
||||
#include "database/UserId.hpp"
|
||||
|
||||
namespace lms::db
|
||||
{
|
||||
class Session;
|
||||
|
||||
class User;
|
||||
|
||||
class AuthToken final : public Object<AuthToken, AuthTokenId>
|
||||
{
|
||||
public:
|
||||
AuthToken() = default;
|
||||
|
||||
// Utility
|
||||
static void removeExpiredTokens(Session& session, const Wt::WDateTime& now);
|
||||
static pointer find(Session& session, std::string_view value);
|
||||
static std::size_t getCount(Session& session);
|
||||
static pointer find(Session& session, AuthTokenId tokenId);
|
||||
static pointer find(Session& session, std::string_view domain, std::string_view value);
|
||||
static void find(Session& session, std::string_view domain, UserId userId, std::function<void(const AuthToken::pointer&)> visitor);
|
||||
static void removeExpiredTokens(Session& session, std::string_view domain, const Wt::WDateTime& now);
|
||||
static void clearUserTokens(Session& session, std::string_view domain, UserId user);
|
||||
|
||||
// Accessors
|
||||
const Wt::WDateTime& getExpiry() const { return _expiry; }
|
||||
ObjectPtr<User> getUser() const { return _user; }
|
||||
const std::string& getValue() const { return _value; }
|
||||
std::size_t getUseCount() const { return _useCount; }
|
||||
Wt::WDateTime getLastUsed() const { return _lastUsed; }
|
||||
std::optional<std::size_t> getMaxUseCount() const { return _maxUseCount; }
|
||||
|
||||
// Setters
|
||||
std::size_t incUseCount() { return ++_useCount; }
|
||||
void setLastUsed(const Wt::WDateTime& lastUsed) { _lastUsed = lastUsed; }
|
||||
|
||||
template<class Action>
|
||||
void persist(Action& a)
|
||||
{
|
||||
Wt::Dbo::field(a, _domain, "domain");
|
||||
Wt::Dbo::field(a, _value, "value");
|
||||
Wt::Dbo::field(a, _expiry, "expiry");
|
||||
Wt::Dbo::field(a, _useCount, "use_count");
|
||||
Wt::Dbo::field(a, _lastUsed, "last_used");
|
||||
Wt::Dbo::field(a, _maxUseCount, "max_use_count");
|
||||
Wt::Dbo::belongsTo(a, _user, "user", Wt::Dbo::OnDeleteCascade);
|
||||
}
|
||||
|
||||
private:
|
||||
friend class Session;
|
||||
AuthToken(std::string_view value, const Wt::WDateTime& expiry, ObjectPtr<User> user);
|
||||
static pointer create(Session& session, std::string_view value, const Wt::WDateTime& expiry, ObjectPtr<User> user);
|
||||
AuthToken(std::string_view domain, std::string_view value, const Wt::WDateTime& expiry, std::optional<long> maxUseCount, ObjectPtr<User> user);
|
||||
static pointer create(Session& session, std::string_view domain, std::string_view value, const Wt::WDateTime& expiry, std::optional<long> maxUseCount, ObjectPtr<User> user);
|
||||
|
||||
std::string _domain;
|
||||
std::string _value;
|
||||
Wt::WDateTime _expiry;
|
||||
long _useCount{};
|
||||
Wt::WDateTime _lastUsed;
|
||||
std::optional<long> _maxUseCount;
|
||||
Wt::Dbo::ptr<User> _user;
|
||||
};
|
||||
} // namespace lms::db
|
||||
} // namespace lms::db
|
||||
@@ -48,7 +48,7 @@ namespace lms::db
|
||||
struct FindParameters
|
||||
{
|
||||
std::optional<Range> range;
|
||||
ClusterSortMethod sortMethod;
|
||||
ClusterSortMethod sortMethod{ ClusterSortMethod::None };
|
||||
ClusterTypeId clusterType; // if non empty, clusters that belong to this cluster type
|
||||
std::string clusterTypeName; // if non empty, clusters that belong to this cluster type
|
||||
TrackId track; // if set, clusters involved in this track
|
||||
|
||||
@@ -53,7 +53,7 @@ namespace lms::db
|
||||
|
||||
// setters
|
||||
void setName(std::string_view name) { _name = name; }
|
||||
void setPath(const std::filesystem::path& p) { _path = p; }
|
||||
void setPath(const std::filesystem::path& p);
|
||||
|
||||
template<class Action>
|
||||
void persist(Action& a)
|
||||
@@ -64,8 +64,8 @@ namespace lms::db
|
||||
|
||||
private:
|
||||
friend class Session;
|
||||
MediaLibrary(const std::filesystem::path& p, std::string_view name);
|
||||
static pointer create(Session& session, const std::filesystem::path& p = {}, std::string_view name = {});
|
||||
MediaLibrary(std::string_view name, const std::filesystem::path& p);
|
||||
static pointer create(Session& session, std::string_view name, const std::filesystem::path& p);
|
||||
|
||||
std::filesystem::path _path;
|
||||
std::string _name;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (C) 2024 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 <chrono>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include <Wt/Dbo/Dbo.h>
|
||||
#include <Wt/WDateTime.h>
|
||||
|
||||
#include "database/Object.hpp"
|
||||
#include "database/TrackId.hpp"
|
||||
#include "database/Types.hpp"
|
||||
#include "database/UserId.hpp"
|
||||
|
||||
LMS_DECLARE_IDTYPE(PlayQueueId)
|
||||
|
||||
namespace lms::db
|
||||
{
|
||||
class Session;
|
||||
class Track;
|
||||
class User;
|
||||
|
||||
// Usage only for subsonic API
|
||||
class PlayQueue final : public Object<PlayQueue, PlayQueueId>
|
||||
{
|
||||
public:
|
||||
PlayQueue() = default;
|
||||
|
||||
static std::size_t getCount(Session& session);
|
||||
static pointer find(Session& session, PlayQueueId playQueueId);
|
||||
static pointer find(Session& session, UserId userId, std::string_view name);
|
||||
|
||||
// Accessors
|
||||
std::string_view getName() const { return _name; }
|
||||
std::size_t getCurrentIndex() const { return _currentIndex; }
|
||||
std::chrono::milliseconds getCurrentPositionInTrack() const { return _currentPositionInTrack; }
|
||||
ObjectPtr<User> getUser() const { return _user; }
|
||||
ObjectPtr<Track> getTrack(std::size_t index) const;
|
||||
ObjectPtr<Track> getTrackAtCurrentIndex() const;
|
||||
// Get tracks, ordered by position
|
||||
std::vector<TrackId> getTrackIds() const;
|
||||
void visitTracks(const std::function<void(const ObjectPtr<Track>& track)>& visitor) const;
|
||||
const Wt::WDateTime getLastModifiedDateTime() const { return _lastModifiedDateTime; }
|
||||
|
||||
// Modifiers
|
||||
void setCurrentIndex(std::size_t index) { _currentIndex = index; }
|
||||
void setCurrentPositionInTrack(std::chrono::milliseconds position) { _currentPositionInTrack = position; }
|
||||
void clear();
|
||||
void addTrack(const ObjectPtr<Track>& track);
|
||||
void setLastModifiedDateTime(const Wt::WDateTime& lastModified) { _lastModifiedDateTime = lastModified; }
|
||||
|
||||
template<class Action>
|
||||
void persist(Action& a)
|
||||
{
|
||||
Wt::Dbo::field(a, _name, "name");
|
||||
Wt::Dbo::field(a, _currentIndex, "current_index");
|
||||
Wt::Dbo::field(a, _currentPositionInTrack, "current_position_in_track");
|
||||
Wt::Dbo::field(a, _lastModifiedDateTime, "last_modified_date_time");
|
||||
|
||||
Wt::Dbo::belongsTo(a, _user, "user", Wt::Dbo::OnDeleteCascade);
|
||||
Wt::Dbo::hasMany(a, _tracks, Wt::Dbo::ManyToMany, "playqueue_track", "", Wt::Dbo::OnDeleteCascade);
|
||||
}
|
||||
|
||||
private:
|
||||
friend class Session;
|
||||
PlayQueue(const ObjectPtr<User>& user, std::string_view name);
|
||||
static pointer create(Session& session, const ObjectPtr<User>& user, std::string_view name);
|
||||
|
||||
std::string _name;
|
||||
int _currentIndex{};
|
||||
std::chrono::duration<int, std::milli> _currentPositionInTrack{};
|
||||
Wt::WDateTime _lastModifiedDateTime;
|
||||
|
||||
Wt::Dbo::ptr<User> _user;
|
||||
Wt::Dbo::collection<Wt::Dbo::ptr<Track>> _tracks;
|
||||
};
|
||||
} // namespace lms::db
|
||||
@@ -252,6 +252,7 @@ namespace lms::db
|
||||
std::vector<std::string> getLabelNames() const;
|
||||
std::vector<std::string> getReleaseTypeNames() const;
|
||||
void visitLabels(const std::function<void(const Label::pointer& label)>& _func) const;
|
||||
std::string_view getBarcode() const { return _barcode; }
|
||||
ObjectPtr<Image> getImage() const;
|
||||
|
||||
// Setters
|
||||
@@ -266,6 +267,7 @@ namespace lms::db
|
||||
void clearReleaseTypes();
|
||||
void addLabel(ObjectPtr<Label> releaseType);
|
||||
void addReleaseType(ObjectPtr<ReleaseType> releaseType);
|
||||
void setBarcode(std::string_view barcode) { _barcode = barcode; }
|
||||
void setImage(ObjectPtr<Image> image);
|
||||
|
||||
// Get the artists of this release
|
||||
@@ -286,6 +288,7 @@ namespace lms::db
|
||||
Wt::Dbo::field(a, _totalDisc, "total_disc");
|
||||
Wt::Dbo::field(a, _artistDisplayName, "artist_display_name");
|
||||
Wt::Dbo::field(a, _isCompilation, "is_compilation");
|
||||
Wt::Dbo::field(a, _barcode, "barcode");
|
||||
Wt::Dbo::hasMany(a, _tracks, Wt::Dbo::ManyToOne, "release");
|
||||
|
||||
Wt::Dbo::belongsTo(a, _image, "image", Wt::Dbo::OnDeleteSetNull);
|
||||
@@ -310,6 +313,7 @@ namespace lms::db
|
||||
std::optional<int> _totalDisc{};
|
||||
std::string _artistDisplayName;
|
||||
bool _isCompilation{}; // See https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html#compilation-itunes-5
|
||||
std::string _barcode;
|
||||
|
||||
Wt::Dbo::ptr<Image> _image;
|
||||
Wt::Dbo::collection<Wt::Dbo::ptr<Track>> _tracks;
|
||||
|
||||
@@ -42,6 +42,7 @@ namespace lms::db
|
||||
public:
|
||||
struct PasswordHash
|
||||
{
|
||||
std::size_t bcryptRoundCount;
|
||||
std::string salt;
|
||||
std::string hash;
|
||||
};
|
||||
@@ -91,7 +92,7 @@ namespace lms::db
|
||||
|
||||
// accessors
|
||||
const std::string& getLoginName() const { return _loginName; }
|
||||
PasswordHash getPasswordHash() const { return PasswordHash{ _passwordSalt, _passwordHash }; }
|
||||
PasswordHash getPasswordHash() const { return PasswordHash{ .bcryptRoundCount = static_cast<std::size_t>(_bcryptRoundCount), .salt = _passwordSalt, .hash = _passwordHash }; }
|
||||
const Wt::WDateTime& getLastLogin() const { return _lastLogin; }
|
||||
std::size_t getAuthTokensCount() const { return _authTokens.size(); }
|
||||
|
||||
@@ -99,6 +100,7 @@ namespace lms::db
|
||||
void setLastLogin(const Wt::WDateTime& dateTime) { _lastLogin = dateTime; }
|
||||
void setPasswordHash(const PasswordHash& passwordHash)
|
||||
{
|
||||
_bcryptRoundCount = passwordHash.bcryptRoundCount;
|
||||
_passwordSalt = passwordHash.salt;
|
||||
_passwordHash = passwordHash.hash;
|
||||
}
|
||||
@@ -108,7 +110,6 @@ namespace lms::db
|
||||
void setSubsonicDefaultTranscodingOutputBitrate(Bitrate bitrate);
|
||||
void setUITheme(UITheme uiTheme) { _uiTheme = uiTheme; }
|
||||
void setUIArtistReleaseSortMethod(ReleaseSortMethod method) { _uiArtistReleaseSortMethod = method; }
|
||||
void clearAuthTokens();
|
||||
void setSubsonicArtistListMode(SubsonicArtistListMode mode) { _subsonicArtistListMode = mode; }
|
||||
void setFeedbackBackend(FeedbackBackend feedbackBackend) { _feedbackBackend = feedbackBackend; }
|
||||
void setScrobblingBackend(ScrobblingBackend scrobblingBackend) { _scrobblingBackend = scrobblingBackend; }
|
||||
@@ -133,6 +134,7 @@ namespace lms::db
|
||||
{
|
||||
Wt::Dbo::field(a, _type, "type");
|
||||
Wt::Dbo::field(a, _loginName, "login_name");
|
||||
Wt::Dbo::field(a, _bcryptRoundCount, "bcrypt_round_count");
|
||||
Wt::Dbo::field(a, _passwordSalt, "password_salt");
|
||||
Wt::Dbo::field(a, _passwordHash, "password_hash");
|
||||
Wt::Dbo::field(a, _lastLogin, "last_login");
|
||||
@@ -156,6 +158,7 @@ namespace lms::db
|
||||
static pointer create(Session& session, std::string_view loginName);
|
||||
|
||||
std::string _loginName;
|
||||
int _bcryptRoundCount{};
|
||||
std::string _passwordSalt;
|
||||
std::string _passwordHash;
|
||||
Wt::WDateTime _lastLogin;
|
||||
|
||||
@@ -89,8 +89,8 @@ namespace lms::db::tests
|
||||
ScopedArtist artist1{ session, "MyArtist1" };
|
||||
ScopedArtist artist2{ session, "MyArtist2" };
|
||||
ScopedArtist artist3{ session, "MyArtist3" };
|
||||
ScopedMediaLibrary library{ session };
|
||||
ScopedMediaLibrary otherLibrary{ session };
|
||||
ScopedMediaLibrary library{ session, "MyLibrary", "/root" };
|
||||
ScopedMediaLibrary otherLibrary{ session, "OtherLibrary", "/otherRoot" };
|
||||
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
@@ -306,8 +306,8 @@ namespace lms::db::tests
|
||||
{
|
||||
ScopedTrack track{ session };
|
||||
ScopedArtist artist{ session, "MyArtist" };
|
||||
ScopedMediaLibrary library{ session };
|
||||
ScopedMediaLibrary otherLibrary{ session };
|
||||
ScopedMediaLibrary library{ session, "MyLibrary", "/root" };
|
||||
ScopedMediaLibrary otherLibrary{ session, "OtherLibrary", "/otherRoot" };
|
||||
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (C) 2024 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 "Common.hpp"
|
||||
|
||||
#include "database/AuthToken.hpp"
|
||||
|
||||
namespace lms::db::tests
|
||||
{
|
||||
using ScopedAuthToken = ScopedEntity<db::AuthToken>;
|
||||
|
||||
TEST_F(DatabaseFixture, AuthTokens)
|
||||
{
|
||||
ScopedUser user{ session, "MyUser" };
|
||||
|
||||
{
|
||||
auto transaction{ session.createReadTransaction() };
|
||||
EXPECT_EQ(AuthToken::getCount(session), 0);
|
||||
}
|
||||
|
||||
ScopedAuthToken token{ session, "myDomain", "foo", Wt::WDateTime{}, std::nullopt, user.lockAndGet() };
|
||||
|
||||
{
|
||||
auto transaction{ session.createReadTransaction() };
|
||||
EXPECT_EQ(AuthToken::getCount(session), 1);
|
||||
}
|
||||
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
AuthToken::clearUserTokens(session, "nonExistingDomain", user.getId());
|
||||
}
|
||||
}
|
||||
} // namespace lms::db::tests
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
add_executable(test-database
|
||||
AuthToken.cpp
|
||||
Artist.cpp
|
||||
Cluster.cpp
|
||||
Common.cpp
|
||||
|
||||
@@ -260,7 +260,7 @@ namespace lms::db::tests
|
||||
ScopedDirectory parent2{ session, "/root_1" };
|
||||
ScopedDirectory child2{ session, "/root_1/foo" };
|
||||
|
||||
ScopedMediaLibrary library{ session, "/root" };
|
||||
ScopedMediaLibrary library{ session, "MyLibrary", "/root" };
|
||||
|
||||
{
|
||||
auto transaction{ session.createReadTransaction() };
|
||||
|
||||
@@ -343,8 +343,8 @@ namespace lms::db::tests
|
||||
ScopedUser user{ session, "MyUser" };
|
||||
const Wt::WDateTime dateTime1{ Wt::WDate{ 2000, 1, 2 }, Wt::WTime{ 12, 0, 1 } };
|
||||
ScopedListen listen{ session, user.lockAndGet(), track.lockAndGet(), ScrobblingBackend::Internal, dateTime1 };
|
||||
ScopedMediaLibrary library{ session };
|
||||
ScopedMediaLibrary otherLibrary{ session };
|
||||
ScopedMediaLibrary library{ session, "MyLibrary", "/root" };
|
||||
ScopedMediaLibrary otherLibrary{ session, "OtherLibrary", "/otherRoot" };
|
||||
|
||||
{
|
||||
auto transaction{ session.createReadTransaction() };
|
||||
@@ -563,8 +563,8 @@ namespace lms::db::tests
|
||||
ScopedUser user{ session, "MyUser" };
|
||||
const Wt::WDateTime dateTime{ Wt::WDate{ 2000, 1, 2 }, Wt::WTime{ 12, 0, 1 } };
|
||||
ScopedRelease release{ session, "MyRelease" };
|
||||
ScopedMediaLibrary library{ session };
|
||||
ScopedMediaLibrary otherLibrary{ session };
|
||||
ScopedMediaLibrary library{ session, "MyLibrary", "/root" };
|
||||
ScopedMediaLibrary otherLibrary{ session, "OtherLibrary", "/otherRoot" };
|
||||
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
@@ -809,8 +809,8 @@ namespace lms::db::tests
|
||||
ScopedTrack track{ session };
|
||||
ScopedUser user{ session, "MyUser" };
|
||||
const Wt::WDateTime dateTime{ Wt::WDate{ 2000, 1, 2 }, Wt::WTime{ 12, 0, 1 } };
|
||||
ScopedMediaLibrary library{ session };
|
||||
ScopedMediaLibrary otherLibrary{ session };
|
||||
ScopedMediaLibrary library{ session, "MyLibrary", "/root" };
|
||||
ScopedMediaLibrary otherLibrary{ session, "OtherLibrary", "/otherRoot" };
|
||||
|
||||
{
|
||||
auto transaction{ session.createReadTransaction() };
|
||||
@@ -1051,8 +1051,8 @@ namespace lms::db::tests
|
||||
ScopedTrack track{ session };
|
||||
ScopedUser user{ session, "MyUser" };
|
||||
ScopedArtist artist{ session, "MyArtist" };
|
||||
ScopedMediaLibrary library{ session };
|
||||
ScopedMediaLibrary otherLibrary{ session };
|
||||
ScopedMediaLibrary library{ session, "MyLibrary", "/root" };
|
||||
ScopedMediaLibrary otherLibrary{ session, "OtherLibrary", "/otherRoot" };
|
||||
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
@@ -1325,8 +1325,8 @@ namespace lms::db::tests
|
||||
ScopedTrack track{ session };
|
||||
ScopedUser user{ session, "MyUser" };
|
||||
ScopedRelease release{ session, "MyRelease" };
|
||||
ScopedMediaLibrary library{ session };
|
||||
ScopedMediaLibrary otherLibrary{ session };
|
||||
ScopedMediaLibrary library{ session, "MyLibrary", "/root" };
|
||||
ScopedMediaLibrary otherLibrary{ session, "OtherLibrary", "/otherRoot" };
|
||||
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
@@ -1410,8 +1410,8 @@ namespace lms::db::tests
|
||||
{
|
||||
ScopedTrack track{ session };
|
||||
ScopedUser user{ session, "MyUser" };
|
||||
ScopedMediaLibrary library{ session };
|
||||
ScopedMediaLibrary otherLibrary{ session };
|
||||
ScopedMediaLibrary library{ session, "MyLibrary", "/root" };
|
||||
ScopedMediaLibrary otherLibrary{ session, "OtherLibrary", "/otherRoot" };
|
||||
|
||||
const Wt::WDateTime dateTime{ Wt::WDate{ 2000, 1, 2 }, Wt::WTime{ 12, 0, 1 } };
|
||||
ScopedListen listen1{ session, user.lockAndGet(), track.lockAndGet(), ScrobblingBackend::Internal, dateTime };
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
#include "Common.hpp"
|
||||
|
||||
#include "core/String.hpp"
|
||||
#include "database/AuthToken.hpp"
|
||||
#include "database/Db.hpp"
|
||||
#include "database/Directory.hpp"
|
||||
#include "database/Image.hpp"
|
||||
#include "database/PlayQueue.hpp"
|
||||
#include "database/RatedArtist.hpp"
|
||||
#include "database/RatedRelease.hpp"
|
||||
#include "database/RatedTrack.hpp"
|
||||
@@ -336,12 +338,14 @@ VALUES
|
||||
auto transaction{ session.createReadTransaction() };
|
||||
|
||||
EXPECT_FALSE(Artist::find(session, ArtistId{}));
|
||||
EXPECT_FALSE(AuthToken::find(session, AuthTokenId{}));
|
||||
EXPECT_FALSE(Cluster::find(session, ClusterId{}));
|
||||
EXPECT_FALSE(ClusterType::find(session, ClusterTypeId{}));
|
||||
EXPECT_FALSE(Directory::find(session, DirectoryId{}));
|
||||
EXPECT_FALSE(Image::find(session, ImageId{}));
|
||||
EXPECT_FALSE(Label::find(session, LabelId{}));
|
||||
EXPECT_FALSE(Listen::find(session, ListenId{}));
|
||||
EXPECT_FALSE(PlayQueue::find(session, PlayQueueId{}));
|
||||
EXPECT_FALSE(RatedArtist::find(session, RatedArtistId{}));
|
||||
EXPECT_FALSE(RatedRelease::find(session, RatedReleaseId{}));
|
||||
EXPECT_FALSE(RatedTrack::find(session, RatedTrackId{}));
|
||||
|
||||
@@ -86,8 +86,8 @@ namespace lms::db::tests
|
||||
ScopedRelease release1{ session, "MyRelease1" };
|
||||
ScopedRelease release2{ session, "MyRelease2" };
|
||||
ScopedRelease release3{ session, "MyRelease3" };
|
||||
ScopedMediaLibrary library{ session };
|
||||
ScopedMediaLibrary otherLibrary{ session };
|
||||
ScopedMediaLibrary library{ session, "MyLibrary", "/root" };
|
||||
ScopedMediaLibrary otherLibrary{ session, "OtherLibrary", "/otherRoot" };
|
||||
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
@@ -233,8 +233,8 @@ namespace lms::db::tests
|
||||
{
|
||||
ScopedTrack track{ session };
|
||||
ScopedRelease release{ session, "MyRelease" };
|
||||
ScopedMediaLibrary library{ session };
|
||||
ScopedMediaLibrary otherLibrary{ session };
|
||||
ScopedMediaLibrary library{ session, "MyLibrary", "/root" };
|
||||
ScopedMediaLibrary otherLibrary{ session, "OtherLibrary", "/otherRoot" };
|
||||
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
|
||||
@@ -67,8 +67,8 @@ namespace lms::db::tests
|
||||
ScopedTrack track1{ session };
|
||||
ScopedTrack track2{ session };
|
||||
ScopedTrack track3{ session };
|
||||
ScopedMediaLibrary library{ session };
|
||||
ScopedMediaLibrary otherLibrary{ session };
|
||||
ScopedMediaLibrary library{ session, "MyLibrary", "/root" };
|
||||
ScopedMediaLibrary otherLibrary{ session, "OtherLibrary", "/otherRoot" };
|
||||
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
@@ -148,8 +148,8 @@ namespace lms::db::tests
|
||||
TEST_F(DatabaseFixture, Track_MediaLibrary)
|
||||
{
|
||||
ScopedTrack track{ session };
|
||||
ScopedMediaLibrary library{ session };
|
||||
ScopedMediaLibrary otherLibrary{ session };
|
||||
ScopedMediaLibrary library{ session, "MyLibrary", "/root" };
|
||||
ScopedMediaLibrary otherLibrary{ session, "OtherLibrary", "/otherRoot" };
|
||||
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
|
||||
@@ -158,7 +158,7 @@ namespace lms::db::tests
|
||||
ScopedTrackList trackList2{ session, "MytrackList2", TrackListType::Playlist, false, user.lockAndGet() };
|
||||
ScopedTrack track1{ session };
|
||||
ScopedTrack track2{ session };
|
||||
ScopedMediaLibrary library{ session };
|
||||
ScopedMediaLibrary library{ session, "MyLibrary", "/root" };
|
||||
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
|
||||
@@ -176,7 +176,7 @@ namespace lms::metadata
|
||||
if (parseTag(trimmedLine, lyrics))
|
||||
continue;
|
||||
|
||||
const std::string_view lyricText{ extractTimestamps(trimmedLine, timestamps) };
|
||||
const std::string_view lyricsText{ extractTimestamps(trimmedLine, timestamps) };
|
||||
|
||||
// If there are timestamps, add as synchronized lyrics
|
||||
if (!timestamps.empty())
|
||||
@@ -188,7 +188,16 @@ namespace lms::metadata
|
||||
|
||||
applyAccumulatedLyrics();
|
||||
for (std::chrono::milliseconds timestamp : timestamps)
|
||||
lyrics.synchronizedLines.emplace(timestamp, lyricText);
|
||||
{
|
||||
auto itLine{ lyrics.synchronizedLines.find(timestamp) };
|
||||
if (itLine != std::cend(lyrics.synchronizedLines))
|
||||
{
|
||||
itLine->second.push_back('\n');
|
||||
itLine->second.append(lyricsText);
|
||||
}
|
||||
else
|
||||
lyrics.synchronizedLines.emplace(timestamp, lyricsText);
|
||||
}
|
||||
|
||||
lastTimestamps = timestamps;
|
||||
}
|
||||
|
||||
@@ -434,6 +434,7 @@ namespace lms::metadata
|
||||
release->groupMBID = getTagValueAs<core::UUID>(tagReader, TagType::MusicBrainzReleaseGroupID);
|
||||
release->mediumCount = getTagValueAs<std::size_t>(tagReader, TagType::TotalDiscs);
|
||||
release->isCompilation = getTagValueAs<bool>(tagReader, TagType::Compilation).value_or(false);
|
||||
release->barcode = getTagValueAs<std::string>(tagReader, TagType::Barcode).value_or("");
|
||||
release->labels = getTagValuesAs<std::string>(tagReader, TagType::RecordLabel, _defaultTagDelimiters);
|
||||
if (!release->mediumCount)
|
||||
{
|
||||
|
||||
@@ -57,7 +57,7 @@ namespace lms::metadata
|
||||
};
|
||||
|
||||
// Mapping to internal taglib names and/or common alternative custom names
|
||||
static const std::unordered_map<TagType, std::vector<std::string>> tagMapping{
|
||||
const std::unordered_map<TagType, std::vector<std::string>> tagMapping{
|
||||
{ TagType::AcoustID, { "ACOUSTID_ID", "ACOUSTID ID" } },
|
||||
{ TagType::Album, { "ALBUM" } },
|
||||
{ TagType::AlbumArtist, { "ALBUMARTIST" } },
|
||||
|
||||
@@ -68,6 +68,7 @@ namespace lms::metadata
|
||||
std::vector<std::string> labels;
|
||||
std::vector<std::string> releaseTypes;
|
||||
bool isCompilation{};
|
||||
std::string barcode;
|
||||
|
||||
auto operator<=>(const Release&) const = default;
|
||||
};
|
||||
|
||||
@@ -271,6 +271,28 @@ SecondLine
|
||||
EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "Foo");
|
||||
}
|
||||
|
||||
TEST(Lyrics, synchronized_emptyLines)
|
||||
{
|
||||
std::istringstream is{ R"([00:03.30]Ooh, ooh
|
||||
[00:03.30]
|
||||
[00:06.75]
|
||||
[00:06.75]Foo
|
||||
)" };
|
||||
|
||||
const Lyrics lyrics{ parseLyrics(is) };
|
||||
|
||||
EXPECT_TRUE(lyrics.displayArtist.empty());
|
||||
EXPECT_TRUE(lyrics.displayAlbum.empty());
|
||||
EXPECT_TRUE(lyrics.displayTitle.empty());
|
||||
EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 });
|
||||
EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0);
|
||||
ASSERT_EQ(lyrics.synchronizedLines.size(), 2);
|
||||
ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms));
|
||||
EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh\n");
|
||||
ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms));
|
||||
EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "\nFoo");
|
||||
}
|
||||
|
||||
TEST(Lyrics, synchronized_multitimestamps)
|
||||
{
|
||||
std::istringstream is{ R"([00:03.30][00:09.16] [00:15.16]Ooh, ooh
|
||||
|
||||
@@ -41,6 +41,7 @@ namespace lms::metadata
|
||||
{ TagType::AlbumArtist, { "MyAlbumArtist1 & MyAlbumArtist2" } },
|
||||
{ TagType::AlbumArtists, { "MyAlbumArtist1", "MyAlbumArtist2" } },
|
||||
{ TagType::AlbumArtistsSortOrder, { "MyAlbumArtist1SortName", "MyAlbumArtist2SortName" } },
|
||||
{ TagType::Barcode, { "MyBarcode" } },
|
||||
{ TagType::Comment, { "Comment1", "Comment2" } },
|
||||
{ TagType::Compilation, { "1" } },
|
||||
{ TagType::Composer, { "MyComposer1", "MyComposer2" } },
|
||||
@@ -208,6 +209,7 @@ namespace lms::metadata
|
||||
EXPECT_EQ(track->medium->release->artists[1].sortName, "MyAlbumArtist2SortName");
|
||||
EXPECT_EQ(track->medium->release->artists[1].mbid, core::UUID::fromString("5ed3d6b3-2aed-4a03-828c-3c4d4f7406e1"));
|
||||
EXPECT_TRUE(track->medium->release->isCompilation);
|
||||
EXPECT_EQ(track->medium->release->barcode, "MyBarcode");
|
||||
ASSERT_EQ(track->medium->release->labels.size(), 2);
|
||||
EXPECT_EQ(track->medium->release->labels[0], "Label1");
|
||||
EXPECT_EQ(track->medium->release->labels[1], "Label2");
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
#include "AuthTokenService.hpp"
|
||||
|
||||
#include <Wt/Auth/HashFunction.h>
|
||||
#include <Wt/Auth/PasswordStrengthValidator.h>
|
||||
#include <Wt/WRandom.h>
|
||||
|
||||
#include "core/Exception.hpp"
|
||||
@@ -32,72 +31,95 @@
|
||||
|
||||
namespace lms::auth
|
||||
{
|
||||
|
||||
std::unique_ptr<IAuthTokenService> createAuthTokenService(db::Db& db, std::size_t maxThrottlerEntries)
|
||||
namespace
|
||||
{
|
||||
return std::make_unique<AuthTokenService>(db, maxThrottlerEntries);
|
||||
AuthTokenService::AuthTokenInfo createAuthTokenInfo(const db::AuthToken::pointer& authToken)
|
||||
{
|
||||
return AuthTokenService::AuthTokenInfo{
|
||||
.userId = authToken->getUser()->getId(),
|
||||
.expiry = authToken->getExpiry(),
|
||||
.lastUsed = authToken->getLastUsed(),
|
||||
.useCount = authToken->getUseCount(),
|
||||
.maxUseCount = authToken->getMaxUseCount(),
|
||||
};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<IAuthTokenService> createAuthTokenService(db::Db& db, std::size_t maxThrottlerEntryCount)
|
||||
{
|
||||
return std::make_unique<AuthTokenService>(db, maxThrottlerEntryCount);
|
||||
}
|
||||
|
||||
static const Wt::Auth::SHA1HashFunction sha1Function;
|
||||
|
||||
AuthTokenService::AuthTokenService(db::Db& db, std::size_t maxThrottlerEntries)
|
||||
AuthTokenService::AuthTokenService(db::Db& db, std::size_t maxThrottlerEntryCount)
|
||||
: AuthServiceBase{ db }
|
||||
, _loginThrottler{ maxThrottlerEntries }
|
||||
, _loginThrottler{ maxThrottlerEntryCount }
|
||||
{
|
||||
}
|
||||
|
||||
std::string
|
||||
AuthTokenService::createAuthToken(db::UserId userId, const Wt::WDateTime& expiry)
|
||||
void AuthTokenService::registerDomain(core::LiteralString domain, const DomainParameters& params)
|
||||
{
|
||||
const std::string secret{ Wt::WRandom::generateId(32) };
|
||||
const std::string secretHash{ sha1Function.compute(secret, {}) };
|
||||
|
||||
db::Session& session{ getDbSession() };
|
||||
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
|
||||
db::User::pointer user{ db::User::find(session, userId) };
|
||||
if (!user)
|
||||
throw Exception{ "User deleted" };
|
||||
|
||||
db::AuthToken::pointer authToken{ session.create<db::AuthToken>(secretHash, expiry, user) };
|
||||
|
||||
LMS_LOG(UI, DEBUG, "Created auth token for user '" << user->getLoginName() << "', expiry = " << expiry.toString());
|
||||
|
||||
if (user->getAuthTokensCount() >= 50)
|
||||
db::AuthToken::removeExpiredTokens(session, Wt::WDateTime::currentDateTime());
|
||||
|
||||
return secret;
|
||||
[[maybe_unused]] auto [it, inserted]{ _domainParameters.emplace(domain, params) };
|
||||
if (!inserted)
|
||||
throw Exception{ "Auth token domain already registered!" };
|
||||
}
|
||||
|
||||
std::optional<AuthTokenService::AuthTokenProcessResult::AuthTokenInfo>
|
||||
AuthTokenService::processAuthToken(std::string_view secret)
|
||||
void AuthTokenService::createAuthToken(core::LiteralString domain, db::UserId userId, std::string_view token)
|
||||
{
|
||||
const std::string secretHash{ sha1Function.compute(std::string{ secret }, {}) };
|
||||
const DomainParameters& params{ getDomainParameters(domain) };
|
||||
|
||||
db::Session& session{ getDbSession() };
|
||||
const auto now{ Wt::WDateTime::currentDateTime() };
|
||||
const auto expiry{ params.tokenDuration ? now.addSecs(std::chrono::duration_cast<std::chrono::seconds>(params.tokenDuration.value()).count()) : Wt::WDateTime{} };
|
||||
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
|
||||
const db::User::pointer user{ db::User::find(session, userId) };
|
||||
if (!user)
|
||||
throw Exception{ "User deleted" };
|
||||
|
||||
const db::AuthToken::pointer authToken{ session.create<db::AuthToken>(domain.str(), token, expiry, params.tokenMaxUseCount, user) };
|
||||
|
||||
LMS_LOG(UI, DEBUG, "Created auth token for user '" << user->getLoginName() << "', expiry = " << authToken->getExpiry().toString() << ", maxUseCount = " << (authToken->getMaxUseCount() ? std::to_string(*authToken->getMaxUseCount()) : "<unset>"));
|
||||
|
||||
// TODO per domain
|
||||
if (user->getAuthTokensCount() >= 50)
|
||||
db::AuthToken::removeExpiredTokens(session, domain.str(), Wt::WDateTime::currentDateTime());
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<AuthTokenService::AuthTokenInfo> AuthTokenService::processAuthToken(core::LiteralString domain, std::string_view token)
|
||||
{
|
||||
db::Session& session{ getDbSession() };
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
|
||||
db::AuthToken::pointer authToken{ db::AuthToken::find(session, secretHash) };
|
||||
db::AuthToken::pointer authToken{ db::AuthToken::find(session, domain.str(), token) };
|
||||
if (!authToken)
|
||||
return std::nullopt;
|
||||
|
||||
if (authToken->getExpiry() < Wt::WDateTime::currentDateTime())
|
||||
if (authToken->getExpiry().isValid() && authToken->getExpiry() < Wt::WDateTime::currentDateTime())
|
||||
{
|
||||
authToken.remove();
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
LMS_LOG(UI, DEBUG, "Found auth token for user '" << authToken->getUser()->getLoginName() << "'!");
|
||||
LMS_LOG(UI, DEBUG, "Found auth token for user '" << authToken->getUser()->getLoginName() << "' on domain '" << domain.str() << "'");
|
||||
|
||||
AuthTokenService::AuthTokenProcessResult::AuthTokenInfo res{ authToken->getUser()->getId(), authToken->getExpiry() };
|
||||
authToken.remove();
|
||||
AuthTokenInfo res{ createAuthTokenInfo(authToken) };
|
||||
|
||||
const std::size_t tokenUseCount{ authToken.modify()->incUseCount() };
|
||||
authToken.modify()->setLastUsed(Wt::WDateTime::currentDateTime());
|
||||
|
||||
if (auto maxUseCount{ authToken->getMaxUseCount() })
|
||||
{
|
||||
if (*maxUseCount >= tokenUseCount)
|
||||
authToken.remove();
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
AuthTokenService::AuthTokenProcessResult
|
||||
AuthTokenService::processAuthToken(const boost::asio::ip::address& clientAddress, std::string_view tokenValue)
|
||||
AuthTokenService::AuthTokenProcessResult AuthTokenService::processAuthToken(core::LiteralString domain, const boost::asio::ip::address& clientAddress, std::string_view tokenValue)
|
||||
{
|
||||
// Do not waste too much resource on brute force attacks (optim)
|
||||
{
|
||||
@@ -107,7 +129,7 @@ namespace lms::auth
|
||||
return AuthTokenProcessResult{ AuthTokenProcessResult::State::Throttled };
|
||||
}
|
||||
|
||||
auto res{ processAuthToken(tokenValue) };
|
||||
auto res{ processAuthToken(domain, tokenValue) };
|
||||
{
|
||||
std::unique_lock lock{ _mutex };
|
||||
|
||||
@@ -122,22 +144,40 @@ namespace lms::auth
|
||||
|
||||
_loginThrottler.onGoodClientAttempt(clientAddress);
|
||||
onUserAuthenticated(res->userId);
|
||||
return AuthTokenProcessResult{ AuthTokenProcessResult::State::Granted, std::move(*res) };
|
||||
return AuthTokenProcessResult{ AuthTokenProcessResult::State::Granted, res };
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
AuthTokenService::clearAuthTokens(db::UserId userId)
|
||||
void AuthTokenService::visitAuthTokens(core::LiteralString domain, db::UserId userId, std::function<void(const AuthTokenInfo& info, std::string_view token)> visitor)
|
||||
{
|
||||
db::Session& session{ getDbSession() };
|
||||
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
{
|
||||
auto transaction{ session.createReadTransaction() };
|
||||
|
||||
db::User::pointer user{ db::User::find(session, userId) };
|
||||
if (!user)
|
||||
throw Exception{ "User deleted" };
|
||||
|
||||
user.modify()->clearAuthTokens();
|
||||
db::AuthToken::find(session, domain.str(), userId, [&](const db::AuthToken::pointer& authToken) {
|
||||
const AuthTokenInfo info{ createAuthTokenInfo(authToken) };
|
||||
visitor(info, authToken->getValue());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void AuthTokenService::clearAuthTokens(core::LiteralString domain, db::UserId userId)
|
||||
{
|
||||
db::Session& session{ getDbSession() };
|
||||
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
db::AuthToken::clearUserTokens(session, domain.str(), userId);
|
||||
}
|
||||
}
|
||||
|
||||
const AuthTokenService::DomainParameters& AuthTokenService::getDomainParameters(core::LiteralString domain) const
|
||||
{
|
||||
auto it{ _domainParameters.find(domain) };
|
||||
if (it == std::cend(_domainParameters))
|
||||
throw Exception{ "Invalid auth token domain" };
|
||||
|
||||
return it->second;
|
||||
}
|
||||
} // namespace lms::auth
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <shared_mutex>
|
||||
|
||||
#include "services/auth/IAuthTokenService.hpp"
|
||||
@@ -36,7 +37,7 @@ namespace lms::auth
|
||||
class AuthTokenService : public IAuthTokenService, public AuthServiceBase
|
||||
{
|
||||
public:
|
||||
AuthTokenService(db::Db& db, std::size_t maxThrottlerEntries);
|
||||
AuthTokenService(db::Db& db, std::size_t maxThrottlerEntryCount);
|
||||
|
||||
AuthTokenService(const AuthTokenService&) = delete;
|
||||
AuthTokenService& operator=(const AuthTokenService&) = delete;
|
||||
@@ -44,13 +45,17 @@ namespace lms::auth
|
||||
AuthTokenService& operator=(AuthTokenService&&) = delete;
|
||||
|
||||
private:
|
||||
AuthTokenProcessResult processAuthToken(const boost::asio::ip::address& clientAddress, std::string_view tokenValue) override;
|
||||
std::string createAuthToken(db::UserId userId, const Wt::WDateTime& expiry) override;
|
||||
void clearAuthTokens(db::UserId userId) override;
|
||||
void registerDomain(core::LiteralString domain, const DomainParameters& params) override;
|
||||
AuthTokenProcessResult processAuthToken(core::LiteralString domain, const boost::asio::ip::address& clientAddress, std::string_view tokenValue) override;
|
||||
void visitAuthTokens(core::LiteralString domain, db::UserId userId, std::function<void(const AuthTokenInfo& info, std::string_view token)> visitor) override;
|
||||
void createAuthToken(core::LiteralString domain, db::UserId userId, std::string_view token) override;
|
||||
void clearAuthTokens(core::LiteralString domain, db::UserId userId) override;
|
||||
|
||||
std::optional<AuthTokenService::AuthTokenProcessResult::AuthTokenInfo> processAuthToken(std::string_view secret);
|
||||
std::optional<AuthTokenInfo> processAuthToken(core::LiteralString domain, std::string_view tokenValue);
|
||||
const DomainParameters& getDomainParameters(core::LiteralString domain) const;
|
||||
|
||||
std::shared_mutex _mutex;
|
||||
std::map<core::LiteralString, DomainParameters> _domainParameters;
|
||||
LoginThrottler _loginThrottler;
|
||||
};
|
||||
} // namespace lms::auth
|
||||
|
||||
@@ -37,28 +37,24 @@ namespace lms::auth
|
||||
{
|
||||
static const Wt::Auth::SHA1HashFunction sha1Function;
|
||||
|
||||
std::unique_ptr<IPasswordService>
|
||||
createPasswordService(std::string_view passwordAuthenticationBackend, db::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService)
|
||||
std::unique_ptr<IPasswordService> createPasswordService(std::string_view backend, db::Db& db, std::size_t maxThrottlerEntryCount)
|
||||
{
|
||||
if (passwordAuthenticationBackend == "internal")
|
||||
return std::make_unique<InternalPasswordService>(db, maxThrottlerEntries, authTokenService);
|
||||
if (backend == "internal")
|
||||
return std::make_unique<InternalPasswordService>(db, maxThrottlerEntryCount);
|
||||
#ifdef LMS_SUPPORT_PAM
|
||||
else if (passwordAuthenticationBackend == "pam")
|
||||
return std::make_unique<PAMPasswordService>(db, maxThrottlerEntries, authTokenService);
|
||||
if (backend == "PAM")
|
||||
return std::make_unique<PAMPasswordService>(db, maxThrottlerEntryCount);
|
||||
#endif // LMS_SUPPORT_PAM
|
||||
|
||||
throw Exception{ "Authentication backend '" + std::string{ passwordAuthenticationBackend } + "' is not supported!" };
|
||||
throw Exception{ "Authentication backend '" + std::string{ backend } + "' not supported!" };
|
||||
}
|
||||
|
||||
PasswordServiceBase::PasswordServiceBase(db::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService)
|
||||
PasswordServiceBase::PasswordServiceBase(db::Db& db, std::size_t maxThrottlerEntries)
|
||||
: AuthServiceBase{ db }
|
||||
, _loginThrottler{ maxThrottlerEntries }
|
||||
, _authTokenService{ authTokenService }
|
||||
{
|
||||
}
|
||||
|
||||
PasswordServiceBase::CheckResult
|
||||
PasswordServiceBase::checkUserPassword(const boost::asio::ip::address& clientAddress, std::string_view loginName, std::string_view password)
|
||||
PasswordServiceBase::CheckResult PasswordServiceBase::checkUserPassword(const boost::asio::ip::address& clientAddress, std::string_view loginName, std::string_view password)
|
||||
{
|
||||
LMS_LOG(AUTH, DEBUG, "Checking password for user '" << loginName << "'");
|
||||
|
||||
|
||||
@@ -36,16 +36,13 @@ namespace lms::auth
|
||||
class PasswordServiceBase : public IPasswordService, public AuthServiceBase
|
||||
{
|
||||
public:
|
||||
PasswordServiceBase(db::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService);
|
||||
PasswordServiceBase(db::Db& db, std::size_t maxThrottlerEntries);
|
||||
|
||||
PasswordServiceBase(const PasswordServiceBase&) = delete;
|
||||
PasswordServiceBase& operator=(const PasswordServiceBase&) = delete;
|
||||
PasswordServiceBase(PasswordServiceBase&&) = delete;
|
||||
PasswordServiceBase& operator=(PasswordServiceBase&&) = delete;
|
||||
|
||||
protected:
|
||||
IAuthTokenService& getAuthTokenService() { return _authTokenService; }
|
||||
|
||||
private:
|
||||
virtual bool checkUserPassword(std::string_view loginName, std::string_view password) = 0;
|
||||
|
||||
@@ -55,6 +52,5 @@ namespace lms::auth
|
||||
|
||||
std::shared_mutex _mutex;
|
||||
LoginThrottler _loginThrottler;
|
||||
IAuthTokenService& _authTokenService;
|
||||
};
|
||||
} // namespace lms::auth
|
||||
|
||||
@@ -38,25 +38,25 @@ namespace lms::auth
|
||||
{
|
||||
const std::string loginName{ env.headerValue(_fieldName) };
|
||||
if (loginName.empty())
|
||||
return { CheckResult::State::Denied };
|
||||
return CheckResult{ .state = CheckResult::State::Denied, .userId = {} };
|
||||
|
||||
LMS_LOG(AUTH, DEBUG, "Extracted login name = '" << loginName << "' from HTTP header");
|
||||
|
||||
const db::UserId userId{ getOrCreateUser(loginName) };
|
||||
onUserAuthenticated(userId);
|
||||
return { CheckResult::State::Granted, userId };
|
||||
return CheckResult{ .state = CheckResult::State::Granted, .userId = userId };
|
||||
}
|
||||
|
||||
HttpHeadersEnvService::CheckResult HttpHeadersEnvService::processRequest(const Wt::Http::Request& request)
|
||||
{
|
||||
const std::string loginName{ request.headerValue(_fieldName) };
|
||||
if (loginName.empty())
|
||||
return { CheckResult::State::Denied };
|
||||
return CheckResult{ .state = CheckResult::State::Denied, .userId = {} };
|
||||
|
||||
LMS_LOG(AUTH, DEBUG, "Extracted login name = '" << loginName << "' from HTTP header");
|
||||
|
||||
const db::UserId userId{ getOrCreateUser(loginName) };
|
||||
onUserAuthenticated(userId);
|
||||
return { CheckResult::State::Granted, userId };
|
||||
return { .state = CheckResult::State::Granted, .userId = userId };
|
||||
}
|
||||
} // namespace lms::auth
|
||||
|
||||
@@ -21,18 +21,21 @@
|
||||
|
||||
#include <Wt/WRandom.h>
|
||||
|
||||
#include "core/Exception.hpp"
|
||||
#include "core/IConfig.hpp"
|
||||
#include "core/ILogger.hpp"
|
||||
#include "database/Session.hpp"
|
||||
#include "database/User.hpp"
|
||||
#include "services/auth/IAuthTokenService.hpp"
|
||||
#include "services/auth/Types.hpp"
|
||||
|
||||
namespace lms::auth
|
||||
{
|
||||
InternalPasswordService::InternalPasswordService(db::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService)
|
||||
: PasswordServiceBase{ db, maxThrottlerEntries, authTokenService }
|
||||
InternalPasswordService::InternalPasswordService(db::Db& db, std::size_t maxThrottlerEntries)
|
||||
: PasswordServiceBase{ db, maxThrottlerEntries }
|
||||
, _bcryptRoundCount{ static_cast<unsigned>(core::Service<core::IConfig>::get()->getULong("internal-password-bcrypt-round", 12)) }
|
||||
{
|
||||
if (_bcryptRoundCount < 7 || _bcryptRoundCount > 31)
|
||||
throw Exception{ "\"internal-password-bcrypt-round\" must be in range 7-31" };
|
||||
|
||||
_validator.setMinimumLength(Wt::Auth::PasswordStrengthType::OneCharClass, 4);
|
||||
_validator.setMinimumLength(Wt::Auth::PasswordStrengthType::TwoCharClass, 4);
|
||||
_validator.setMinimumLength(Wt::Auth::PasswordStrengthType::PassPhrase, 4);
|
||||
@@ -55,7 +58,7 @@ namespace lms::auth
|
||||
if (!user)
|
||||
{
|
||||
LMS_LOG(AUTH, DEBUG, "hashing random stuff");
|
||||
// hash random stuff here to waste some time
|
||||
// hash random stuff here to waste some time, don't give clue the user does not exist
|
||||
hashRandomPassword();
|
||||
return false;
|
||||
}
|
||||
@@ -64,13 +67,29 @@ namespace lms::auth
|
||||
passwordHash = user->getPasswordHash();
|
||||
if (passwordHash.salt.empty() || passwordHash.hash.empty())
|
||||
{
|
||||
// hash random stuff here to waste some time
|
||||
// hash random stuff here to waste some time, don't give clue the user has no password set
|
||||
hashRandomPassword();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return _hashFunc.verify(std::string{ password }, std::string{ passwordHash.salt }, std::string{ passwordHash.hash });
|
||||
// Note: the round count set in the actual hash is used to verify, not the one used to construct _hashFunc
|
||||
bool passwordMatched{ _hashFunc.verify(std::string{ password }, std::string{ passwordHash.salt }, std::string{ passwordHash.hash }) };
|
||||
if (passwordMatched && passwordHash.bcryptRoundCount != _bcryptRoundCount)
|
||||
{
|
||||
LMS_LOG(AUTH, INFO, "Updating password hash for user '" << loginName << "' to match new bcrypt round count: previously " << passwordHash.bcryptRoundCount << " rounds, now " << _bcryptRoundCount << " rounds");
|
||||
const db::User::PasswordHash updatedPasswordHash{ hashPassword(password) };
|
||||
|
||||
{
|
||||
db::Session& session{ getDbSession() };
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
|
||||
if (db::User::pointer user{ db::User::find(session, loginName) })
|
||||
user.modify()->setPasswordHash(updatedPasswordHash);
|
||||
}
|
||||
}
|
||||
|
||||
return passwordMatched;
|
||||
}
|
||||
|
||||
bool InternalPasswordService::canSetPasswords() const
|
||||
@@ -96,38 +115,37 @@ namespace lms::auth
|
||||
{
|
||||
const db::User::PasswordHash passwordHash{ hashPassword(newPassword) };
|
||||
|
||||
db::Session& session{ getDbSession() };
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
|
||||
db::User::pointer user{ db::User::find(session, userId) };
|
||||
if (!user)
|
||||
throw Exception{ "User not found!" };
|
||||
|
||||
switch (checkPasswordAcceptability(newPassword, PasswordValidationContext{ user->getLoginName(), user->getType() }))
|
||||
{
|
||||
case PasswordAcceptabilityResult::OK:
|
||||
break;
|
||||
case PasswordAcceptabilityResult::TooWeak:
|
||||
throw PasswordTooWeakException{};
|
||||
case PasswordAcceptabilityResult::MustMatchLoginName:
|
||||
throw PasswordMustMatchLoginNameException{};
|
||||
}
|
||||
db::Session& session{ getDbSession() };
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
|
||||
user.modify()->setPasswordHash(passwordHash);
|
||||
getAuthTokenService().clearAuthTokens(userId);
|
||||
db::User::pointer user{ db::User::find(session, userId) };
|
||||
if (!user)
|
||||
throw Exception{ "User not found!" };
|
||||
|
||||
switch (checkPasswordAcceptability(newPassword, PasswordValidationContext{ user->getLoginName(), user->getType() }))
|
||||
{
|
||||
case PasswordAcceptabilityResult::OK:
|
||||
break;
|
||||
case PasswordAcceptabilityResult::TooWeak:
|
||||
throw PasswordTooWeakException{};
|
||||
case PasswordAcceptabilityResult::MustMatchLoginName:
|
||||
throw PasswordMustMatchLoginNameException{};
|
||||
}
|
||||
|
||||
user.modify()->setPasswordHash(passwordHash);
|
||||
}
|
||||
}
|
||||
|
||||
db::User::PasswordHash InternalPasswordService::hashPassword(std::string_view password) const
|
||||
{
|
||||
const std::string salt{ Wt::WRandom::generateId(32) };
|
||||
|
||||
return { salt, _hashFunc.compute(std::string{ password }, salt) };
|
||||
return db::User::PasswordHash{ .bcryptRoundCount = _bcryptRoundCount, .salt = salt, .hash = _hashFunc.compute(std::string{ password }, salt) };
|
||||
}
|
||||
|
||||
void
|
||||
InternalPasswordService::hashRandomPassword() const
|
||||
void InternalPasswordService::hashRandomPassword() const
|
||||
{
|
||||
hashPassword(Wt::WRandom::generateId(32));
|
||||
}
|
||||
|
||||
} // namespace lms::auth
|
||||
|
||||
@@ -24,17 +24,15 @@
|
||||
|
||||
#include "database/User.hpp"
|
||||
|
||||
#include "LoginThrottler.hpp"
|
||||
#include "PasswordServiceBase.hpp"
|
||||
#include "services/auth/IPasswordService.hpp"
|
||||
|
||||
namespace lms::auth
|
||||
{
|
||||
class IAuthTokenService;
|
||||
|
||||
class InternalPasswordService : public PasswordServiceBase
|
||||
{
|
||||
public:
|
||||
InternalPasswordService(db::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService);
|
||||
InternalPasswordService(db::Db& db, std::size_t maxThrottlerEntries);
|
||||
|
||||
private:
|
||||
bool checkUserPassword(std::string_view loginName, std::string_view password) override;
|
||||
@@ -46,8 +44,8 @@ namespace lms::auth
|
||||
db::User::PasswordHash hashPassword(std::string_view password) const;
|
||||
void hashRandomPassword() const;
|
||||
|
||||
const Wt::Auth::BCryptHashFunction _hashFunc{ 7 }; // TODO parametrize this
|
||||
const unsigned _bcryptRoundCount;
|
||||
const Wt::Auth::BCryptHashFunction _hashFunc{ static_cast<int>(_bcryptRoundCount) };
|
||||
Wt::Auth::PasswordStrengthValidator _validator;
|
||||
};
|
||||
|
||||
} // namespace lms::auth
|
||||
|
||||
@@ -19,18 +19,21 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <Wt/WDateTime.h>
|
||||
#include <boost/asio/ip/address.hpp>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include <Wt/WDateTime.h>
|
||||
#include <boost/asio/ip/address.hpp>
|
||||
|
||||
#include "core/LiteralString.hpp"
|
||||
#include "database/UserId.hpp"
|
||||
|
||||
namespace lms::db
|
||||
{
|
||||
class Db;
|
||||
class User;
|
||||
} // namespace lms::db
|
||||
|
||||
namespace lms::auth
|
||||
@@ -40,7 +43,15 @@ namespace lms::auth
|
||||
public:
|
||||
virtual ~IAuthTokenService() = default;
|
||||
|
||||
// Auth Token services
|
||||
struct AuthTokenInfo
|
||||
{
|
||||
db::UserId userId;
|
||||
Wt::WDateTime expiry;
|
||||
Wt::WDateTime lastUsed; // if called by processAuthToken, value is before processing
|
||||
std::size_t useCount; // if called by processAuthToken, value is before processing
|
||||
std::optional<std::size_t> maxUseCount;
|
||||
};
|
||||
|
||||
struct AuthTokenProcessResult
|
||||
{
|
||||
enum class State
|
||||
@@ -50,22 +61,25 @@ namespace lms::auth
|
||||
Denied,
|
||||
};
|
||||
|
||||
struct AuthTokenInfo
|
||||
{
|
||||
db::UserId userId;
|
||||
Wt::WDateTime expiry;
|
||||
};
|
||||
|
||||
State state{ State::Denied };
|
||||
std::optional<AuthTokenInfo> authTokenInfo{};
|
||||
};
|
||||
|
||||
// Provided token is only accepted once
|
||||
virtual AuthTokenProcessResult processAuthToken(const boost::asio::ip::address& clientAddress, std::string_view tokenValue) = 0;
|
||||
struct DomainParameters
|
||||
{
|
||||
std::optional<std::size_t> tokenMaxUseCount;
|
||||
std::optional<std::chrono::seconds> tokenDuration;
|
||||
};
|
||||
|
||||
// Returns a one time token
|
||||
virtual std::string createAuthToken(db::UserId userid, const Wt::WDateTime& expiry) = 0;
|
||||
virtual void clearAuthTokens(db::UserId userid) = 0;
|
||||
virtual void registerDomain(core::LiteralString domain, const DomainParameters& params) = 0;
|
||||
|
||||
// Processing an auth token will make its useCount increase by 1. Token is then automatically deleted if its maxUsecount is reached
|
||||
virtual AuthTokenProcessResult processAuthToken(core::LiteralString domain, const boost::asio::ip::address& clientAddress, std::string_view tokenValue) = 0;
|
||||
|
||||
virtual void visitAuthTokens(core::LiteralString domain, db::UserId userid, std::function<void(const AuthTokenInfo& info, std::string_view token)> visitor) = 0;
|
||||
|
||||
virtual void createAuthToken(core::LiteralString domain, db::UserId userid, std::string_view token) = 0;
|
||||
virtual void clearAuthTokens(core::LiteralString domain, db::UserId userid) = 0;
|
||||
};
|
||||
|
||||
std::unique_ptr<IAuthTokenService> createAuthTokenService(db::Db& db, std::size_t maxThrottlerEntryCount);
|
||||
|
||||
@@ -58,12 +58,12 @@ namespace lms::auth
|
||||
};
|
||||
|
||||
State state{ State::Denied };
|
||||
std::optional<db::UserId> userId{};
|
||||
db::UserId userId{};
|
||||
};
|
||||
|
||||
virtual CheckResult processEnv(const Wt::WEnvironment& env) = 0;
|
||||
virtual CheckResult processRequest(const Wt::Http::Request& request) = 0;
|
||||
};
|
||||
|
||||
std::unique_ptr<IEnvService> createEnvService(std::string_view backendName, db::Db& db);
|
||||
std::unique_ptr<IEnvService> createEnvService(std::string_view backend, db::Db& db);
|
||||
} // namespace lms::auth
|
||||
|
||||
@@ -37,8 +37,6 @@ namespace lms::db
|
||||
|
||||
namespace lms::auth
|
||||
{
|
||||
class IAuthTokenService;
|
||||
|
||||
class IPasswordService
|
||||
{
|
||||
public:
|
||||
@@ -53,7 +51,7 @@ namespace lms::auth
|
||||
Throttled,
|
||||
};
|
||||
State state{ State::Denied };
|
||||
std::optional<db::UserId> userId{};
|
||||
db::UserId userId{};
|
||||
std::optional<Wt::WDateTime> expiry{};
|
||||
};
|
||||
virtual CheckResult checkUserPassword(const boost::asio::ip::address& clientAddress,
|
||||
@@ -73,5 +71,5 @@ namespace lms::auth
|
||||
virtual void setPassword(db::UserId userId, std::string_view newPassword) = 0;
|
||||
};
|
||||
|
||||
std::unique_ptr<IPasswordService> createPasswordService(std::string_view authPasswordBackend, db::Db& db, std::size_t maxThrottlerEntryCount, IAuthTokenService& authTokenService);
|
||||
std::unique_ptr<IPasswordService> createPasswordService(std::string_view backend, db::Db& db, std::size_t maxThrottlerEntryCount);
|
||||
} // namespace lms::auth
|
||||
|
||||
@@ -144,7 +144,7 @@ namespace lms::scanner
|
||||
Artist::pointer artist{ session.create<Artist>(artistInfo.name) };
|
||||
|
||||
if (artistInfo.mbid)
|
||||
artist.modify()->setMBID(*artistInfo.mbid);
|
||||
artist.modify()->setMBID(artistInfo.mbid);
|
||||
if (artistInfo.sortName)
|
||||
artist.modify()->setSortName(*artistInfo.sortName);
|
||||
|
||||
@@ -254,6 +254,8 @@ namespace lms::scanner
|
||||
release.modify()->setArtistDisplayName(releaseInfo.artistDisplayName);
|
||||
if (release->isCompilation() != releaseInfo.isCompilation)
|
||||
release.modify()->setCompilation(releaseInfo.isCompilation);
|
||||
if (release->getBarcode() != releaseInfo.barcode)
|
||||
release.modify()->setBarcode(releaseInfo.barcode);
|
||||
if (release->getReleaseTypeNames() != releaseInfo.releaseTypes)
|
||||
{
|
||||
release.modify()->clearReleaseTypes();
|
||||
@@ -276,7 +278,9 @@ namespace lms::scanner
|
||||
return candidateRelease->getName() == releaseInfo.name
|
||||
&& candidateRelease->getSortName() == releaseInfo.sortName
|
||||
&& candidateRelease->getTotalDisc() == releaseInfo.mediumCount
|
||||
&& candidateRelease->isCompilation() == releaseInfo.isCompilation;
|
||||
&& candidateRelease->isCompilation() == releaseInfo.isCompilation
|
||||
&& candidateRelease->getLabelNames() == releaseInfo.labels
|
||||
&& candidateRelease->getBarcode() == releaseInfo.barcode;
|
||||
}
|
||||
|
||||
Release::pointer getOrCreateRelease(Session& session, const metadata::Release& releaseInfo, const Directory::pointer& currentDirectory)
|
||||
@@ -354,7 +358,7 @@ namespace lms::scanner
|
||||
{
|
||||
std::vector<Cluster::pointer> clusters;
|
||||
|
||||
auto getOrCreateClusters{ [&](std::string tag, std::span<const std::string> values) {
|
||||
auto getOrCreateClusters{ [&](std::string_view tag, std::span<const std::string> values) {
|
||||
auto clusterType = ClusterType::find(session, tag);
|
||||
if (!clusterType)
|
||||
clusterType = session.create<ClusterType>(tag);
|
||||
@@ -437,7 +441,6 @@ namespace lms::scanner
|
||||
|
||||
std::vector<FileScanResult> scanResults;
|
||||
|
||||
std::filesystem::path currentDirectory;
|
||||
core::pathUtils::exploreFilesRecursive(
|
||||
mediaLibrary.rootDirectory, [&](std::error_code ec, const std::filesystem::path& path) {
|
||||
LMS_SCOPED_TRACE_DETAILED("Scanner", "OnExploreFile");
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
|
||||
add_library(lmssubsonic SHARED
|
||||
impl/entrypoints/AlbumSongLists.cpp
|
||||
impl/entrypoints/Bookmarks.cpp
|
||||
impl/entrypoints/Browsing.cpp
|
||||
impl/entrypoints/MediaAnnotation.cpp
|
||||
impl/entrypoints/MediaLibraryScanning.cpp
|
||||
impl/entrypoints/MediaRetrieval.cpp
|
||||
impl/entrypoints/Playlists.cpp
|
||||
impl/entrypoints/Searching.cpp
|
||||
impl/entrypoints/System.cpp
|
||||
impl/entrypoints/UserManagement.cpp
|
||||
impl/endpoints/AlbumSongLists.cpp
|
||||
impl/endpoints/Bookmarks.cpp
|
||||
impl/endpoints/Browsing.cpp
|
||||
impl/endpoints/MediaAnnotation.cpp
|
||||
impl/endpoints/MediaLibraryScanning.cpp
|
||||
impl/endpoints/MediaRetrieval.cpp
|
||||
impl/endpoints/Playlists.cpp
|
||||
impl/endpoints/Searching.cpp
|
||||
impl/endpoints/System.cpp
|
||||
impl/endpoints/UserManagement.cpp
|
||||
impl/responses/Album.cpp
|
||||
impl/responses/AlbumInfo.cpp
|
||||
impl/responses/Artist.cpp
|
||||
impl/responses/Bookmark.cpp
|
||||
impl/responses/Contributor.cpp
|
||||
@@ -24,12 +25,12 @@ add_library(lmssubsonic SHARED
|
||||
impl/responses/ReplayGain.cpp
|
||||
impl/responses/Song.cpp
|
||||
impl/responses/User.cpp
|
||||
impl/ResponseFormat.cpp
|
||||
impl/ProtocolVersion.cpp
|
||||
impl/ParameterParsing.cpp
|
||||
impl/SubsonicId.cpp
|
||||
impl/SubsonicResource.cpp
|
||||
impl/SubsonicResponse.cpp
|
||||
impl/Utils.cpp
|
||||
)
|
||||
|
||||
target_include_directories(lmssubsonic INTERFACE
|
||||
|
||||
@@ -27,10 +27,7 @@ namespace lms::api::subsonic
|
||||
{
|
||||
struct ClientInfo
|
||||
{
|
||||
std::string ipAddress;
|
||||
std::string name;
|
||||
std::string user;
|
||||
std::string password;
|
||||
ProtocolVersion version;
|
||||
};
|
||||
} // namespace lms::api::subsonic
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
#include "ClientInfo.hpp"
|
||||
#include "ProtocolVersion.hpp"
|
||||
#include "SubsonicResponse.hpp"
|
||||
#include "ResponseFormat.hpp"
|
||||
|
||||
namespace lms::db
|
||||
{
|
||||
@@ -41,7 +41,8 @@ namespace lms::api::subsonic
|
||||
{
|
||||
const Wt::Http::ParameterMap& parameters;
|
||||
db::Session& dbSession;
|
||||
const db::ObjectPtr<db::User> user;
|
||||
db::ObjectPtr<db::User> user;
|
||||
std::string clientIpAddr;
|
||||
ClientInfo clientInfo;
|
||||
ProtocolVersion serverProtocolVersion;
|
||||
ResponseFormat responseFormat;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (C) 2024 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 "ResponseFormat.hpp"
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace lms::api::subsonic
|
||||
{
|
||||
std::string_view ResponseFormatToMimeType(ResponseFormat format)
|
||||
{
|
||||
switch (format)
|
||||
{
|
||||
case ResponseFormat::xml:
|
||||
return "text/xml";
|
||||
case ResponseFormat::json:
|
||||
return "application/json";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
} // namespace lms::api::subsonic
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Emeric Poupon
|
||||
* Copyright (C) 2024 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
@@ -19,11 +19,15 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace lms::api::subsonic::utils
|
||||
namespace lms::api::subsonic
|
||||
{
|
||||
void checkSetPasswordImplemented();
|
||||
std::string makeNameFilesystemCompatible(std::string_view name);
|
||||
} // namespace lms::api::subsonic::utils
|
||||
enum class ResponseFormat
|
||||
{
|
||||
xml,
|
||||
json,
|
||||
};
|
||||
|
||||
std::string_view ResponseFormatToMimeType(ResponseFormat format);
|
||||
} // namespace lms::api::subsonic
|
||||
@@ -33,7 +33,7 @@
|
||||
#include "database/Db.hpp"
|
||||
#include "database/Session.hpp"
|
||||
#include "database/User.hpp"
|
||||
#include "services/auth/IEnvService.hpp"
|
||||
#include "services/auth/IAuthTokenService.hpp"
|
||||
#include "services/auth/IPasswordService.hpp"
|
||||
|
||||
#include "ParameterParsing.hpp"
|
||||
@@ -41,17 +41,16 @@
|
||||
#include "RequestContext.hpp"
|
||||
#include "SubsonicId.hpp"
|
||||
#include "SubsonicResponse.hpp"
|
||||
#include "Utils.hpp"
|
||||
#include "entrypoints/AlbumSongLists.hpp"
|
||||
#include "entrypoints/Bookmarks.hpp"
|
||||
#include "entrypoints/Browsing.hpp"
|
||||
#include "entrypoints/MediaAnnotation.hpp"
|
||||
#include "entrypoints/MediaLibraryScanning.hpp"
|
||||
#include "entrypoints/MediaRetrieval.hpp"
|
||||
#include "entrypoints/Playlists.hpp"
|
||||
#include "entrypoints/Searching.hpp"
|
||||
#include "entrypoints/System.hpp"
|
||||
#include "entrypoints/UserManagement.hpp"
|
||||
#include "endpoints/AlbumSongLists.hpp"
|
||||
#include "endpoints/Bookmarks.hpp"
|
||||
#include "endpoints/Browsing.hpp"
|
||||
#include "endpoints/MediaAnnotation.hpp"
|
||||
#include "endpoints/MediaLibraryScanning.hpp"
|
||||
#include "endpoints/MediaRetrieval.hpp"
|
||||
#include "endpoints/Playlists.hpp"
|
||||
#include "endpoints/Searching.hpp"
|
||||
#include "endpoints/System.hpp"
|
||||
#include "endpoints/UserManagement.hpp"
|
||||
|
||||
namespace lms::api::subsonic
|
||||
{
|
||||
@@ -132,9 +131,10 @@ namespace lms::api::subsonic
|
||||
return res;
|
||||
}
|
||||
|
||||
void checkUserTypeIsAllowed(RequestContext& context, core::EnumSet<db::UserType> allowedUserTypes)
|
||||
void checkUserTypeIsAllowed(const db::User::pointer& user, core::EnumSet<db::UserType> allowedUserTypes)
|
||||
{
|
||||
if (!allowedUserTypes.contains(context.user->getType()))
|
||||
assert(user);
|
||||
if (!allowedUserTypes.contains(user->getType()))
|
||||
throw UserNotAuthorizedError{};
|
||||
}
|
||||
|
||||
@@ -143,20 +143,24 @@ namespace lms::api::subsonic
|
||||
throw NotImplementedGenericError{};
|
||||
}
|
||||
|
||||
enum class AuthenticationMode
|
||||
{
|
||||
Authenticated,
|
||||
Unauthenticated,
|
||||
};
|
||||
using RequestHandlerFunc = std::function<Response(RequestContext& context)>;
|
||||
using CheckImplementedFunc = std::function<void()>;
|
||||
struct RequestEntryPointInfo
|
||||
{
|
||||
RequestHandlerFunc func;
|
||||
AuthenticationMode authMode{ AuthenticationMode::Authenticated };
|
||||
core::EnumSet<db::UserType> allowedUserTypes{ db::UserType::DEMO, db::UserType::REGULAR, db::UserType::ADMIN };
|
||||
CheckImplementedFunc checkFunc{};
|
||||
};
|
||||
|
||||
const std::unordered_map<core::LiteralString, RequestEntryPointInfo, core::LiteralStringHash, core::LiteralStringEqual> requestEntryPoints{
|
||||
// System
|
||||
{ "/ping", { handlePingRequest } },
|
||||
{ "/getLicense", { handleGetLicenseRequest } },
|
||||
{ "/getOpenSubsonicExtensions", { handleGetOpenSubsonicExtensions } },
|
||||
{ "/getOpenSubsonicExtensions", { handleGetOpenSubsonicExtensions, AuthenticationMode::Unauthenticated } },
|
||||
|
||||
// Browsing
|
||||
{ "/getMusicFolders", { handleGetMusicFoldersRequest } },
|
||||
@@ -170,8 +174,8 @@ namespace lms::api::subsonic
|
||||
{ "/getVideos", { handleNotImplemented } },
|
||||
{ "/getArtistInfo", { handleNotImplemented } },
|
||||
{ "/getArtistInfo2", { handleGetArtistInfo2Request } },
|
||||
{ "/getAlbumInfo", { handleNotImplemented } },
|
||||
{ "/getAlbumInfo2", { handleNotImplemented } },
|
||||
{ "/getAlbumInfo", { handleGetAlbumInfo } },
|
||||
{ "/getAlbumInfo2", { handleGetAlbumInfo2 } },
|
||||
{ "/getSimilarSongs", { handleGetSimilarSongsRequest } },
|
||||
{ "/getSimilarSongs2", { handleGetSimilarSongs2Request } },
|
||||
{ "/getTopSongs", { handleGetTopSongs } },
|
||||
@@ -240,22 +244,22 @@ namespace lms::api::subsonic
|
||||
|
||||
// User management
|
||||
{ "/getUser", { handleGetUserRequest } },
|
||||
{ "/getUsers", { handleGetUsersRequest, { db::UserType::ADMIN } } },
|
||||
{ "/createUser", { handleCreateUserRequest, { db::UserType::ADMIN }, &utils::checkSetPasswordImplemented } },
|
||||
{ "/updateUser", { handleUpdateUserRequest, { db::UserType::ADMIN } } },
|
||||
{ "/deleteUser", { handleDeleteUserRequest, { db::UserType::ADMIN } } },
|
||||
{ "/changePassword", { handleChangePassword, { db::UserType::REGULAR, db::UserType::ADMIN }, &utils::checkSetPasswordImplemented } },
|
||||
{ "/getUsers", { handleGetUsersRequest, AuthenticationMode::Authenticated, { db::UserType::ADMIN } } },
|
||||
{ "/createUser", { handleNotImplemented } },
|
||||
{ "/updateUser", { handleNotImplemented } },
|
||||
{ "/deleteUser", { handleNotImplemented } },
|
||||
{ "/changePassword", { handleNotImplemented } },
|
||||
|
||||
// Bookmarks
|
||||
{ "/getBookmarks", { handleGetBookmarks } },
|
||||
{ "/createBookmark", { handleCreateBookmark } },
|
||||
{ "/deleteBookmark", { handleDeleteBookmark } },
|
||||
{ "/getPlayQueue", { handleNotImplemented } },
|
||||
{ "/savePlayQueue", { handleNotImplemented } },
|
||||
{ "/getPlayQueue", { handleGetPlayQueue } },
|
||||
{ "/savePlayQueue", { handleSavePlayQueue } },
|
||||
|
||||
// Media library scanning
|
||||
{ "/getScanStatus", { Scan::handleGetScanStatus } },
|
||||
{ "/startScan", { Scan::handleStartScan, { db::UserType::ADMIN } } },
|
||||
{ "/startScan", { Scan::handleStartScan, AuthenticationMode::Authenticated, { db::UserType::ADMIN } } },
|
||||
};
|
||||
|
||||
using MediaRetrievalHandlerFunc = std::function<void(RequestContext&, const Wt::Http::Request&, Wt::Http::Response&)>;
|
||||
@@ -278,12 +282,23 @@ namespace lms::api::subsonic
|
||||
TLSMonotonicMemoryResourceCleaner(const TLSMonotonicMemoryResourceCleaner&) = delete;
|
||||
TLSMonotonicMemoryResourceCleaner& operator=(const TLSMonotonicMemoryResourceCleaner&) = delete;
|
||||
};
|
||||
|
||||
db::User::pointer getUserFromUserId(db::Session& session, db::UserId userId)
|
||||
{
|
||||
auto transaction{ session.createReadTransaction() };
|
||||
|
||||
if (db::User::pointer user{ db::User::find(session, userId) })
|
||||
return user;
|
||||
|
||||
throw UserNotAuthorizedError{};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
SubsonicResource::SubsonicResource(db::Db& db)
|
||||
: _serverProtocolVersionsByClient{ readConfigProtocolVersions() }
|
||||
, _openSubsonicDisabledClients{ readOpenSubsonicDisabledClients() }
|
||||
, _defaultReleaseCoverClients{ readDefaultCoverClients() }
|
||||
, _supportUserPasswordAuthentication{ core::Service<core::IConfig>::get()->getBool("api-subsonic-support-user-password-auth", true) }
|
||||
, _db{ db }
|
||||
{
|
||||
}
|
||||
@@ -317,10 +332,11 @@ namespace lms::api::subsonic
|
||||
{
|
||||
LMS_SCOPED_TRACE_OVERVIEW("Subsonic", itEntryPoint->first);
|
||||
|
||||
if (itEntryPoint->second.checkFunc)
|
||||
itEntryPoint->second.checkFunc();
|
||||
|
||||
checkUserTypeIsAllowed(requestContext, itEntryPoint->second.allowedUserTypes);
|
||||
if (itEntryPoint->second.authMode == AuthenticationMode::Authenticated)
|
||||
{
|
||||
requestContext.user = getUserFromUserId(_db.getTLSSession(), authenticateUser(request));
|
||||
checkUserTypeIsAllowed(requestContext.user, itEntryPoint->second.allowedUserTypes);
|
||||
}
|
||||
|
||||
const Response resp{ [&] {
|
||||
LMS_SCOPED_TRACE_DETAILED("Subsonic", "HandleRequest");
|
||||
@@ -343,11 +359,19 @@ namespace lms::api::subsonic
|
||||
{
|
||||
LMS_SCOPED_TRACE_OVERVIEW("Subsonic", itStreamHandler->first);
|
||||
|
||||
// Media retrieval endpoints are always authenticated
|
||||
// Optim: no need to reauth user for each continuation
|
||||
if (!request.continuation())
|
||||
requestContext.user = getUserFromUserId(_db.getTLSSession(), authenticateUser(request));
|
||||
|
||||
itStreamHandler->second(requestContext, request, response);
|
||||
LMS_LOG(API_SUBSONIC, DEBUG, "Request " << requestId << " '" << requestPath << "' handled!");
|
||||
return;
|
||||
}
|
||||
|
||||
// do not disclose unhandled commands for unauthenticated users
|
||||
authenticateUser(request);
|
||||
|
||||
LMS_LOG(API_SUBSONIC, ERROR, "Unhandled command '" << requestPath << "'");
|
||||
throw UnknownEntryPointGenericError{};
|
||||
}
|
||||
@@ -391,16 +415,9 @@ namespace lms::api::subsonic
|
||||
const auto& parameters{ request.getParameterMap() };
|
||||
ClientInfo res;
|
||||
|
||||
if (hasParameter(parameters, "t"))
|
||||
throw TokenAuthenticationNotSupportedForLDAPUsersError{};
|
||||
|
||||
res.ipAddress = request.clientAddress();
|
||||
|
||||
// Mandatory parameters
|
||||
res.name = getMandatoryParameterAs<std::string>(parameters, "c");
|
||||
res.version = getMandatoryParameterAs<ProtocolVersion>(parameters, "v");
|
||||
res.user = getMandatoryParameterAs<std::string>(parameters, "u");
|
||||
res.password = decodePasswordIfNeeded(getMandatoryParameterAs<std::string>(parameters, "p"));
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -409,25 +426,15 @@ namespace lms::api::subsonic
|
||||
{
|
||||
const Wt::Http::ParameterMap& parameters{ request.getParameterMap() };
|
||||
const ClientInfo clientInfo{ getClientInfo(request) };
|
||||
const db::UserId userId{ authenticateUser(request, clientInfo) };
|
||||
bool enableOpenSubsonic{ !_openSubsonicDisabledClients.contains(clientInfo.name) };
|
||||
bool enableDefaultCover{ _defaultReleaseCoverClients.contains(clientInfo.name) };
|
||||
const ResponseFormat format{ getParameterAs<std::string>(request.getParameterMap(), "f").value_or("xml") == "json" ? ResponseFormat::json : ResponseFormat::xml };
|
||||
|
||||
db::User::pointer user;
|
||||
{
|
||||
db::Session& session{ _db.getTLSSession() };
|
||||
auto transaction{ session.createReadTransaction() };
|
||||
|
||||
user = db::User::find(session, userId);
|
||||
if (!user)
|
||||
throw UserNotAuthorizedError{};
|
||||
}
|
||||
|
||||
return RequestContext{
|
||||
.parameters = parameters,
|
||||
.dbSession = _db.getTLSSession(),
|
||||
.user = user,
|
||||
.user = db::User::pointer{},
|
||||
.clientIpAddr = request.clientAddress(),
|
||||
.clientInfo = clientInfo,
|
||||
.serverProtocolVersion = getServerProtocolVersion(clientInfo.name),
|
||||
.responseFormat = format,
|
||||
@@ -436,46 +443,52 @@ namespace lms::api::subsonic
|
||||
};
|
||||
}
|
||||
|
||||
db::UserId SubsonicResource::authenticateUser(const Wt::Http::Request& request, const ClientInfo& clientInfo)
|
||||
db::UserId SubsonicResource::authenticateUser(const Wt::Http::Request& request)
|
||||
{
|
||||
// if the request if a continuation, the user is already authenticated
|
||||
if (request.continuation())
|
||||
const auto& parameters{ request.getParameterMap() };
|
||||
|
||||
if (hasParameter(parameters, "t"))
|
||||
throw TokenAuthenticationNotSupportedForLDAPUsersError{};
|
||||
|
||||
const auto user{ getParameterAs<std::string>(parameters, "u") };
|
||||
const auto password{ getParameterAs<std::string>(parameters, "p") };
|
||||
if (!_supportUserPasswordAuthentication && (password || user))
|
||||
throw ProvidedAuthenticationMechanismNotSupportedError{};
|
||||
|
||||
const auto apiKey{ getParameterAs<std::string>(parameters, "apiKey") };
|
||||
|
||||
if (user && !password)
|
||||
throw RequiredParameterMissingError{ "p" };
|
||||
if (!user && password)
|
||||
throw RequiredParameterMissingError{ "u" };
|
||||
if (apiKey && password)
|
||||
throw MultipleConflictingAuthenticationMechanismsProvidedError{};
|
||||
if (!apiKey && !password)
|
||||
throw RequiredParameterMissingError{ "apiKey" };
|
||||
|
||||
const auto clientAddress{ boost::asio::ip::address::from_string(request.clientAddress()) };
|
||||
const std::string authToken{ apiKey ? *apiKey : decodePasswordIfNeeded(*password) };
|
||||
|
||||
const auto authResult{ core::Service<auth::IAuthTokenService>::get()->processAuthToken("subsonic", clientAddress, authToken) };
|
||||
switch (authResult.state)
|
||||
{
|
||||
db::Session& session{ _db.getTLSSession() };
|
||||
auto transaction{ session.createReadTransaction() };
|
||||
|
||||
const auto user{ db::User::find(session, clientInfo.user) };
|
||||
if (!user)
|
||||
throw UserNotAuthorizedError{};
|
||||
|
||||
return user->getId();
|
||||
}
|
||||
|
||||
if (auto* authEnvService{ core::Service<auth::IEnvService>::get() })
|
||||
{
|
||||
const auto checkResult{ authEnvService->processRequest(request) };
|
||||
if (checkResult.state != auth::IEnvService::CheckResult::State::Granted)
|
||||
throw UserNotAuthorizedError{};
|
||||
|
||||
return *checkResult.userId;
|
||||
}
|
||||
else if (auto* authPasswordService{ core::Service<auth::IPasswordService>::get() })
|
||||
{
|
||||
const auto checkResult{ authPasswordService->checkUserPassword(boost::asio::ip::address::from_string(request.clientAddress()), clientInfo.user, clientInfo.password) };
|
||||
|
||||
switch (checkResult.state)
|
||||
case auth::IAuthTokenService::AuthTokenProcessResult::State::Granted:
|
||||
if (user)
|
||||
{
|
||||
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{};
|
||||
const auto authenticatedUser{ getUserFromUserId(_db.getTLSSession(), authResult.authTokenInfo->userId) };
|
||||
if (!authenticatedUser || authenticatedUser->getLoginName() != *user)
|
||||
throw WrongUsernameOrPasswordError{};
|
||||
}
|
||||
return authResult.authTokenInfo->userId;
|
||||
case auth::IAuthTokenService::AuthTokenProcessResult::State::Denied:
|
||||
if (apiKey)
|
||||
throw InvalidAPIkeyError{};
|
||||
else
|
||||
throw WrongUsernameOrPasswordError{};
|
||||
case auth::IAuthTokenService::AuthTokenProcessResult::State::Throttled:
|
||||
throw LoginThrottledGenericError{};
|
||||
}
|
||||
|
||||
throw InternalErrorGenericError{ "No service available to authenticate user" };
|
||||
throw InternalErrorGenericError{ "Cannot authenticate user" };
|
||||
}
|
||||
|
||||
} // namespace lms::api::subsonic
|
||||
|
||||
@@ -50,11 +50,12 @@ namespace lms::api::subsonic
|
||||
static void checkProtocolVersion(ProtocolVersion client, ProtocolVersion server);
|
||||
ClientInfo getClientInfo(const Wt::Http::Request& request);
|
||||
RequestContext buildRequestContext(const Wt::Http::Request& request);
|
||||
db::UserId authenticateUser(const Wt::Http::Request& request, const ClientInfo& clientInfo);
|
||||
db::UserId authenticateUser(const Wt::Http::Request& request);
|
||||
|
||||
const std::unordered_map<std::string, ProtocolVersion> _serverProtocolVersionsByClient;
|
||||
const std::unordered_set<std::string> _openSubsonicDisabledClients;
|
||||
const std::unordered_set<std::string> _defaultReleaseCoverClients;
|
||||
const bool _supportUserPasswordAuthentication;
|
||||
|
||||
db::Db& _db;
|
||||
};
|
||||
|
||||
@@ -32,19 +32,6 @@
|
||||
|
||||
namespace lms::api::subsonic
|
||||
{
|
||||
std::string_view ResponseFormatToMimeType(ResponseFormat format)
|
||||
{
|
||||
switch (format)
|
||||
{
|
||||
case ResponseFormat::xml:
|
||||
return "text/xml";
|
||||
case ResponseFormat::json:
|
||||
return "application/json";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
void Response::Node::setValue(std::string_view value)
|
||||
{
|
||||
assert(_children.empty() && _childrenArrays.empty() && _childrenValues.empty());
|
||||
@@ -215,7 +202,10 @@ namespace lms::api::subsonic
|
||||
{
|
||||
for (const auto& [key, childNode] : node._children)
|
||||
{
|
||||
res.add_child(std::string{ key.str() }, nodeToPropertyTree(childNode));
|
||||
boost::property_tree::ptree& tree{ res.add_child(std::string{ key.str() }, nodeToPropertyTree(childNode)) };
|
||||
// Hardcoded attribute to simplify createOkResponse calls
|
||||
if (key == "subsonic-response")
|
||||
tree.put("<xmlattr>.xmlns", "http://subsonic.org/restapi");
|
||||
}
|
||||
|
||||
for (const auto& [key, childArrayNodes] : node._childrenArrays)
|
||||
|
||||
@@ -28,20 +28,13 @@
|
||||
#include "core/LiteralString.hpp"
|
||||
|
||||
#include "ProtocolVersion.hpp"
|
||||
#include "ResponseFormat.hpp"
|
||||
#include "SubsonicResponseAllocator.hpp"
|
||||
|
||||
namespace lms::api::subsonic
|
||||
{
|
||||
// Max count expected from all API methods that expose a count
|
||||
static inline constexpr std::size_t defaultMaxCountSize{ 1000 };
|
||||
|
||||
enum class ResponseFormat
|
||||
{
|
||||
xml,
|
||||
json,
|
||||
};
|
||||
|
||||
std::string_view ResponseFormatToMimeType(ResponseFormat format);
|
||||
static inline constexpr std::size_t defaultMaxCountSize{ 1'000 };
|
||||
|
||||
class Error
|
||||
{
|
||||
@@ -54,6 +47,9 @@ namespace lms::api::subsonic
|
||||
ServerMustUpgrade = 30,
|
||||
WrongUsernameOrPassword = 40,
|
||||
TokenAuthenticationNotSupportedForLDAPUsers = 41,
|
||||
ProvidedAuthenticationMechanismNotSupported = 42,
|
||||
MultipleConflictingAuthenticationMechanismsProvided = 43,
|
||||
InvalidAPIkey = 44,
|
||||
UserNotAuthorized = 50,
|
||||
RequestedDataNotFound = 70,
|
||||
};
|
||||
@@ -130,6 +126,45 @@ namespace lms::api::subsonic
|
||||
std::string getMessage() const override { return "Token authentication not supported for LDAP users."; }
|
||||
};
|
||||
|
||||
class ProvidedAuthenticationMechanismNotSupportedError : public Error
|
||||
{
|
||||
public:
|
||||
ProvidedAuthenticationMechanismNotSupportedError()
|
||||
: Error{ Code::ProvidedAuthenticationMechanismNotSupported } {}
|
||||
|
||||
private:
|
||||
std::string getMessage() const override
|
||||
{
|
||||
return "Provided authentication mechanism not supported.";
|
||||
}
|
||||
};
|
||||
|
||||
class MultipleConflictingAuthenticationMechanismsProvidedError : public Error
|
||||
{
|
||||
public:
|
||||
MultipleConflictingAuthenticationMechanismsProvidedError()
|
||||
: Error{ Code::MultipleConflictingAuthenticationMechanismsProvided } {}
|
||||
|
||||
private:
|
||||
std::string getMessage() const override
|
||||
{
|
||||
return "Multiple conflicting authentication mechanisms provided.";
|
||||
}
|
||||
};
|
||||
|
||||
class InvalidAPIkeyError : public Error
|
||||
{
|
||||
public:
|
||||
InvalidAPIkeyError()
|
||||
: Error{ Code::InvalidAPIkey } {}
|
||||
|
||||
private:
|
||||
std::string getMessage() const override
|
||||
{
|
||||
return "Invalid API key.";
|
||||
}
|
||||
};
|
||||
|
||||
class UserNotAuthorizedError : public Error
|
||||
{
|
||||
public:
|
||||
@@ -153,7 +188,7 @@ namespace lms::api::subsonic
|
||||
class InternalErrorGenericError : public GenericError
|
||||
{
|
||||
public:
|
||||
InternalErrorGenericError(const std::string& message)
|
||||
InternalErrorGenericError(std::string_view message)
|
||||
: _message{ message } {}
|
||||
|
||||
private:
|
||||
@@ -176,26 +211,6 @@ namespace lms::api::subsonic
|
||||
std::string getMessage() const override { return "Unknown API method"; }
|
||||
};
|
||||
|
||||
class PasswordTooWeakGenericError : public GenericError
|
||||
{
|
||||
std::string getMessage() const override { return "Password too weak"; }
|
||||
};
|
||||
|
||||
class PasswordMustMatchLoginNameGenericError : public GenericError
|
||||
{
|
||||
std::string getMessage() const override { return "Password must match login name"; }
|
||||
};
|
||||
|
||||
class DemoUserCannotChangePasswordGenericError : public GenericError
|
||||
{
|
||||
std::string getMessage() const override { return "Demo user cannot change its password"; }
|
||||
};
|
||||
|
||||
class UserAlreadyExistsGenericError : public GenericError
|
||||
{
|
||||
std::string getMessage() const override { return "User already exists"; }
|
||||
};
|
||||
|
||||
class BadParameterGenericError : public GenericError
|
||||
{
|
||||
public:
|
||||
@@ -222,7 +237,7 @@ namespace lms::api::subsonic
|
||||
std::size_t _max;
|
||||
};
|
||||
|
||||
class Response
|
||||
class Response final
|
||||
{
|
||||
public:
|
||||
class Node
|
||||
@@ -284,7 +299,7 @@ namespace lms::api::subsonic
|
||||
static Response createOkResponse(ProtocolVersion protocolVersion);
|
||||
static Response createFailedResponse(ProtocolVersion protocolVersion, const Error& error);
|
||||
|
||||
virtual ~Response() {}
|
||||
~Response() = default;
|
||||
Response(const Response&) = delete;
|
||||
Response& operator=(const Response&) = delete;
|
||||
Response(Response&&) = default;
|
||||
|
||||
+80
@@ -19,6 +19,7 @@
|
||||
|
||||
#include "Bookmarks.hpp"
|
||||
|
||||
#include "database/PlayQueue.hpp"
|
||||
#include "database/Session.hpp"
|
||||
#include "database/Track.hpp"
|
||||
#include "database/TrackBookmark.hpp"
|
||||
@@ -29,6 +30,8 @@
|
||||
#include "responses/Bookmark.hpp"
|
||||
#include "responses/Song.hpp"
|
||||
|
||||
#include "core/String.hpp"
|
||||
|
||||
namespace lms::api::subsonic
|
||||
{
|
||||
using namespace db;
|
||||
@@ -93,4 +96,81 @@ namespace lms::api::subsonic
|
||||
|
||||
return Response::createOkResponse(context.serverProtocolVersion);
|
||||
}
|
||||
|
||||
// Use a dedicated internal playlist
|
||||
Response handleGetPlayQueue(RequestContext& context)
|
||||
{
|
||||
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
|
||||
|
||||
auto transaction{ context.dbSession.createReadTransaction() };
|
||||
const db::PlayQueue::pointer playQueue{ db::PlayQueue::find(context.dbSession, context.user->getId(), "subsonic") };
|
||||
if (playQueue)
|
||||
{
|
||||
Response::Node& playQueueNode{ response.createNode("playQueue") };
|
||||
if (auto currentTrack{ playQueue->getTrackAtCurrentIndex() })
|
||||
{
|
||||
// optional fields
|
||||
playQueueNode.setAttribute("current", idToString(currentTrack->getId()));
|
||||
playQueueNode.setAttribute("position", playQueue->getCurrentPositionInTrack().count());
|
||||
}
|
||||
|
||||
// mandatory fields
|
||||
playQueueNode.setAttribute("username", context.user->getLoginName());
|
||||
playQueueNode.setAttribute("changed", core::stringUtils::toISO8601String(playQueue->getLastModifiedDateTime()));
|
||||
playQueueNode.setAttribute("changedBy", "unknown"); // we don't store the client name (could be several same clients on several devices...)
|
||||
|
||||
playQueue->visitTracks([&](const db::Track::pointer& track) {
|
||||
playQueueNode.addArrayChild("entry", createSongNode(context, track, true /* id3 */));
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
Response handleSavePlayQueue(RequestContext& context)
|
||||
{
|
||||
// optional params
|
||||
std::vector<db::TrackId> trackIds{ getMultiParametersAs<TrackId>(context.parameters, "id") };
|
||||
const std::optional<db::TrackId> currentTrackId{ getParameterAs<db::TrackId>(context.parameters, "current") };
|
||||
const std::chrono::milliseconds currentPositionInTrack{ getParameterAs<std::size_t>(context.parameters, "current").value_or(0) };
|
||||
|
||||
std::vector<db::Track::pointer> tracks;
|
||||
tracks.reserve(trackIds.size());
|
||||
|
||||
// no id means we clear the play queue (see https://github.com/opensubsonic/open-subsonic-api/pull/106)
|
||||
if (!trackIds.empty())
|
||||
{
|
||||
auto transaction{ context.dbSession.createReadTransaction() };
|
||||
for (db::TrackId trackId : trackIds)
|
||||
{
|
||||
if (db::Track::pointer track{ db::Track::find(context.dbSession, trackId) })
|
||||
tracks.push_back(track);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
auto transaction{ context.dbSession.createWriteTransaction() };
|
||||
|
||||
db::PlayQueue::pointer playQueue{ db::PlayQueue::find(context.dbSession, context.user->getId(), "subsonic") };
|
||||
if (!playQueue)
|
||||
playQueue = context.dbSession.create<db::PlayQueue>(context.user, "subsonic");
|
||||
|
||||
playQueue.modify()->clear();
|
||||
std::size_t index{};
|
||||
for (std::size_t i{}; i < tracks.size(); ++i)
|
||||
{
|
||||
db::Track::pointer& track{ tracks[i] };
|
||||
playQueue.modify()->addTrack(track);
|
||||
|
||||
if (track->getId() == currentTrackId)
|
||||
index = i;
|
||||
}
|
||||
|
||||
playQueue.modify()->setCurrentIndex(index);
|
||||
playQueue.modify()->setCurrentPositionInTrack(currentPositionInTrack);
|
||||
playQueue.modify()->setLastModifiedDateTime(Wt::WDateTime::currentDateTime());
|
||||
}
|
||||
|
||||
return Response::createOkResponse(context.serverProtocolVersion);
|
||||
}
|
||||
} // namespace lms::api::subsonic
|
||||
+2
@@ -27,4 +27,6 @@ namespace lms::api::subsonic
|
||||
Response handleGetBookmarks(RequestContext& context);
|
||||
Response handleCreateBookmark(RequestContext& context);
|
||||
Response handleDeleteBookmark(RequestContext& context);
|
||||
Response handleGetPlayQueue(RequestContext& context);
|
||||
Response handleSavePlayQueue(RequestContext& context);
|
||||
} // namespace lms::api::subsonic
|
||||
+37
-7
@@ -36,8 +36,8 @@
|
||||
|
||||
#include "ParameterParsing.hpp"
|
||||
#include "SubsonicId.hpp"
|
||||
#include "Utils.hpp"
|
||||
#include "responses/Album.hpp"
|
||||
#include "responses/AlbumInfo.hpp"
|
||||
#include "responses/Artist.hpp"
|
||||
#include "responses/Genre.hpp"
|
||||
#include "responses/Song.hpp"
|
||||
@@ -56,12 +56,11 @@ namespace lms::api::subsonic
|
||||
|
||||
if (libraryId.isValid())
|
||||
{
|
||||
const MediaLibrary::pointer library{ MediaLibrary::find(session, libraryId) };
|
||||
if (!library)
|
||||
throw BadParameterGenericError{ "id" };
|
||||
|
||||
if (Directory::pointer rootDirectory{ Directory::find(session, library->getPath()) })
|
||||
res.push_back(rootDirectory);
|
||||
if (const MediaLibrary::pointer library{ MediaLibrary::find(session, libraryId) })
|
||||
{
|
||||
if (Directory::pointer rootDirectory{ Directory::find(session, library->getPath()) })
|
||||
res.push_back(rootDirectory);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -560,6 +559,37 @@ namespace lms::api::subsonic
|
||||
return response;
|
||||
}
|
||||
|
||||
Response handleGetAlbumInfo(RequestContext& context)
|
||||
{
|
||||
const db::DirectoryId directoryId{ getMandatoryParameterAs<db::DirectoryId>(context.parameters, "id") };
|
||||
|
||||
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
|
||||
|
||||
{
|
||||
auto transaction{ context.dbSession.createReadTransaction() };
|
||||
|
||||
if (db::Release::pointer release{ getReleaseFromDirectory(context.dbSession, directoryId) })
|
||||
response.addNode("albumInfo", createAlbumInfoNode(context, release));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
Response handleGetAlbumInfo2(RequestContext& context)
|
||||
{
|
||||
const db::ReleaseId releaseId{ getMandatoryParameterAs<db::ReleaseId>(context.parameters, "id") };
|
||||
|
||||
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
|
||||
|
||||
{
|
||||
auto transaction{ context.dbSession.createReadTransaction() };
|
||||
|
||||
if (db::Release::pointer release{ db::Release::find(context.dbSession, releaseId) })
|
||||
response.addNode("albumInfo", createAlbumInfoNode(context, release));
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
Response handleGetSimilarSongsRequest(RequestContext& context)
|
||||
{
|
||||
return handleGetSimilarSongsRequestCommon(context, false /* no id3 */);
|
||||
+2
@@ -34,6 +34,8 @@ namespace lms::api::subsonic
|
||||
Response handleGetSongRequest(RequestContext& context);
|
||||
Response handleGetArtistInfoRequest(RequestContext& context);
|
||||
Response handleGetArtistInfo2Request(RequestContext& context);
|
||||
Response handleGetAlbumInfo(RequestContext& context);
|
||||
Response handleGetAlbumInfo2(RequestContext& context);
|
||||
Response handleGetSimilarSongsRequest(RequestContext& context);
|
||||
Response handleGetSimilarSongs2Request(RequestContext& context);
|
||||
Response handleGetTopSongs(RequestContext& context);
|
||||
+1
@@ -36,6 +36,7 @@
|
||||
#include "services/artwork/IArtworkService.hpp"
|
||||
|
||||
#include "ParameterParsing.hpp"
|
||||
#include "RequestContext.hpp"
|
||||
#include "SubsonicId.hpp"
|
||||
#include "responses/Lyrics.hpp"
|
||||
|
||||
+3
-1
@@ -22,10 +22,12 @@
|
||||
#include <Wt/Http/Request.h>
|
||||
#include <Wt/Http/Response.h>
|
||||
|
||||
#include "RequestContext.hpp"
|
||||
#include "SubsonicResponse.hpp"
|
||||
|
||||
namespace lms::api::subsonic
|
||||
{
|
||||
class RequestContext;
|
||||
|
||||
Response handleGetLyrics(RequestContext& context);
|
||||
Response handleGetLyricsBySongId(RequestContext& context);
|
||||
|
||||
+7
-7
@@ -54,7 +54,7 @@ namespace lms::api::subsonic
|
||||
{
|
||||
std::string clientAddress;
|
||||
std::string clientName;
|
||||
std::string userName;
|
||||
UserId user;
|
||||
MediaLibraryId library;
|
||||
std::size_t offset{};
|
||||
auto operator<=>(const ScanInfo&) const = default;
|
||||
@@ -177,9 +177,9 @@ namespace lms::api::subsonic
|
||||
else
|
||||
{
|
||||
ScanTracker<ArtistId>::ScanInfo scanInfo{
|
||||
.clientAddress = context.clientInfo.ipAddress,
|
||||
.clientAddress = context.clientIpAddr,
|
||||
.clientName = context.clientInfo.name,
|
||||
.userName = context.clientInfo.user,
|
||||
.user = context.user->getId(),
|
||||
.library = mediaLibrary,
|
||||
.offset = artistOffset
|
||||
};
|
||||
@@ -241,9 +241,9 @@ namespace lms::api::subsonic
|
||||
else
|
||||
{
|
||||
ScanTracker<ReleaseId>::ScanInfo scanInfo{
|
||||
.clientAddress = context.clientInfo.ipAddress,
|
||||
.clientAddress = context.clientIpAddr,
|
||||
.clientName = context.clientInfo.name,
|
||||
.userName = context.clientInfo.user,
|
||||
.user = context.user->getId(),
|
||||
.library = mediaLibrary,
|
||||
.offset = albumOffset
|
||||
};
|
||||
@@ -305,9 +305,9 @@ namespace lms::api::subsonic
|
||||
else
|
||||
{
|
||||
ScanTracker<TrackId>::ScanInfo scanInfo{
|
||||
.clientAddress = context.clientInfo.ipAddress,
|
||||
.clientAddress = context.clientIpAddr,
|
||||
.clientName = context.clientInfo.name,
|
||||
.userName = context.clientInfo.user,
|
||||
.user = context.user->getId(),
|
||||
.library = mediaLibrary,
|
||||
.offset = songOffset
|
||||
};
|
||||
+7
-1
@@ -1,4 +1,4 @@
|
||||
#include "entrypoints/System.hpp"
|
||||
#include "endpoints/System.hpp"
|
||||
|
||||
namespace lms::api::subsonic
|
||||
{
|
||||
@@ -41,6 +41,12 @@ namespace lms::api::subsonic
|
||||
songLyricsNode.addArrayValue("versions", 1);
|
||||
}
|
||||
|
||||
{
|
||||
Response::Node& apiKeyAuthentication{ response.createArrayNode("openSubsonicExtensions") };
|
||||
apiKeyAuthentication.setAttribute("name", "apiKeyAuthentication");
|
||||
apiKeyAuthentication.addArrayValue("versions", 1);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
} // namespace lms::api::subsonic
|
||||
@@ -0,0 +1,54 @@
|
||||
#include "UserManagement.hpp"
|
||||
|
||||
#include "core/Service.hpp"
|
||||
#include "database/Session.hpp"
|
||||
#include "database/User.hpp"
|
||||
#include "services/auth/IPasswordService.hpp"
|
||||
|
||||
#include "ParameterParsing.hpp"
|
||||
#include "responses/User.hpp"
|
||||
|
||||
namespace lms::api::subsonic
|
||||
{
|
||||
using namespace db;
|
||||
|
||||
namespace
|
||||
{
|
||||
void checkUserIsMySelfOrAdmin(RequestContext& context, const std::string& username)
|
||||
{
|
||||
if (context.user->getLoginName() != username && !context.user->isAdmin())
|
||||
throw UserNotAuthorizedError{};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Response handleGetUserRequest(RequestContext& context)
|
||||
{
|
||||
std::string username{ getMandatoryParameterAs<std::string>(context.parameters, "username") };
|
||||
|
||||
auto transaction{ context.dbSession.createReadTransaction() };
|
||||
|
||||
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(context, user));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
Response handleGetUsersRequest(RequestContext& context)
|
||||
{
|
||||
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
|
||||
Response::Node& usersNode{ response.createNode("users") };
|
||||
|
||||
auto transaction{ context.dbSession.createReadTransaction() };
|
||||
User::find(context.dbSession, User::FindParameters{}, [&](const User::pointer& user) {
|
||||
usersNode.addArrayChild("user", createUserNode(context, user));
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
} // namespace lms::api::subsonic
|
||||
@@ -1,201 +0,0 @@
|
||||
#include "UserManagement.hpp"
|
||||
|
||||
#include "core/Service.hpp"
|
||||
#include "database/Session.hpp"
|
||||
#include "database/User.hpp"
|
||||
#include "services/auth/IPasswordService.hpp"
|
||||
|
||||
#include "ParameterParsing.hpp"
|
||||
#include "Utils.hpp"
|
||||
#include "responses/User.hpp"
|
||||
|
||||
namespace lms::api::subsonic
|
||||
{
|
||||
using namespace db;
|
||||
|
||||
namespace
|
||||
{
|
||||
void checkUserIsMySelfOrAdmin(RequestContext& context, const std::string& username)
|
||||
{
|
||||
if (context.user->getLoginName() != username && !context.user->isAdmin())
|
||||
throw UserNotAuthorizedError{};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Response handleGetUserRequest(RequestContext& context)
|
||||
{
|
||||
std::string username{ getMandatoryParameterAs<std::string>(context.parameters, "username") };
|
||||
|
||||
auto transaction{ context.dbSession.createReadTransaction() };
|
||||
|
||||
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(context, user));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
Response handleGetUsersRequest(RequestContext& context)
|
||||
{
|
||||
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
|
||||
Response::Node& usersNode{ response.createNode("users") };
|
||||
|
||||
auto transaction{ context.dbSession.createReadTransaction() };
|
||||
User::find(context.dbSession, User::FindParameters{}, [&](const User::pointer& user) {
|
||||
usersNode.addArrayChild("user", createUserNode(context, 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
|
||||
|
||||
db::UserId userId;
|
||||
{
|
||||
auto transaction{ context.dbSession.createWriteTransaction() };
|
||||
|
||||
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.createWriteTransaction() };
|
||||
User::pointer user{ User::find(context.dbSession, userId) };
|
||||
if (user)
|
||||
user.remove();
|
||||
} };
|
||||
|
||||
try
|
||||
{
|
||||
core::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.createWriteTransaction() };
|
||||
|
||||
User::pointer user{ User::find(context.dbSession, username) };
|
||||
if (!user)
|
||||
throw RequestedDataNotFoundError{};
|
||||
|
||||
// cannot delete ourself
|
||||
if (user->getId() == context.user->getId())
|
||||
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.createReadTransaction() };
|
||||
|
||||
User::pointer user{ User::find(context.dbSession, username) };
|
||||
if (!user)
|
||||
throw RequestedDataNotFoundError{};
|
||||
|
||||
userId = user->getId();
|
||||
}
|
||||
|
||||
if (password)
|
||||
{
|
||||
utils::checkSetPasswordImplemented();
|
||||
|
||||
try
|
||||
{
|
||||
core::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
|
||||
{
|
||||
db::UserId userId;
|
||||
{
|
||||
auto transaction{ context.dbSession.createReadTransaction() };
|
||||
|
||||
checkUserIsMySelfOrAdmin(context, username);
|
||||
|
||||
User::pointer user{ User::find(context.dbSession, username) };
|
||||
if (!user)
|
||||
throw UserNotAuthorizedError{};
|
||||
|
||||
userId = user->getId();
|
||||
}
|
||||
|
||||
core::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);
|
||||
}
|
||||
} // namespace lms::api::subsonic
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (C) 2024 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 "AlbumInfo.hpp"
|
||||
|
||||
#include "database/Release.hpp"
|
||||
|
||||
#include "RequestContext.hpp"
|
||||
|
||||
namespace lms::api::subsonic
|
||||
{
|
||||
Response::Node createAlbumInfoNode(RequestContext& context, const db::ObjectPtr<db::Release>& release)
|
||||
{
|
||||
Response::Node albumInfo;
|
||||
|
||||
if (const auto releaseMBID{ release->getMBID() })
|
||||
{
|
||||
switch (context.responseFormat)
|
||||
{
|
||||
case ResponseFormat::json:
|
||||
albumInfo.setAttribute("musicBrainzId", releaseMBID->getAsString());
|
||||
break;
|
||||
case ResponseFormat::xml:
|
||||
albumInfo.createChild("musicBrainzId").setValue(releaseMBID->getAsString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return albumInfo;
|
||||
}
|
||||
} // namespace lms::api::subsonic
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Emeric Poupon
|
||||
* Copyright (C) 2024 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
@@ -17,26 +17,20 @@
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "Utils.hpp"
|
||||
#pragma once
|
||||
|
||||
#include "core/Service.hpp"
|
||||
#include "core/String.hpp"
|
||||
#include "services/auth/IPasswordService.hpp"
|
||||
#include "database/Object.hpp"
|
||||
|
||||
#include "SubsonicResponse.hpp"
|
||||
|
||||
namespace lms::api::subsonic::utils
|
||||
namespace lms::db
|
||||
{
|
||||
void checkSetPasswordImplemented()
|
||||
{
|
||||
auth::IPasswordService* passwordService{ core::Service<auth::IPasswordService>::get() };
|
||||
if (!passwordService || !passwordService->canSetPasswords())
|
||||
throw NotImplementedGenericError{};
|
||||
}
|
||||
class Release;
|
||||
} // namespace lms::db
|
||||
|
||||
std::string makeNameFilesystemCompatible(std::string_view name)
|
||||
{
|
||||
return core::stringUtils::replaceInString(name, "/", "_");
|
||||
}
|
||||
namespace lms::api::subsonic
|
||||
{
|
||||
struct RequestContext;
|
||||
|
||||
} // namespace lms::api::subsonic::utils
|
||||
Response::Node createAlbumInfoNode(RequestContext& context, const db::ObjectPtr<db::Release>& release);
|
||||
} // namespace lms::api::subsonic
|
||||
@@ -38,7 +38,6 @@
|
||||
|
||||
#include "RequestContext.hpp"
|
||||
#include "SubsonicId.hpp"
|
||||
#include "Utils.hpp"
|
||||
#include "responses/Artist.hpp"
|
||||
#include "responses/Contributor.hpp"
|
||||
#include "responses/ItemGenre.hpp"
|
||||
|
||||
+40
-13
@@ -45,6 +45,7 @@
|
||||
#include "services/scanner/IScannerService.hpp"
|
||||
#include "services/scrobbling/IScrobblingService.hpp"
|
||||
#include "subsonic/SubsonicResource.hpp"
|
||||
#include "ui/Auth.hpp"
|
||||
#include "ui/LmsApplication.hpp"
|
||||
#include "ui/LmsApplicationManager.hpp"
|
||||
#include "ui/LmsInitApplication.hpp"
|
||||
@@ -79,6 +80,19 @@ namespace lms
|
||||
throw core::LmsException{ "Invalid config value for 'log-min-severity'" };
|
||||
}
|
||||
|
||||
ui::AuthenticationBackend getUIAuthenticationBackend()
|
||||
{
|
||||
const std::string backend{ core::stringUtils::stringToLower(core::Service<core::IConfig>::get()->getString("authentication-backend", "internal")) };
|
||||
if (backend == "internal")
|
||||
return ui::AuthenticationBackend::Internal;
|
||||
if (backend == "pam")
|
||||
return ui::AuthenticationBackend::PAM;
|
||||
if (backend == "http-headers")
|
||||
return ui::AuthenticationBackend::Env;
|
||||
|
||||
throw core::LmsException{ "Invalid config value for 'authentication-backend'" };
|
||||
}
|
||||
|
||||
std::optional<core::tracing::Level> getTracingLevel()
|
||||
{
|
||||
std::string_view tracingLevel{ core::Service<core::IConfig>::get()->getString("tracing-level", "disabled") };
|
||||
@@ -320,24 +334,37 @@ namespace lms
|
||||
|
||||
ui::LmsApplicationManager appManager;
|
||||
|
||||
const std::size_t loginThrottlerMaxEntries{ config->getULong("login-throttler-max-entries", 10'000) };
|
||||
// Service initialization order is important (reverse-order for deinit)
|
||||
core::Service<core::IChildProcessManager> childProcessManagerService{ core::createChildProcessManager(ioContext) };
|
||||
core::Service<auth::IAuthTokenService> authTokenService;
|
||||
|
||||
const ui::AuthenticationBackend uiAuthenticationBackend{ getUIAuthenticationBackend() };
|
||||
core::Service<auth::IAuthTokenService> authTokenService{ auth::createAuthTokenService(database, config->getULong("login-throttler-max-entriees", 10'000)) };
|
||||
core::Service<auth::IPasswordService> authPasswordService;
|
||||
core::Service<auth::IEnvService> authEnvService;
|
||||
|
||||
const std::string authenticationBackend{ core::stringUtils::stringToLower(config->getString("authentication-backend", "internal")) };
|
||||
if (authenticationBackend == "internal" || authenticationBackend == "pam")
|
||||
authTokenService->registerDomain("ui", auth::IAuthTokenService::DomainParameters{
|
||||
.tokenMaxUseCount = 1,
|
||||
.tokenDuration = std::chrono::weeks{ 8 },
|
||||
});
|
||||
|
||||
authTokenService->registerDomain("subsonic", auth::IAuthTokenService::DomainParameters{
|
||||
.tokenMaxUseCount = std::nullopt, // no usage limit
|
||||
.tokenDuration = std::nullopt, // no time limit
|
||||
});
|
||||
|
||||
switch (uiAuthenticationBackend)
|
||||
{
|
||||
authTokenService.assign(auth::createAuthTokenService(database, config->getULong("login-throttler-max-entriees", 10000)));
|
||||
authPasswordService.assign(auth::createPasswordService(authenticationBackend, database, config->getULong("login-throttler-max-entriees", 10000), *authTokenService.get()));
|
||||
case ui::AuthenticationBackend::Internal:
|
||||
authPasswordService.assign(auth::createPasswordService("internal", database, loginThrottlerMaxEntries));
|
||||
break;
|
||||
case ui::AuthenticationBackend::PAM:
|
||||
authPasswordService.assign(auth::createPasswordService("PAM", database, loginThrottlerMaxEntries));
|
||||
break;
|
||||
case ui::AuthenticationBackend::Env:
|
||||
authEnvService.assign(auth::createEnvService("http-headers", database));
|
||||
break;
|
||||
}
|
||||
else if (authenticationBackend == "http-headers")
|
||||
{
|
||||
authEnvService.assign(auth::createEnvService(authenticationBackend, database));
|
||||
}
|
||||
else
|
||||
throw core::LmsException{ "Bad value '" + authenticationBackend + "' for 'authentication-backend'" };
|
||||
|
||||
image::init(argv[0]);
|
||||
core::Service<cover::IArtworkService> artworkService{ cover::createArtworkService(database, server.appRoot() + "/images/unknown-cover.svg", server.appRoot() + "/images/unknown-artist.svg") };
|
||||
@@ -370,8 +397,8 @@ namespace lms
|
||||
|
||||
// bind UI entry point
|
||||
server.addEntryPoint(Wt::EntryPointType::Application,
|
||||
[&](const Wt::WEnvironment& env) {
|
||||
return ui::LmsApplication::create(env, database, appManager);
|
||||
[&database, &appManager, uiAuthenticationBackend](const Wt::WEnvironment& env) {
|
||||
return ui::LmsApplication::create(env, database, appManager, uiAuthenticationBackend);
|
||||
});
|
||||
|
||||
proxyScannerEventsToApplication(*scannerService, server);
|
||||
|
||||
+32
-16
@@ -19,6 +19,8 @@
|
||||
|
||||
#include "Auth.hpp"
|
||||
|
||||
#include <Wt/Auth/HashFunction.h>
|
||||
|
||||
#include <Wt/WCheckBox.h>
|
||||
#include <Wt/WEnvironment.h>
|
||||
#include <Wt/WFormModel.h>
|
||||
@@ -42,14 +44,18 @@ 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
|
||||
|
||||
void createAuthToken(db::UserId userId, const Wt::WDateTime& expiry)
|
||||
{
|
||||
const std::string secret{ core::Service<auth::IAuthTokenService>::get()->createAuthToken(userId, expiry) };
|
||||
const std::string authCookie{ Wt::WRandom::generateId(64) };
|
||||
const std::string hashedAuthCookie{ Wt::Auth::SHA1HashFunction{}.compute(authCookie, authCookieSalt) };
|
||||
core::Service<auth::IAuthTokenService>::get()->createAuthToken(authTokenDomain, userId, hashedAuthCookie);
|
||||
|
||||
LmsApp->setCookie(authCookieName,
|
||||
secret,
|
||||
authCookie,
|
||||
expiry.toTime_t() - Wt::WDateTime::currentDateTime().toTime_t(),
|
||||
"",
|
||||
"",
|
||||
@@ -64,7 +70,8 @@ namespace lms::ui
|
||||
static const Field PasswordField;
|
||||
static const Field RememberMeField;
|
||||
|
||||
AuthModel()
|
||||
AuthModel(auth::IPasswordService& passwordService)
|
||||
: _passwordService{ passwordService }
|
||||
{
|
||||
addField(LoginNameField);
|
||||
addField(PasswordField);
|
||||
@@ -90,8 +97,7 @@ namespace lms::ui
|
||||
if (Wt::asNumber(value(RememberMeField)))
|
||||
{
|
||||
const Wt::WDateTime now{ Wt::WDateTime::currentDateTime() };
|
||||
|
||||
createAuthToken(*_userId, isDemo ? now.addDays(3) : now.addYears(1));
|
||||
createAuthToken(_userId, isDemo ? now.addDays(3) : now.addYears(1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,14 +107,14 @@ namespace lms::ui
|
||||
|
||||
if (field == PasswordField)
|
||||
{
|
||||
const auto checkResult{ core::Service<auth::IPasswordService>::get()->checkUserPassword(
|
||||
const auto checkResult{ _passwordService.checkUserPassword(
|
||||
boost::asio::ip::address::from_string(LmsApp->environment().clientAddress()),
|
||||
valueText(LoginNameField).toUTF8(),
|
||||
valueText(PasswordField).toUTF8()) };
|
||||
switch (checkResult.state)
|
||||
{
|
||||
case auth::IPasswordService::CheckResult::State::Granted:
|
||||
_userId = *checkResult.userId;
|
||||
_userId = checkResult.userId;
|
||||
break;
|
||||
case auth::IPasswordService::CheckResult::State::Denied:
|
||||
error = Wt::WString::tr("Lms.password-bad-login-combination");
|
||||
@@ -128,10 +134,11 @@ namespace lms::ui
|
||||
return (validation(field).state() == Wt::ValidationState::Valid);
|
||||
}
|
||||
|
||||
std::optional<db::UserId> getUserId() const { return _userId; }
|
||||
db::UserId getUserId() const { return _userId; }
|
||||
|
||||
private:
|
||||
std::optional<db::UserId> _userId;
|
||||
db::UserId _userId;
|
||||
auth::IPasswordService& _passwordService;
|
||||
};
|
||||
|
||||
const AuthModel::Field AuthModel::LoginNameField{ "login-name" };
|
||||
@@ -139,21 +146,24 @@ namespace lms::ui
|
||||
const AuthModel::Field AuthModel::RememberMeField{ "remember-me" };
|
||||
} // namespace
|
||||
|
||||
std::optional<db::UserId> processAuthToken(const Wt::WEnvironment& env)
|
||||
db::UserId processAuthToken(const Wt::WEnvironment& env)
|
||||
{
|
||||
const std::string* authCookie{ env.getCookie(authCookieName) };
|
||||
if (!authCookie)
|
||||
return std::nullopt;
|
||||
return db::UserId{};
|
||||
|
||||
const auto res{ core::Service<auth::IAuthTokenService>::get()->processAuthToken(boost::asio::ip::address::from_string(env.clientAddress()), *authCookie) };
|
||||
const std::string hashedCookie{ Wt::Auth::SHA1HashFunction{}.compute(*authCookie, authCookieSalt) };
|
||||
|
||||
const auto res{ core::Service<auth::IAuthTokenService>::get()->processAuthToken(authTokenDomain, boost::asio::ip::address::from_string(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");
|
||||
return std::nullopt;
|
||||
return db::UserId{};
|
||||
|
||||
case auth::IAuthTokenService::AuthTokenProcessResult::State::Granted:
|
||||
assert(res.authTokenInfo->maxUseCount && res.authTokenInfo->maxUseCount.value() == 1); // single-use token
|
||||
createAuthToken(res.authTokenInfo->userId, res.authTokenInfo->expiry);
|
||||
break;
|
||||
}
|
||||
@@ -161,10 +171,15 @@ namespace lms::ui
|
||||
return res.authTokenInfo->userId;
|
||||
}
|
||||
|
||||
Auth::Auth()
|
||||
void clearAuthTokens(db::UserId userId)
|
||||
{
|
||||
core::Service<auth::IAuthTokenService>::get()->clearAuthTokens(authTokenDomain, userId);
|
||||
}
|
||||
|
||||
PasswordAuth::PasswordAuth(auth::IPasswordService& passwordService)
|
||||
: Wt::WTemplateFormView{ Wt::WString::tr("Lms.Auth.template") }
|
||||
{
|
||||
auto model{ std::make_shared<AuthModel>() };
|
||||
auto model{ std::make_shared<AuthModel>(passwordService) };
|
||||
|
||||
auto processAuth = [this, model] {
|
||||
updateModel(model.get());
|
||||
@@ -172,7 +187,8 @@ namespace lms::ui
|
||||
if (model->validate())
|
||||
{
|
||||
model->saveData();
|
||||
userLoggedIn.emit(*model->getUserId());
|
||||
assert(model->getUserId().isValid());
|
||||
userLoggedIn.emit(model->getUserId());
|
||||
}
|
||||
else
|
||||
updateView(model.get());
|
||||
|
||||
+15
-5
@@ -19,20 +19,30 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <Wt/WTemplateFormView.h>
|
||||
|
||||
#include "database/UserId.hpp"
|
||||
|
||||
namespace lms::auth
|
||||
{
|
||||
class IPasswordService;
|
||||
}
|
||||
|
||||
namespace lms::ui
|
||||
{
|
||||
std::optional<db::UserId> processAuthToken(const Wt::WEnvironment& env);
|
||||
enum class AuthenticationBackend
|
||||
{
|
||||
Internal,
|
||||
Env,
|
||||
PAM,
|
||||
};
|
||||
|
||||
class Auth : public Wt::WTemplateFormView
|
||||
db::UserId processAuthToken(const Wt::WEnvironment& env);
|
||||
|
||||
class PasswordAuth : public Wt::WTemplateFormView
|
||||
{
|
||||
public:
|
||||
Auth();
|
||||
PasswordAuth(auth::IPasswordService& passwordService);
|
||||
|
||||
Wt::Signal<db::UserId> userLoggedIn;
|
||||
};
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
#include "database/TrackList.hpp"
|
||||
#include "database/User.hpp"
|
||||
#include "services/artwork/IArtworkService.hpp"
|
||||
#include "services/auth/IAuthTokenService.hpp"
|
||||
#include "services/auth/IEnvService.hpp"
|
||||
#include "services/auth/IPasswordService.hpp"
|
||||
#include "services/scrobbling/IScrobblingService.hpp"
|
||||
@@ -184,22 +185,9 @@ namespace lms::ui
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<Wt::WApplication> LmsApplication::create(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager)
|
||||
std::unique_ptr<Wt::WApplication> LmsApplication::create(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend)
|
||||
{
|
||||
if (auto* authEnvService{ core::Service<auth::IEnvService>::get() })
|
||||
{
|
||||
const auto checkResult{ authEnvService->processEnv(env) };
|
||||
if (checkResult.state != auth::IEnvService::CheckResult::State::Granted)
|
||||
{
|
||||
LMS_LOG(UI, ERROR, "Cannot authenticate user from environment!");
|
||||
// return a blank page
|
||||
return std::make_unique<Wt::WApplication>(env);
|
||||
}
|
||||
|
||||
return std::make_unique<LmsApplication>(env, db, appManager, checkResult.userId);
|
||||
}
|
||||
|
||||
return std::make_unique<LmsApplication>(env, db, appManager);
|
||||
return std::make_unique<LmsApplication>(env, db, appManager, authBackend);
|
||||
}
|
||||
|
||||
LmsApplication* LmsApplication::instance()
|
||||
@@ -249,17 +237,15 @@ namespace lms::ui
|
||||
return _user->userLoginName;
|
||||
}
|
||||
|
||||
LmsApplication::LmsApplication(const Wt::WEnvironment& env,
|
||||
db::Db& db,
|
||||
LmsApplicationManager& appManager,
|
||||
std::optional<db::UserId> userId)
|
||||
LmsApplication::LmsApplication(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend)
|
||||
: Wt::WApplication{ env }
|
||||
, _db{ db }
|
||||
, _appManager{ appManager }
|
||||
, _authBackend{ authBackend }
|
||||
{
|
||||
try
|
||||
{
|
||||
init(userId);
|
||||
init();
|
||||
}
|
||||
catch (LmsApplicationException& e)
|
||||
{
|
||||
@@ -275,7 +261,7 @@ namespace lms::ui
|
||||
|
||||
LmsApplication::~LmsApplication() = default;
|
||||
|
||||
void LmsApplication::init(std::optional<db::UserId> userId)
|
||||
void LmsApplication::init()
|
||||
{
|
||||
LMS_SCOPED_TRACE_OVERVIEW("UI", "ApplicationInit");
|
||||
|
||||
@@ -291,24 +277,38 @@ namespace lms::ui
|
||||
// Handle Media Scanner events and other session events
|
||||
enableUpdates(true);
|
||||
|
||||
if (userId)
|
||||
onUserLoggedIn(*userId, false /* strongAuth */);
|
||||
else if (core::Service<auth::IPasswordService>::exists())
|
||||
db::UserId userId;
|
||||
switch (_authBackend)
|
||||
{
|
||||
case AuthenticationBackend::Env:
|
||||
{
|
||||
const auto checkResult{ core::Service<auth::IEnvService>::get()->processEnv(environment()) };
|
||||
if (checkResult.state != auth::IEnvService::CheckResult::State::Granted)
|
||||
{
|
||||
LMS_LOG(UI, ERROR, "Cannot authenticate user from environment!");
|
||||
throw core::LmsException{ "Cannot authenticate user from environment!" }; // Do not put details here at it may appear on the user rendered html
|
||||
}
|
||||
assert(checkResult.userId.isValid());
|
||||
userId = checkResult.userId;
|
||||
}
|
||||
break;
|
||||
|
||||
case AuthenticationBackend::Internal:
|
||||
[[fallthrough]];
|
||||
case AuthenticationBackend::PAM:
|
||||
// Try to authenticate using auth token ("remember me" checkbox), may fail
|
||||
userId = processAuthToken(environment());
|
||||
break;
|
||||
}
|
||||
|
||||
if (userId.isValid())
|
||||
onUserLoggedIn(userId, false /* strongAuth */);
|
||||
else
|
||||
processPasswordAuth();
|
||||
}
|
||||
|
||||
void LmsApplication::processPasswordAuth()
|
||||
{
|
||||
{
|
||||
std::optional<db::UserId> userId{ processAuthToken(environment()) };
|
||||
if (userId)
|
||||
{
|
||||
LMS_LOG(UI, DEBUG, "User authenticated using Auth token!");
|
||||
onUserLoggedIn(*userId, false /* strongAuth */);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If here is no account in the database, launch the first connection wizard
|
||||
bool firstConnection{};
|
||||
{
|
||||
@@ -318,17 +318,19 @@ namespace lms::ui
|
||||
|
||||
LMS_LOG(UI, DEBUG, "Creating root widget. First connection = " << firstConnection);
|
||||
|
||||
if (firstConnection && core::Service<auth::IPasswordService>::get()->canSetPasswords())
|
||||
assert(_authBackend == AuthenticationBackend::Internal || _authBackend == AuthenticationBackend::PAM);
|
||||
auth::IPasswordService& passwordService{ *core::Service<auth::IPasswordService>::get() };
|
||||
|
||||
if (firstConnection && _authBackend == AuthenticationBackend::Internal)
|
||||
{
|
||||
root()->addWidget(std::make_unique<InitWizardView>());
|
||||
}
|
||||
else
|
||||
{
|
||||
Auth* auth{ root()->addNew<Auth>() };
|
||||
auth->userLoggedIn.connect(this, [this](db::UserId userId) {
|
||||
onUserLoggedIn(userId, true /* strongAuth */);
|
||||
});
|
||||
root()->addNew<InitWizardView>(passwordService);
|
||||
return;
|
||||
}
|
||||
|
||||
PasswordAuth* auth{ root()->addNew<PasswordAuth>(passwordService) };
|
||||
auth->userLoggedIn.connect(this, [this](db::UserId userId) {
|
||||
onUserLoggedIn(userId, true /* strongAuth */);
|
||||
});
|
||||
}
|
||||
|
||||
void LmsApplication::finalize()
|
||||
@@ -360,11 +362,7 @@ namespace lms::ui
|
||||
|
||||
void LmsApplication::logoutUser()
|
||||
{
|
||||
{
|
||||
auto transaction{ getDbSession().createWriteTransaction() };
|
||||
getUser().modify()->clearAuthTokens();
|
||||
}
|
||||
|
||||
core::Service<auth::IAuthTokenService>::get()->clearAuthTokens("ui", getUserId());
|
||||
LMS_LOG(UI, INFO, "User '" << getUserLoginName() << " 'logged out");
|
||||
goHomeAndQuit();
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
#include "database/UserId.hpp"
|
||||
#include "services/scanner/ScannerEvents.hpp"
|
||||
|
||||
#include "Auth.hpp"
|
||||
#include "Notification.hpp"
|
||||
#include "admin/ScannerController.hpp"
|
||||
|
||||
@@ -53,10 +54,10 @@ namespace lms::ui
|
||||
class LmsApplication : public Wt::WApplication
|
||||
{
|
||||
public:
|
||||
LmsApplication(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager, std::optional<db::UserId> userId = std::nullopt);
|
||||
LmsApplication(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend);
|
||||
~LmsApplication();
|
||||
|
||||
static std::unique_ptr<Wt::WApplication> create(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager);
|
||||
static std::unique_ptr<Wt::WApplication> create(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend);
|
||||
static LmsApplication* instance();
|
||||
|
||||
// Session application data
|
||||
@@ -73,6 +74,8 @@ namespace lms::ui
|
||||
// Proxified scanner events
|
||||
scanner::Events& getScannerEvents() { return _scannerEvents; }
|
||||
|
||||
AuthenticationBackend getAuthBackend() const { return _authBackend; }
|
||||
|
||||
// Utils
|
||||
void post(std::function<void()> func);
|
||||
void setTitle(const Wt::WString& title = "");
|
||||
@@ -88,7 +91,7 @@ namespace lms::ui
|
||||
Wt::Signal<>& preQuit() { return _preQuit; }
|
||||
|
||||
private:
|
||||
void init(std::optional<db::UserId> userId);
|
||||
void init();
|
||||
void processPasswordAuth();
|
||||
void handleException(LmsApplicationException& e);
|
||||
void goHomeAndQuit();
|
||||
@@ -106,6 +109,7 @@ namespace lms::ui
|
||||
db::Db& _db;
|
||||
Wt::Signal<> _preQuit;
|
||||
LmsApplicationManager& _appManager;
|
||||
const AuthenticationBackend _authBackend;
|
||||
scanner::Events _scannerEvents;
|
||||
struct UserAuthInfo
|
||||
{
|
||||
|
||||
+87
-11
@@ -33,6 +33,7 @@
|
||||
#include "core/Service.hpp"
|
||||
#include "database/Session.hpp"
|
||||
#include "database/User.hpp"
|
||||
#include "services/auth/IAuthTokenService.hpp"
|
||||
#include "services/auth/IPasswordService.hpp"
|
||||
|
||||
#include "LmsApplication.hpp"
|
||||
@@ -59,6 +60,7 @@ namespace lms::ui
|
||||
static inline const Field ReplayGainModeField{ "replaygain-mode" };
|
||||
static inline const Field ReplayGainPreAmpGainField{ "replaygain-preamp" };
|
||||
static inline const Field ReplayGainPreAmpGainIfNoInfoField{ "replaygain-preamp-no-rg-info" };
|
||||
static inline const Field SubsonicTokenField{ "subsonic-token" };
|
||||
static inline const Field SubsonicEnableTranscodingByDefault{ "subsonic-enable-transcoding-by-default" };
|
||||
static inline const Field SubsonicArtistListModeField{ "subsonic-artist-list-mode" };
|
||||
static inline const Field SubsonicTranscodingOutputFormatField{ "subsonic-transcoding-output-format" };
|
||||
@@ -76,9 +78,10 @@ namespace lms::ui
|
||||
using FeedbackBackendModel = ValueStringModel<db::FeedbackBackend>;
|
||||
using ScrobblingBackendModel = ValueStringModel<db::ScrobblingBackend>;
|
||||
|
||||
SettingsModel(auth::IPasswordService* authPasswordService, bool withOldPassword)
|
||||
SettingsModel(auth::IPasswordService* authPasswordService, bool withOldPassword, auth::IAuthTokenService& authTokenService)
|
||||
: _authPasswordService{ authPasswordService }
|
||||
, _withOldPassword{ withOldPassword }
|
||||
, _authTokenService{ authTokenService }
|
||||
{
|
||||
initializeModels();
|
||||
|
||||
@@ -89,12 +92,15 @@ namespace lms::ui
|
||||
addField(ReplayGainModeField);
|
||||
addField(ReplayGainPreAmpGainField);
|
||||
addField(ReplayGainPreAmpGainIfNoInfoField);
|
||||
addField(SubsonicTokenField);
|
||||
addField(SubsonicEnableTranscodingByDefault);
|
||||
addField(SubsonicTranscodingOutputBitrateField);
|
||||
addField(SubsonicTranscodingOutputFormatField);
|
||||
addField(FeedbackBackendField);
|
||||
addField(ScrobblingBackendField);
|
||||
addField(ListenBrainzTokenField);
|
||||
|
||||
setValidator(SubsonicTokenField, createUUIDValidator());
|
||||
setValidator(ListenBrainzTokenField, createUUIDValidator());
|
||||
|
||||
if (_authPasswordService)
|
||||
@@ -102,11 +108,11 @@ namespace lms::ui
|
||||
if (_withOldPassword)
|
||||
{
|
||||
addField(PasswordOldField);
|
||||
setValidator(PasswordOldField, createPasswordCheckValidator());
|
||||
setValidator(PasswordOldField, createPasswordCheckValidator(*_authPasswordService));
|
||||
}
|
||||
|
||||
addField(PasswordField);
|
||||
setValidator(PasswordField, createPasswordStrengthValidator([] { return auth::PasswordValidationContext{ .loginName = std::string{ LmsApp->getUserLoginName() }, .userType = LmsApp->getUserType() }; }));
|
||||
setValidator(PasswordField, createPasswordStrengthValidator(*authPasswordService, [] { return auth::PasswordValidationContext{ .loginName = std::string{ LmsApp->getUserLoginName() }, .userType = LmsApp->getUserType() }; }));
|
||||
addField(PasswordConfirmField);
|
||||
}
|
||||
|
||||
@@ -173,7 +179,33 @@ namespace lms::ui
|
||||
LmsApp->getMediaPlayer().setSettings(settings);
|
||||
}
|
||||
|
||||
// Subsonic API
|
||||
{
|
||||
const std::string token{ Wt::asString(value(SubsonicTokenField)).toUTF8() };
|
||||
|
||||
if (token.empty())
|
||||
{
|
||||
_authTokenService.clearAuthTokens("subsonic", user->getId());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Consider there must be only one token
|
||||
bool hasNonMatchingToken{ false };
|
||||
bool hasMatchingToken{ false };
|
||||
_authTokenService.visitAuthTokens("subsonic", user->getId(), [&](const auth::IAuthTokenService::AuthTokenInfo&, std::string_view storedToken) {
|
||||
if (storedToken == token)
|
||||
hasMatchingToken = true;
|
||||
else
|
||||
hasNonMatchingToken = true;
|
||||
});
|
||||
|
||||
if (!hasMatchingToken || hasNonMatchingToken)
|
||||
{
|
||||
_authTokenService.clearAuthTokens("subsonic", user->getId());
|
||||
_authTokenService.createAuthToken("subsonic", user->getId(), token);
|
||||
}
|
||||
}
|
||||
|
||||
bool subsonicEnableTranscodingByDefault{ Wt::asNumber(value(SubsonicEnableTranscodingByDefault)) != 0 };
|
||||
user.modify()->setSubsonicEnableTranscodingByDefault(subsonicEnableTranscodingByDefault);
|
||||
|
||||
@@ -207,6 +239,7 @@ namespace lms::ui
|
||||
if (_authPasswordService && !valueText(PasswordField).empty())
|
||||
{
|
||||
_authPasswordService->setPassword(user->getId(), valueText(PasswordField).toUTF8());
|
||||
_authTokenService.clearAuthTokens("ui", user->getId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +247,7 @@ namespace lms::ui
|
||||
{
|
||||
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
|
||||
|
||||
User::pointer user{ LmsApp->getUser() };
|
||||
const User::pointer user{ LmsApp->getUser() };
|
||||
|
||||
{
|
||||
auto artistReleaseSortMethodRow{ _artistReleaseSortMethodModel->getRowFromValue(user->getUIArtistReleaseSortMethod()) };
|
||||
@@ -251,7 +284,14 @@ namespace lms::ui
|
||||
setValue(ReplayGainPreAmpGainIfNoInfoField, settings.replayGain.preAmpGainIfNoInfo);
|
||||
}
|
||||
|
||||
// Subsonic
|
||||
{
|
||||
// Consider there is only one auth token
|
||||
_authTokenService.visitAuthTokens("subsonic", user->getId(), [&](const auth::IAuthTokenService::AuthTokenInfo&, std::string_view storedToken) {
|
||||
if (Wt::asString(value(SubsonicTokenField)).empty())
|
||||
setValue(SubsonicTokenField, Wt::WString::fromUTF8(std::string{ storedToken }));
|
||||
});
|
||||
|
||||
setValue(SubsonicEnableTranscodingByDefault, user->getSubsonicEnableTranscodingByDefault());
|
||||
|
||||
auto subsonicTranscodingOutputBitrateRow{ _transcodingOutputBitrateModel->getRowFromValue(user->getSubsonicDefaultTranscodingOutputBitrate()) };
|
||||
@@ -380,6 +420,8 @@ namespace lms::ui
|
||||
auth::IPasswordService* _authPasswordService{};
|
||||
bool _withOldPassword{};
|
||||
|
||||
auth::IAuthTokenService& _authTokenService;
|
||||
|
||||
std::shared_ptr<ArtistReleaseSortMethodModel> _artistReleaseSortMethodModel;
|
||||
std::shared_ptr<TranscodingModeModel> _transcodingModeModeModel;
|
||||
std::shared_ptr<ValueStringModel<Bitrate>> _transcodingOutputBitrateModel;
|
||||
@@ -403,8 +445,7 @@ namespace lms::ui
|
||||
refreshView();
|
||||
}
|
||||
|
||||
void
|
||||
SettingsView::refreshView()
|
||||
void SettingsView::refreshView()
|
||||
{
|
||||
if (!wApp->internalPathMatches("/settings"))
|
||||
return;
|
||||
@@ -417,12 +458,14 @@ namespace lms::ui
|
||||
|
||||
auto t{ addNew<Wt::WTemplateFormView>(Wt::WString::tr("Lms.Settings.template")) };
|
||||
|
||||
auto* authPasswordService{ core::Service<auth::IPasswordService>::get() };
|
||||
if (authPasswordService && !authPasswordService->canSetPasswords())
|
||||
authPasswordService = nullptr;
|
||||
|
||||
auto model{ std::make_shared<SettingsModel>(authPasswordService, !LmsApp->isUserAuthStrong()) };
|
||||
auth::IPasswordService* authPasswordService{};
|
||||
if (LmsApp->getAuthBackend() == AuthenticationBackend::Internal)
|
||||
{
|
||||
authPasswordService = core::Service<auth::IPasswordService>::get();
|
||||
assert(authPasswordService->canSetPasswords());
|
||||
}
|
||||
|
||||
auto model{ std::make_shared<SettingsModel>(authPasswordService, !LmsApp->isUserAuthStrong(), *core::Service<auth::IAuthTokenService>::get()) };
|
||||
if (authPasswordService)
|
||||
{
|
||||
t->setCondition("if-has-change-password", true);
|
||||
@@ -514,6 +557,31 @@ namespace lms::ui
|
||||
// Subsonic
|
||||
{
|
||||
t->setCondition("if-has-subsonic-api", core::Service<core::IConfig>::get()->getBool("api-subsonic", true));
|
||||
t->setCondition("if-has-subsonic-token-usage", core::Service<core::IConfig>::get()->getBool("api-subsonic-support-user-password-auth", true));
|
||||
|
||||
auto subsonicToken{ std::make_unique<Wt::WLineEdit>() };
|
||||
Wt::WLineEdit* subsonicTokenPtr{ subsonicToken.get() };
|
||||
subsonicTokenPtr->setEchoMode(Wt::EchoMode::Password);
|
||||
subsonicTokenPtr->setReadOnly(true);
|
||||
t->setFormWidget(SettingsModel::SubsonicTokenField, std::move(subsonicToken));
|
||||
|
||||
auto subsonicTokenRegenBtn{ std::make_unique<Wt::WPushButton>(Wt::WString::tr("Lms.Settings.regen-token")) };
|
||||
subsonicTokenRegenBtn->clicked().connect(this, [subsonicTokenPtr] {
|
||||
subsonicTokenPtr->setValueText(Wt::WString::fromUTF8(std::string{ core::UUID::generate().getAsString() }));
|
||||
});
|
||||
t->bindWidget("subsonic-token-regen-btn", std::move(subsonicTokenRegenBtn));
|
||||
|
||||
auto subsonicTokenVisibilityBtn{ std::make_unique<Wt::WPushButton>(Wt::WString::tr("Lms.template.toggle-visibility-btn"), Wt::TextFormat::XHTML) };
|
||||
subsonicTokenVisibilityBtn->clicked().connect(this, [subsonicTokenPtr] {
|
||||
subsonicTokenPtr->setEchoMode(subsonicTokenPtr->echoMode() == Wt::EchoMode::Password ? Wt::EchoMode::Normal : Wt::EchoMode::Password);
|
||||
});
|
||||
t->bindWidget("subsonic-token-visibility-btn", std::move(subsonicTokenVisibilityBtn));
|
||||
|
||||
auto subsonicTokenDelBtn{ std::make_unique<Wt::WPushButton>(Wt::WString::tr("Lms.template.trash-btn"), Wt::TextFormat::XHTML) };
|
||||
subsonicTokenDelBtn->clicked().connect(this, [subsonicTokenPtr] {
|
||||
subsonicTokenPtr->setValueText("");
|
||||
});
|
||||
t->bindWidget("subsonic-token-del-btn", std::move(subsonicTokenDelBtn));
|
||||
|
||||
// Enable transcoding by default
|
||||
t->setFormWidget(SettingsModel::SubsonicEnableTranscodingByDefault, std::make_unique<Wt::WCheckBox>());
|
||||
@@ -555,7 +623,15 @@ namespace lms::ui
|
||||
// Backend settings
|
||||
{
|
||||
auto listenbrainzToken{ std::make_unique<Wt::WLineEdit>() };
|
||||
Wt::WLineEdit* listenbrainzTokenPtr{ listenbrainzToken.get() };
|
||||
listenbrainzTokenPtr->setEchoMode(Wt::EchoMode::Password);
|
||||
t->setFormWidget(SettingsModel::ListenBrainzTokenField, std::move(listenbrainzToken));
|
||||
|
||||
auto listenbrainzTokenVisibilityBtn{ std::make_unique<Wt::WPushButton>(Wt::WString::tr("Lms.template.toggle-visibility-btn"), Wt::TextFormat::XHTML) };
|
||||
listenbrainzTokenVisibilityBtn->clicked().connect(this, [listenbrainzTokenPtr] {
|
||||
listenbrainzTokenPtr->setEchoMode(listenbrainzTokenPtr->echoMode() == Wt::EchoMode::Password ? Wt::EchoMode::Normal : Wt::EchoMode::Password);
|
||||
});
|
||||
t->bindWidget("listenbrainz-token-visibility-btn", std::move(listenbrainzTokenVisibilityBtn));
|
||||
}
|
||||
|
||||
auto updateListenBrainzTokenField{ [=] {
|
||||
|
||||
@@ -47,15 +47,15 @@ namespace lms::ui
|
||||
static inline const Field PasswordField{ "password" };
|
||||
static inline const Field PasswordConfirmField{ "password-confirm" };
|
||||
|
||||
InitWizardModel()
|
||||
: Wt::WFormModel()
|
||||
InitWizardModel(auth::IPasswordService& passwordService)
|
||||
: _passwordService{ passwordService }
|
||||
{
|
||||
addField(AdminLoginField);
|
||||
addField(PasswordField);
|
||||
addField(PasswordConfirmField);
|
||||
|
||||
setValidator(AdminLoginField, createLoginNameValidator());
|
||||
setValidator(PasswordField, createPasswordStrengthValidator([this] { return auth::PasswordValidationContext{ valueText(AdminLoginField).toUTF8(), db::UserType::ADMIN }; }));
|
||||
setValidator(PasswordField, createPasswordStrengthValidator(passwordService, [this] { return auth::PasswordValidationContext{ valueText(AdminLoginField).toUTF8(), db::UserType::ADMIN }; }));
|
||||
validator(PasswordField)->setMandatory(true);
|
||||
setValidator(PasswordConfirmField, createMandatoryValidator());
|
||||
}
|
||||
@@ -71,7 +71,7 @@ namespace lms::ui
|
||||
|
||||
db::User::pointer user{ LmsApp->getDbSession().create<db::User>(valueText(AdminLoginField).toUTF8()) };
|
||||
user.modify()->setType(db::UserType::ADMIN);
|
||||
core::Service<auth::IPasswordService>::get()->setPassword(user->getId(), valueText(PasswordField).toUTF8());
|
||||
_passwordService.setPassword(user->getId(), valueText(PasswordField).toUTF8());
|
||||
}
|
||||
|
||||
bool validateField(Field field)
|
||||
@@ -97,12 +97,15 @@ namespace lms::ui
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
auth::IPasswordService& _passwordService;
|
||||
};
|
||||
|
||||
InitWizardView::InitWizardView()
|
||||
InitWizardView::InitWizardView(auth::IPasswordService& passwordService)
|
||||
: Wt::WTemplateFormView{ Wt::WString::tr("Lms.Admin.InitWizard.template") }
|
||||
{
|
||||
auto model = std::make_shared<InitWizardModel>();
|
||||
auto model = std::make_shared<InitWizardModel>(passwordService);
|
||||
|
||||
// AdminLogin
|
||||
{
|
||||
|
||||
@@ -21,13 +21,17 @@
|
||||
|
||||
#include <Wt/WTemplateFormView.h>
|
||||
|
||||
namespace lms::auth
|
||||
{
|
||||
class IPasswordService;
|
||||
}
|
||||
|
||||
namespace lms::ui
|
||||
{
|
||||
|
||||
class InitWizardView : public Wt::WTemplateFormView
|
||||
{
|
||||
public:
|
||||
InitWizardView();
|
||||
InitWizardView(auth::IPasswordService& passwordService);
|
||||
};
|
||||
|
||||
} // namespace lms::ui
|
||||
|
||||
@@ -87,7 +87,7 @@ namespace lms::ui
|
||||
if (p.is_relative())
|
||||
return Wt::WValidator::Result(Wt::ValidationState::Invalid, Wt::WString::tr("Lms.Admin.MediaLibrary.path-must-be-absolute"));
|
||||
|
||||
// TODO check and translate rights issues
|
||||
// TODO check and translate access rights issues
|
||||
bool res{ std::filesystem::is_directory(p, ec) };
|
||||
if (ec)
|
||||
return Wt::WValidator::Result(Wt::ValidationState::Invalid, ec.message()); // TODO translate common errors
|
||||
@@ -151,14 +151,23 @@ namespace lms::ui
|
||||
auto& session{ LmsApp->getDbSession() };
|
||||
auto transaction{ LmsApp->getDbSession().createWriteTransaction() };
|
||||
|
||||
std::string name{ valueText(NameField).toUTF8() };
|
||||
std::string path{ valueText(DirectoryField).toUTF8() };
|
||||
|
||||
MediaLibrary::pointer library;
|
||||
if (_libraryId.isValid())
|
||||
{
|
||||
library = MediaLibrary::find(session, _libraryId);
|
||||
if (library)
|
||||
{
|
||||
library.modify()->setName(name);
|
||||
library.modify()->setPath(path);
|
||||
}
|
||||
}
|
||||
else
|
||||
library = session.create<MediaLibrary>();
|
||||
|
||||
library.modify()->setName(valueText(NameField).toUTF8());
|
||||
library.modify()->setPath(valueText(DirectoryField).toUTF8());
|
||||
{
|
||||
library = session.create<MediaLibrary>(name, path);
|
||||
}
|
||||
|
||||
return library->getId();
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
#include "core/String.hpp"
|
||||
#include "database/Session.hpp"
|
||||
#include "database/User.hpp"
|
||||
#include "services/auth/IAuthTokenService.hpp"
|
||||
#include "services/auth/IPasswordService.hpp"
|
||||
|
||||
#include "LmsApplication.hpp"
|
||||
@@ -52,9 +53,10 @@ namespace lms::ui
|
||||
static inline const Field PasswordField{ "password" };
|
||||
static inline const Field DemoField{ "demo" };
|
||||
|
||||
UserModel(std::optional<UserId> userId, auth::IPasswordService* authPasswordService)
|
||||
UserModel(std::optional<UserId> userId, auth::IPasswordService* authPasswordService, auth::IAuthTokenService& authTokenService)
|
||||
: _userId{ userId }
|
||||
, _authPasswordService{ authPasswordService }
|
||||
, _authTokenService{ authTokenService }
|
||||
{
|
||||
if (!_userId)
|
||||
{
|
||||
@@ -65,7 +67,7 @@ namespace lms::ui
|
||||
if (authPasswordService)
|
||||
{
|
||||
addField(PasswordField);
|
||||
setValidator(PasswordField, createPasswordStrengthValidator([this] { return auth::PasswordValidationContext{ getLoginName(), getUserType() }; }));
|
||||
setValidator(PasswordField, createPasswordStrengthValidator(*authPasswordService, [this] { return auth::PasswordValidationContext{ getLoginName(), getUserType() }; }));
|
||||
if (!userId)
|
||||
validator(PasswordField)->setMandatory(true);
|
||||
}
|
||||
@@ -86,7 +88,10 @@ namespace lms::ui
|
||||
throw UserNotFoundException{};
|
||||
|
||||
if (_authPasswordService && !valueText(PasswordField).empty())
|
||||
{
|
||||
_authPasswordService->setPassword(user->getId(), valueText(PasswordField).toUTF8());
|
||||
_authTokenService.clearAuthTokens("ui", user->getId());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -103,6 +108,9 @@ namespace lms::ui
|
||||
|
||||
if (_authPasswordService)
|
||||
_authPasswordService->setPassword(user->getId(), valueText(PasswordField).toUTF8());
|
||||
|
||||
// For demo user, we create the subsonic API auth token now as we have no other mean to create it later
|
||||
core::Service<auth::IAuthTokenService>::get()->createAuthToken("subsonic", user->getId(), core::UUID::generate().getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +185,7 @@ namespace lms::ui
|
||||
|
||||
std::optional<UserId> _userId;
|
||||
auth::IPasswordService* _authPasswordService{};
|
||||
auth::IAuthTokenService& _authTokenService;
|
||||
};
|
||||
|
||||
UserView::UserView()
|
||||
@@ -199,12 +208,14 @@ namespace lms::ui
|
||||
|
||||
Wt::WTemplateFormView* t{ addNew<Wt::WTemplateFormView>(Wt::WString::tr("Lms.Admin.User.template")) };
|
||||
|
||||
auto* authPasswordService{ core::Service<auth::IPasswordService>::get() };
|
||||
if (authPasswordService && !authPasswordService->canSetPasswords())
|
||||
authPasswordService = nullptr;
|
||||
|
||||
auto model{ std::make_shared<UserModel>(userId, authPasswordService) };
|
||||
auth::IPasswordService* authPasswordService{};
|
||||
if (LmsApp->getAuthBackend() == AuthenticationBackend::Internal)
|
||||
{
|
||||
authPasswordService = core::Service<auth::IPasswordService>::get();
|
||||
assert(authPasswordService->canSetPasswords());
|
||||
}
|
||||
|
||||
auto model{ std::make_shared<UserModel>(userId, authPasswordService, *core::Service<auth::IAuthTokenService>::get()) };
|
||||
if (userId)
|
||||
{
|
||||
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace lms::ui
|
||||
|
||||
_container = bindNew<Wt::WContainerWidget>("users");
|
||||
|
||||
if (core::Service<auth::IPasswordService>::get() && core::Service<auth::IPasswordService>::get()->canSetPasswords())
|
||||
if (LmsApp->getAuthBackend() == AuthenticationBackend::Internal)
|
||||
{
|
||||
setCondition("if-can-create-user", true);
|
||||
|
||||
|
||||
@@ -33,8 +33,9 @@ namespace lms::ui
|
||||
class PasswordStrengthValidator : public Wt::WValidator
|
||||
{
|
||||
public:
|
||||
PasswordStrengthValidator(PasswordValidationContextGetFunc passwordValidationContextGetFunc)
|
||||
PasswordStrengthValidator(const auth::IPasswordService& passwordService, PasswordValidationContextGetFunc passwordValidationContextGetFunc)
|
||||
: _passwordValidationContextGetFunc{ std::move(passwordValidationContextGetFunc) }
|
||||
, _passwordService{ passwordService }
|
||||
{
|
||||
}
|
||||
|
||||
@@ -43,6 +44,7 @@ namespace lms::ui
|
||||
std::string javaScriptValidate() const override { return {}; }
|
||||
|
||||
PasswordValidationContextGetFunc _passwordValidationContextGetFunc;
|
||||
const auth::IPasswordService& _passwordService;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
@@ -53,7 +55,7 @@ namespace lms::ui
|
||||
|
||||
const auth::PasswordValidationContext context{ _passwordValidationContextGetFunc() };
|
||||
|
||||
switch (core::Service<auth::IPasswordService>::get()->checkPasswordAcceptability(input.toUTF8(), context))
|
||||
switch (_passwordService.checkPasswordAcceptability(input.toUTF8(), context))
|
||||
{
|
||||
case auth::IPasswordService::PasswordAcceptabilityResult::OK:
|
||||
return Wt::WValidator::Result{ Wt::ValidationState::Valid };
|
||||
@@ -66,16 +68,24 @@ namespace lms::ui
|
||||
throw core::LmsException{ "internal error" };
|
||||
}
|
||||
|
||||
std::unique_ptr<Wt::WValidator> createPasswordStrengthValidator(PasswordValidationContextGetFunc passwordValidationContextGetFunc)
|
||||
std::unique_ptr<Wt::WValidator> createPasswordStrengthValidator(const auth::IPasswordService& passwordService, PasswordValidationContextGetFunc passwordValidationContextGetFunc)
|
||||
{
|
||||
return std::make_unique<PasswordStrengthValidator>(std::move(passwordValidationContextGetFunc));
|
||||
return std::make_unique<PasswordStrengthValidator>(passwordService, std::move(passwordValidationContextGetFunc));
|
||||
}
|
||||
|
||||
class PasswordCheckValidator : public Wt::WValidator
|
||||
{
|
||||
public:
|
||||
PasswordCheckValidator(auth::IPasswordService& passwordService)
|
||||
: _passwordService{ passwordService }
|
||||
{
|
||||
}
|
||||
|
||||
private:
|
||||
Wt::WValidator::Result validate(const Wt::WString& input) const override;
|
||||
std::string javaScriptValidate() const override { return {}; }
|
||||
|
||||
auth::IPasswordService& _passwordService;
|
||||
};
|
||||
|
||||
Wt::WValidator::Result PasswordCheckValidator::validate(const Wt::WString& input) const
|
||||
@@ -83,7 +93,7 @@ namespace lms::ui
|
||||
if (input.empty())
|
||||
return Wt::WValidator::validate(input);
|
||||
|
||||
const auto checkResult{ core::Service<auth::IPasswordService>::get()->checkUserPassword(
|
||||
const auto checkResult{ _passwordService.checkUserPassword(
|
||||
boost::asio::ip::address::from_string(LmsApp->environment().clientAddress()),
|
||||
LmsApp->getUserLoginName(),
|
||||
input.toUTF8()) };
|
||||
@@ -100,9 +110,9 @@ namespace lms::ui
|
||||
throw core::LmsException{ "InternalError" };
|
||||
}
|
||||
|
||||
std::unique_ptr<Wt::WValidator> createPasswordCheckValidator()
|
||||
std::unique_ptr<Wt::WValidator> createPasswordCheckValidator(auth::IPasswordService& passwordService)
|
||||
{
|
||||
return std::make_unique<PasswordCheckValidator>();
|
||||
return std::make_unique<PasswordCheckValidator>(passwordService);
|
||||
}
|
||||
|
||||
} // namespace lms::ui
|
||||
@@ -25,11 +25,16 @@
|
||||
|
||||
#include "services/auth/Types.hpp"
|
||||
|
||||
namespace lms::auth
|
||||
{
|
||||
class IPasswordService;
|
||||
}
|
||||
|
||||
namespace lms::ui
|
||||
{
|
||||
using PasswordValidationContextGetFunc = std::function<auth::PasswordValidationContext()>;
|
||||
std::unique_ptr<Wt::WValidator> createPasswordStrengthValidator(PasswordValidationContextGetFunc passwordValidationContextGetFunc);
|
||||
std::unique_ptr<Wt::WValidator> createPasswordStrengthValidator(const auth::IPasswordService& passwordService, PasswordValidationContextGetFunc passwordValidationContextGetFunc);
|
||||
|
||||
// Check current user password
|
||||
std::unique_ptr<Wt::WValidator> createPasswordCheckValidator();
|
||||
std::unique_ptr<Wt::WValidator> createPasswordCheckValidator(auth::IPasswordService& passwordService);
|
||||
} // namespace lms::ui
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user