Validate podcast URLs and probe artwork before saving

This commit is contained in:
emeric
2026-07-12 12:38:35 +02:00
parent 0cd6b1251a
commit 714b8640da
13 changed files with 259 additions and 29 deletions
+1
View File
@@ -13,6 +13,7 @@ configure_file(
add_library(lmscore STATIC add_library(lmscore STATIC
impl/http/Client.cpp impl/http/Client.cpp
impl/http/SendQueue.cpp impl/http/SendQueue.cpp
impl/http/UrlValidation.cpp
impl/media/Codec.cpp impl/media/Codec.cpp
impl/media/Container.cpp impl/media/Container.cpp
impl/media/ImageType.cpp impl/media/ImageType.cpp
+7
View File
@@ -31,6 +31,7 @@
#include "core/ILogger.hpp" #include "core/ILogger.hpp"
#include "core/ITraceLogger.hpp" #include "core/ITraceLogger.hpp"
#include "core/String.hpp" #include "core/String.hpp"
#include "core/http/UrlValidation.hpp"
#define LOG(sev, message) LMS_LOG(HTTP, sev, "[Http SendQueue] - " << message) #define LOG(sev, message) LMS_LOG(HTTP, sev, "[Http SendQueue] - " << message)
@@ -190,6 +191,12 @@ namespace lms::core::http
const std::string url{ _baseUrl + request.getParameters().relativeUrl }; const std::string url{ _baseUrl + request.getParameters().relativeUrl };
LOG(DEBUG, "Sending " << (request.getType() == ClientRequest::Type::GET ? "GET" : "POST") << " request to url '" << url << "'"); LOG(DEBUG, "Sending " << (request.getType() == ClientRequest::Type::GET ? "GET" : "POST") << " request to url '" << url << "'");
if (!isValidUrl(url))
{
LOG(ERROR, "Refusing request to '" << url << "': invalid URL");
return false;
}
_client.setMaximumResponseSize(request.getParameters().onChunkReceived ? 0 : request.getParameters().responseBufferSize); _client.setMaximumResponseSize(request.getParameters().onChunkReceived ? 0 : request.getParameters().responseBufferSize);
bool res{}; bool res{};
+28
View File
@@ -0,0 +1,28 @@
/*
* Copyright (C) 2025 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 "core/http/UrlValidation.hpp"
namespace lms::core::http
{
bool isValidUrl(std::string_view url)
{
return !url.empty() && (url.starts_with("http://") || url.starts_with("https://"));
}
} // namespace lms::core::http
@@ -0,0 +1,28 @@
/*
* Copyright (C) 2025 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 <string_view>
namespace lms::core::http
{
// Returns true if url is a well-formed absolute URL with an http:// or https:// scheme.
bool isValidUrl(std::string_view url);
} // namespace lms::core::http
+1
View File
@@ -2,6 +2,7 @@ include(GoogleTest)
add_executable(test-core add_executable(test-core
EnumSet.cpp EnumSet.cpp
HttpUrlValidation.cpp
JobScheduler.cpp JobScheduler.cpp
LiteralString.cpp LiteralString.cpp
PartialDateTime.cpp PartialDateTime.cpp
+43
View File
@@ -0,0 +1,43 @@
/*
* Copyright (C) 2025 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/http/UrlValidation.hpp"
namespace lms::core::http::tests
{
TEST(HttpUrlValidation, AllowedUrls)
{
EXPECT_TRUE(isValidUrl("http://feeds.example.com/podcast.rss"));
EXPECT_TRUE(isValidUrl("https://feeds.example.com/podcast.rss"));
EXPECT_TRUE(isValidUrl("http://192.168.1.100/feed.rss"));
EXPECT_TRUE(isValidUrl("https://example.com/episode.mp3"));
}
TEST(HttpUrlValidation, DisallowedUrls)
{
EXPECT_FALSE(isValidUrl(""));
EXPECT_FALSE(isValidUrl("ftp://attacker.com/feed"));
EXPECT_FALSE(isValidUrl("file:///etc/passwd"));
EXPECT_FALSE(isValidUrl("javascript:alert(1)"));
EXPECT_FALSE(isValidUrl("//example.com/feed"));
EXPECT_FALSE(isValidUrl("HTTP://example.com/feed")); // scheme check is case-sensitive
}
} // namespace lms::core::http::tests
+1
View File
@@ -11,6 +11,7 @@ add_library(lmspodcast STATIC
impl/Executor.cpp impl/Executor.cpp
impl/PodcastParsing.cpp impl/PodcastParsing.cpp
impl/PodcastService.cpp impl/PodcastService.cpp
impl/UrlValidation.cpp
) )
target_include_directories(lmspodcast INTERFACE target_include_directories(lmspodcast INTERFACE
@@ -0,0 +1,30 @@
/*
* Copyright (C) 2025 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 "UrlValidation.hpp"
#include "core/http/UrlValidation.hpp"
namespace lms::podcast
{
bool isAllowedPodcastUrl(std::string_view url)
{
return core::http::isValidUrl(url);
}
} // namespace lms::podcast
@@ -0,0 +1,27 @@
/*
* Copyright (C) 2025 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 <string_view>
namespace lms::podcast
{
bool isAllowedPodcastUrl(std::string_view url);
} // namespace lms::podcast
@@ -25,11 +25,14 @@
#include "core/ILogger.hpp" #include "core/ILogger.hpp"
#include "core/http/IClient.hpp" #include "core/http/IClient.hpp"
#include "database/IDb.hpp" #include "database/IDb.hpp"
#include "database/Session.hpp" #include "database/Session.hpp"
#include "database/objects/Artwork.hpp" #include "database/objects/Artwork.hpp"
#include "database/objects/Image.hpp" #include "database/objects/Image.hpp"
#include "database/objects/PodcastEpisode.hpp" #include "database/objects/PodcastEpisode.hpp"
#include "image/Exception.hpp"
#include "image/Image.hpp"
#include "Executor.hpp" #include "Executor.hpp"
#include "Utils.hpp" #include "Utils.hpp"
@@ -122,6 +125,19 @@ namespace lms::podcast
}; };
params.onSuccessFunc = [=, this](const Wt::Http::Message& msg) { params.onSuccessFunc = [=, this](const Wt::Http::Message& msg) {
getExecutor().post([=, this] { getExecutor().post([=, this] {
const std::string body{ msg.body() }; // API enforces a copy here :(
const auto bodySpan{ std::as_bytes(std::span{ body.data(), body.size() }) };
try
{
image::probeImage(bodySpan);
}
catch (const image::Exception& e)
{
LMS_LOG(PODCAST, WARNING, "Discarding non-image response for episode '" << episode->getTitle() << "' from '" << url << "': " << e.what());
processNext();
return;
}
std::ofstream file{ finalFilePath, std::ios::binary | std::ios::trunc }; std::ofstream file{ finalFilePath, std::ios::binary | std::ios::trunc };
if (!file) if (!file)
{ {
@@ -131,8 +147,7 @@ namespace lms::podcast
return; return;
} }
const std::string body{ msg.body() }; // API enforces a copy here file.write(body.data(), static_cast<std::streamsize>(body.size()));
file.write(body.data(), body.size());
if (!file) if (!file)
{ {
std::error_code ec{ errno, std::generic_category() }; std::error_code ec{ errno, std::generic_category() };
@@ -212,11 +212,13 @@ namespace lms::podcast
else else
{ {
LMS_LOG(PODCAST, WARNING, "Failed to get audio properties from downloaded episode from '" << url << "'"); LMS_LOG(PODCAST, WARNING, "Failed to get audio properties from downloaded episode from '" << url << "'");
utils::removeFile(tmpFilePath);
} }
} }
catch (const audio::Exception& e) catch (const audio::Exception& e)
{ {
LMS_LOG(PODCAST, WARNING, "Failed to parse downloaded episode from '" << url << "': " << e.what()); LMS_LOG(PODCAST, WARNING, "Failed to parse downloaded episode from '" << url << "': " << e.what());
utils::removeFile(tmpFilePath);
} }
processNext(); processNext();
@@ -29,6 +29,8 @@
#include "database/Session.hpp" #include "database/Session.hpp"
#include "database/objects/Artwork.hpp" #include "database/objects/Artwork.hpp"
#include "database/objects/Podcast.hpp" #include "database/objects/Podcast.hpp"
#include "image/Exception.hpp"
#include "image/Image.hpp"
#include "Executor.hpp" #include "Executor.hpp"
#include "Utils.hpp" #include "Utils.hpp"
@@ -121,9 +123,20 @@ namespace lms::podcast
}; };
params.onSuccessFunc = [=, this](const Wt::Http::Message& msg) { params.onSuccessFunc = [=, this](const Wt::Http::Message& msg) {
getExecutor().post([=, this] { getExecutor().post([=, this] {
const std::string body{ msg.body() }; // API enforces a copy here const std::string body{ msg.body() }; // API enforces a copy here :(
const auto bodySpan{ std::as_bytes(std::span{ body.data(), body.size() }) };
try
{
image::probeImage(bodySpan);
}
catch (const image::Exception& e)
{
LMS_LOG(PODCAST, WARNING, "Discarding non-image response for podcast artwork from '" << url << "': " << e.what());
processNext();
return;
}
std::ofstream file{ finalFilePath, std::ios::binary | std::ios::app }; std::ofstream file{ finalFilePath, std::ios::binary | std::ios::trunc };
if (!file) if (!file)
{ {
std::error_code ec{ errno, std::generic_category() }; std::error_code ec{ errno, std::generic_category() };
@@ -132,7 +145,7 @@ namespace lms::podcast
return; return;
} }
file.write(body.data(), body.size()); file.write(body.data(), static_cast<std::streamsize>(body.size()));
if (!file) if (!file)
{ {
std::error_code ec{ errno, std::generic_category() }; std::error_code ec{ errno, std::generic_category() };
@@ -31,6 +31,7 @@
#include "Executor.hpp" #include "Executor.hpp"
#include "PodcastParsing.hpp" #include "PodcastParsing.hpp"
#include "PodcastTypes.hpp" #include "PodcastTypes.hpp"
#include "UrlValidation.hpp"
namespace lms::podcast namespace lms::podcast
{ {
@@ -49,6 +50,40 @@ namespace lms::podcast
image.remove(); image.remove();
} }
void addEpisode(db::Session& session, const db::Podcast::pointer& dbPodcast, const PodcastEpisode& episode)
{
LMS_LOG(PODCAST, DEBUG, "Adding episode '" << episode.title << "' to podcast '" << dbPodcast->getTitle() << "'");
auto dbEpisode{ session.create<db::PodcastEpisode>(dbPodcast) };
dbEpisode.modify()->setAuthor(episode.author);
dbEpisode.modify()->setCategory(episode.category);
dbEpisode.modify()->setDescription(episode.description);
if (isAllowedPodcastUrl(episode.enclosureUrl.url))
{
dbEpisode.modify()->setEnclosureUrl(episode.enclosureUrl.url);
dbEpisode.modify()->setEnclosureContentType(episode.enclosureUrl.type);
dbEpisode.modify()->setEnclosureLength(episode.enclosureUrl.length);
}
else
{
LMS_LOG(PODCAST, WARNING, "Episode '" << episode.title << "' : ignoring enclosure URL '" << episode.enclosureUrl.url << "' (bad URL)");
}
dbEpisode.modify()->setExplicit(episode.explicitContent ? *episode.explicitContent : false);
dbEpisode.modify()->setLink(episode.link);
dbEpisode.modify()->setPubDate(episode.pubDate);
dbEpisode.modify()->setTitle(episode.title);
if (isAllowedPodcastUrl(episode.imageUrl))
dbEpisode.modify()->setImageUrl(episode.imageUrl);
else if (!episode.imageUrl.empty())
LMS_LOG(PODCAST, WARNING, "Episode '" << episode.title << "' : ignoring image URL '" << episode.imageUrl << "' (bad URL)");
dbEpisode.modify()->setDuration(episode.duration);
}
void updatePodcast(db::Session& session, db::PodcastId podcastId, const Podcast& podcast) void updatePodcast(db::Session& session, db::PodcastId podcastId, const Podcast& podcast)
{ {
auto transaction{ session.createWriteTransaction() }; auto transaction{ session.createWriteTransaction() };
@@ -62,8 +97,15 @@ namespace lms::podcast
// force update the podcast data // force update the podcast data
if (!podcast.newUrl.empty() && podcast.newUrl != dbPodcast->getUrl()) if (!podcast.newUrl.empty() && podcast.newUrl != dbPodcast->getUrl())
{ {
LMS_LOG(PODCAST, INFO, "Podcast '" << podcast.title << "' : URL changed from '" << dbPodcast->getUrl() << "' to '" << podcast.newUrl << "'"); if (isAllowedPodcastUrl(podcast.newUrl))
dbPodcast.modify()->setUrl(podcast.newUrl); {
LMS_LOG(PODCAST, INFO, "Podcast '" << podcast.title << "' : URL changed from '" << dbPodcast->getUrl() << "' to '" << podcast.newUrl << "'");
dbPodcast.modify()->setUrl(podcast.newUrl);
}
else
{
LMS_LOG(PODCAST, WARNING, "Podcast '" << podcast.title << "' : ignoring new podcast URL '" << podcast.newUrl << "' (bad URL)");
}
} }
dbPodcast.modify()->setAuthor(podcast.author); dbPodcast.modify()->setAuthor(podcast.author);
dbPodcast.modify()->setCategory(podcast.category); dbPodcast.modify()->setCategory(podcast.category);
@@ -80,14 +122,21 @@ namespace lms::podcast
dbPodcast.modify()->setTitle(podcast.title); dbPodcast.modify()->setTitle(podcast.title);
if (std::string previousUrl{ dbPodcast->getImageUrl() }; !previousUrl.empty() && previousUrl != podcast.imageUrl) if (std::string previousUrl{ dbPodcast->getImageUrl() }; !previousUrl.empty() && previousUrl != podcast.imageUrl)
{ {
LMS_LOG(PODCAST, INFO, "Podcast '" << podcast.title << "' : image url changed from '" << previousUrl << "' to '" << podcast.imageUrl << "'"); if (isAllowedPodcastUrl(podcast.imageUrl))
if (db::Artwork::pointer currentArtwork{ dbPodcast->getArtwork() })
{ {
removeArtwork(currentArtwork); LMS_LOG(PODCAST, INFO, "Podcast '" << podcast.title << "' : image url changed from '" << previousUrl << "' to '" << podcast.imageUrl << "'");
dbPodcast.modify()->setArtwork({}); if (db::Artwork::pointer currentArtwork{ dbPodcast->getArtwork() })
} {
removeArtwork(currentArtwork);
dbPodcast.modify()->setArtwork({});
}
dbPodcast.modify()->setImageUrl(podcast.imageUrl); dbPodcast.modify()->setImageUrl(podcast.imageUrl);
}
else
{
LMS_LOG(PODCAST, WARNING, "Podcast '" << podcast.title << "' : ignoring image URL '" << podcast.imageUrl << "' (disallowed scheme)");
}
} }
// Only create episodes if they are new, do not modify/update existing entries for now // Only create episodes if they are new, do not modify/update existing entries for now
@@ -102,22 +151,7 @@ namespace lms::podcast
if (previousNewestEpisodeDateTime.isValid() && episode.pubDate <= previousNewestEpisodeDateTime) if (previousNewestEpisodeDateTime.isValid() && episode.pubDate <= previousNewestEpisodeDateTime)
continue; // consider already in db continue; // consider already in db
LMS_LOG(PODCAST, DEBUG, "Adding episode '" << episode.title << "' to podcast '" << podcast.title << "'"); addEpisode(session, dbPodcast, episode);
auto dbEpisode{ session.create<db::PodcastEpisode>(dbPodcast) };
dbEpisode.modify()->setAuthor(episode.author);
dbEpisode.modify()->setCategory(episode.category);
dbEpisode.modify()->setDescription(episode.description);
dbEpisode.modify()->setEnclosureUrl(episode.enclosureUrl.url);
dbEpisode.modify()->setEnclosureContentType(episode.enclosureUrl.type);
dbEpisode.modify()->setEnclosureLength(episode.enclosureUrl.length);
dbEpisode.modify()->setExplicit(episode.explicitContent ? *episode.explicitContent : false);
dbEpisode.modify()->setLink(episode.link);
dbEpisode.modify()->setPubDate(episode.pubDate);
dbEpisode.modify()->setTitle(episode.title);
dbEpisode.modify()->setImageUrl(episode.imageUrl);
dbEpisode.modify()->setDuration(episode.duration);
} }
} }
} // namespace } // namespace