Merge branch 'develop' for release v3.61.0

This commit is contained in:
emeric
2024-11-30 16:38:19 +01:00
102 changed files with 1638 additions and 770 deletions
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+8 -6
View File
@@ -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>
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+4 -3
View File
@@ -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(); }
+1
View File
@@ -5,6 +5,7 @@ add_executable(test-core
LiteralString.cpp
Path.cpp
RecursiveSharedMutex.cpp
Service.cpp
String.cpp
TraceLogger.cpp
Utils.cpp
+2
View File
@@ -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 },
};
+69
View File
@@ -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
+1
View File
@@ -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
+49 -13
View File
@@ -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
+14 -5
View File
@@ -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
+70 -2
View File
@@ -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);
+97
View File
@@ -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
+5 -3
View File
@@ -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)");
-6
View File
@@ -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;
+5 -2
View File
@@ -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;
+4 -4
View File
@@ -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() };
+49
View File
@@ -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
View File
@@ -1,5 +1,6 @@
add_executable(test-database
AuthToken.cpp
Artist.cpp
Cluster.cpp
Common.cpp
+1 -1
View File
@@ -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() };
+12 -12
View File
@@ -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 };
+4
View File
@@ -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{}));
+4 -4
View File
@@ -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() };
+4 -4
View File
@@ -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() };
+1 -1
View File
@@ -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() };
+11 -2
View File
@@ -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;
}
+1
View File
@@ -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)
{
+1 -1
View File
@@ -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;
};
+22
View File
@@ -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
+2
View File
@@ -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");
+12 -11
View File
@@ -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
-3
View File
@@ -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
+3 -2
View File
@@ -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;
+38
View File
@@ -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
+98 -85
View File
@@ -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
+2 -1
View File
@@ -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;
};
+4 -14
View File
@@ -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)
+47 -32
View File
@@ -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;
@@ -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
@@ -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
@@ -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 */);
@@ -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);
@@ -36,6 +36,7 @@
#include "services/artwork/IArtworkService.hpp"
#include "ParameterParsing.hpp"
#include "RequestContext.hpp"
#include "SubsonicId.hpp"
#include "responses/Lyrics.hpp"
@@ -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);
@@ -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
};
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
};
+46 -48
View File
@@ -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();
}
+7 -3
View File
@@ -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
View File
@@ -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{ [=] {
+9 -6
View File
@@ -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
{
+6 -2
View File
@@ -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
+14 -5
View File
@@ -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();
}
+18 -7
View File
@@ -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() };
+1 -1
View File
@@ -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);
+17 -7
View File
@@ -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
+7 -2
View File
@@ -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