You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

579 lines
13 KiB
C++

// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright The Music Player Daemon Project
#include "CurlStorage.hxx"
#include "storage/StoragePlugin.hxx"
#include "storage/StorageInterface.hxx"
#include "storage/FileInfo.hxx"
#include "storage/MemoryDirectoryReader.hxx"
#include "input/InputStream.hxx"
#include "input/RewindInputStream.hxx"
#include "input/plugins/CurlInputPlugin.hxx"
#include "lib/curl/HttpStatusError.hxx"
#include "lib/curl/Init.hxx"
#include "lib/curl/Global.hxx"
#include "lib/curl/Slist.hxx"
#include "lib/curl/String.hxx"
#include "lib/curl/Request.hxx"
#include "lib/curl/Handler.hxx"
#include "lib/curl/Escape.hxx"
#include "lib/expat/ExpatParser.hxx"
#include "lib/fmt/ToBuffer.hxx"
#include "fs/Traits.hxx"
#include "event/InjectEvent.hxx"
#include "thread/Mutex.hxx"
#include "thread/Cond.hxx"
#include "util/ASCII.hxx"
#include "util/NumberParser.hxx"
#include "util/SpanCast.hxx"
#include "util/StringCompare.hxx"
#include "util/StringSplit.hxx"
#include "util/UriExtract.hxx"
#include <cassert>
#include <memory>
#include <string>
#include <utility>
using std::string_view_literals::operator""sv;
class CurlStorage final : public Storage {
const std::string base;
CurlInit curl;
public:
CurlStorage(EventLoop &_loop, const char *_base)
:base(_base),
curl(_loop) {}
/* virtual methods from class Storage */
StorageFileInfo GetInfo(std::string_view uri_utf8, bool follow) override;
std::unique_ptr<StorageDirectoryReader> OpenDirectory(std::string_view uri_utf8) override;
[[nodiscard]] std::string MapUTF8(std::string_view uri_utf8) const noexcept override;
[[nodiscard]] std::string_view MapToRelativeUTF8(std::string_view uri_utf8) const noexcept override;
InputStreamPtr OpenFile(std::string_view uri_utf8, Mutex &mutex) override;
};
std::string
CurlStorage::MapUTF8(std::string_view uri_utf8) const noexcept
{
if (uri_utf8.empty())
return base;
std::string path_esc = CurlEscapeUriPath(uri_utf8);
return PathTraitsUTF8::Build(base, path_esc);
}
std::string_view
CurlStorage::MapToRelativeUTF8(std::string_view uri_utf8) const noexcept
{
return PathTraitsUTF8::Relative(base,
CurlUnescape(uri_utf8));
}
InputStreamPtr
CurlStorage::OpenFile(std::string_view uri_utf8, Mutex &mutex)
{
return input_rewind_open(OpenCurlInputStream(MapUTF8(uri_utf8), {}, mutex));
}
class BlockingHttpRequest : protected CurlResponseHandler {
InjectEvent defer_start;
std::exception_ptr postponed_error;
bool done = false;
protected:
CurlRequest request;
Mutex mutex;
Cond cond;
public:
BlockingHttpRequest(CurlGlobal &curl, const char *uri)
:defer_start(curl.GetEventLoop(),
BIND_THIS_METHOD(OnDeferredStart)),
request(curl, uri, *this) {
// TODO: use CurlInputStream's configuration
}
void DeferStart() noexcept {
/* start the transfer inside the IOThread */
defer_start.Schedule();
}
void Wait() {
std::unique_lock lock{mutex};
cond.wait(lock, [this]{ return done; });
if (postponed_error)
std::rethrow_exception(postponed_error);
}
CURL *GetEasy() noexcept {
return request.Get();
}
protected:
void SetDone() {
assert(!done);
request.Stop();
done = true;
cond.notify_one();
}
void LockSetDone() {
const std::scoped_lock lock{mutex};
SetDone();
}
private:
/* InjectEvent callback */
void OnDeferredStart() noexcept {
assert(!done);
try {
request.Start();
} catch (...) {
OnError(std::current_exception());
}
}
/* virtual methods from CurlResponseHandler */
void OnError(std::exception_ptr e) noexcept final {
const std::scoped_lock lock{mutex};
postponed_error = std::move(e);
SetDone();
}
};
/**
* The (relevant) contents of a "<D:response>" element.
*/
struct DavResponse {
std::string href;
unsigned status = 0;
bool collection = false;
std::chrono::system_clock::time_point mtime =
std::chrono::system_clock::time_point::min();
uint64_t length = 0;
[[nodiscard]] bool Check() const {
return !href.empty();
}
};
[[gnu::pure]]
static unsigned
ParseStatus(std::string_view s) noexcept
{
/* skip the "HTTP/1.1" prefix */
const auto [http_1_1, rest] = Split(s, ' ');
/* skip the string suffix */
const auto [status_string, _] = Split(rest, ' ');
if (const auto status = ParseInteger<unsigned>(status_string))
return *status;
return 0;
}
[[gnu::pure]]
static std::chrono::system_clock::time_point
ParseTimeStamp(const char *s) noexcept
{
return std::chrono::system_clock::from_time_t(curl_getdate(s, nullptr));
}
[[gnu::pure]]
static std::chrono::system_clock::time_point
ParseTimeStamp(std::string_view s) noexcept
{
return ParseTimeStamp(std::string{s}.c_str());
}
[[gnu::pure]]
static uint64_t
ParseU64(std::string_view s) noexcept
{
if (const auto i = ParseInteger<uint_least64_t>(s))
return *i;
return 0;
}
[[gnu::pure]]
static bool
IsXmlContentType(const char *content_type) noexcept
{
return StringStartsWith(content_type, "text/xml") ||
StringStartsWith(content_type, "application/xml");
}
[[gnu::pure]]
static bool
IsXmlContentType(const Curl::Headers &headers) noexcept
{
auto i = headers.find("content-type");
return i != headers.end() && IsXmlContentType(i->second.c_str());
}
/**
* A WebDAV PROPFIND request. Each "response" element will be passed
* to OnDavResponse() (to be implemented by a derived class).
*/
class PropfindOperation : BlockingHttpRequest, CommonExpatParser {
CurlSlist request_headers;
enum class State {
ROOT,
RESPONSE,
PROPSTAT,
HREF,
STATUS,
TYPE,
MTIME,
LENGTH,
} state = State::ROOT;
DavResponse response;
public:
PropfindOperation(CurlGlobal &_curl, const char *_uri, unsigned depth)
:BlockingHttpRequest(_curl, _uri),
CommonExpatParser(ExpatNamespaceSeparator{'|'})
{
auto &easy = request.GetEasy();
easy.SetOption(CURLOPT_CUSTOMREQUEST, "PROPFIND");
easy.SetOption(CURLOPT_FOLLOWLOCATION, 1L);
easy.SetOption(CURLOPT_MAXREDIRS, 1L);
/* this option eliminates the probe request when
username/password are specified */
easy.SetOption(CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
request_headers.Append(FmtBuffer<40>("depth: {}", depth));
request_headers.Append("content-type: text/xml");
easy.SetRequestHeaders(request_headers.Get());
easy.SetRequestBody("<?xml version=\"1.0\"?>\n"
"<a:propfind xmlns:a=\"DAV:\">"
"<a:prop>"
"<a:resourcetype/>"
"<a:getcontenttype/>"
"<a:getcontentlength/>"
"<a:getlastmodified/>"
"</a:prop>"
"</a:propfind>"sv);
}
using BlockingHttpRequest::GetEasy;
using BlockingHttpRequest::DeferStart;
using BlockingHttpRequest::Wait;
protected:
virtual void OnDavResponse(DavResponse &&r) = 0;
private:
void FinishResponse() {
if (response.Check())
OnDavResponse(std::move(response));
response = DavResponse();
}
/* virtual methods from CurlResponseHandler */
void OnHeaders(unsigned status, Curl::Headers &&headers) final {
if (status != 207)
throw HttpStatusError(status,
FmtBuffer<80>("Status {} from WebDAV server; expected \"207 Multi-Status\"",
status));
if (!IsXmlContentType(headers))
throw std::runtime_error("Unexpected Content-Type from WebDAV server");
}
void OnData(std::span<const std::byte> src) final {
Parse(ToStringView(src));
}
void OnEnd() final {
CompleteParse();
LockSetDone();
}
/* virtual methods from CommonExpatParser */
void StartElement(const XML_Char *name,
[[maybe_unused]] const XML_Char **attrs) final {
switch (state) {
case State::ROOT:
if (strcmp(name, "DAV:|response") == 0)
state = State::RESPONSE;
break;
case State::RESPONSE:
if (strcmp(name, "DAV:|propstat") == 0)
state = State::PROPSTAT;
else if (strcmp(name, "DAV:|href") == 0)
state = State::HREF;
break;
case State::PROPSTAT:
if (strcmp(name, "DAV:|status") == 0)
state = State::STATUS;
else if (strcmp(name, "DAV:|resourcetype") == 0)
state = State::TYPE;
else if (strcmp(name, "DAV:|getlastmodified") == 0)
state = State::MTIME;
else if (strcmp(name, "DAV:|getcontentlength") == 0)
state = State::LENGTH;
break;
case State::TYPE:
if (strcmp(name, "DAV:|collection") == 0)
response.collection = true;
break;
case State::HREF:
case State::STATUS:
case State::LENGTH:
case State::MTIME:
break;
}
}
void EndElement(const XML_Char *name) final {
switch (state) {
case State::ROOT:
break;
case State::RESPONSE:
if (strcmp(name, "DAV:|response") == 0) {
state = State::ROOT;
}
break;
case State::PROPSTAT:
if (strcmp(name, "DAV:|propstat") == 0) {
FinishResponse();
state = State::RESPONSE;
}
break;
case State::HREF:
if (strcmp(name, "DAV:|href") == 0)
state = State::RESPONSE;
break;
case State::STATUS:
if (strcmp(name, "DAV:|status") == 0)
state = State::PROPSTAT;
break;
case State::TYPE:
if (strcmp(name, "DAV:|resourcetype") == 0)
state = State::PROPSTAT;
break;
case State::MTIME:
if (strcmp(name, "DAV:|getlastmodified") == 0)
state = State::PROPSTAT;
break;
case State::LENGTH:
if (strcmp(name, "DAV:|getcontentlength") == 0)
state = State::PROPSTAT;
break;
}
}
void CharacterData(std::string_view s) final {
switch (state) {
case State::ROOT:
case State::PROPSTAT:
case State::RESPONSE:
case State::TYPE:
break;
case State::HREF:
response.href.append(s);
break;
case State::STATUS:
response.status = ParseStatus(s);
break;
case State::MTIME:
response.mtime = ParseTimeStamp(s);
break;
case State::LENGTH:
response.length = ParseU64(s);
break;
}
}
};
/**
* Obtain information about a single file using WebDAV PROPFIND.
*/
class HttpGetInfoOperation final : public PropfindOperation {
StorageFileInfo info;
public:
HttpGetInfoOperation(CurlGlobal &curl, const char *uri)
:PropfindOperation(curl, uri, 0),
info(StorageFileInfo::Type::OTHER) {
}
const StorageFileInfo &Perform() {
DeferStart();
Wait();
return info;
}
protected:
/* virtual methods from PropfindOperation */
void OnDavResponse(DavResponse &&r) override {
if (r.status != 200)
return;
info.type = r.collection
? StorageFileInfo::Type::DIRECTORY
: StorageFileInfo::Type::REGULAR;
info.size = r.length;
info.mtime = r.mtime;
}
};
StorageFileInfo
CurlStorage::GetInfo(std::string_view uri_utf8, [[maybe_unused]] bool follow)
{
// TODO: escape the given URI
const auto uri = MapUTF8(uri_utf8);
return HttpGetInfoOperation(*curl, uri.c_str()).Perform();
}
[[gnu::pure]]
static std::string_view
UriPathOrSlash(const char *uri) noexcept
{
auto path = uri_get_path(uri);
if (path.data() == nullptr)
path = "/";
return path;
}
/**
* Obtain a directory listing using WebDAV PROPFIND.
*/
class HttpListDirectoryOperation final : public PropfindOperation {
const std::string base_path;
MemoryStorageDirectoryReader::List entries;
public:
HttpListDirectoryOperation(CurlGlobal &curl, const char *uri)
:PropfindOperation(curl, uri, 1),
base_path(CurlUnescape(GetEasy(), UriPathOrSlash(uri))) {}
std::unique_ptr<StorageDirectoryReader> Perform() {
DeferStart();
Wait();
return ToReader();
}
private:
std::unique_ptr<StorageDirectoryReader> ToReader() {
return std::make_unique<MemoryStorageDirectoryReader>(std::move(entries));
}
/**
* Convert a "href" attribute (which may be an absolute URI)
* to the base file name.
*/
[[gnu::pure]]
std::string_view HrefToEscapedName(const char *href) const noexcept {
std::string_view path = uri_get_path(href);
if (path.data() == nullptr)
return {};
/* kludge: ignoring case in this comparison to avoid
false negatives if the web server uses a different
case */
path = StringAfterPrefixIgnoreCase(path, base_path.c_str());
if (path.empty())
return {};
const auto slash = path.find('/');
if (slash == path.npos)
/* regular file */
return path;
else if (slash + 1 == path.size())
/* trailing slash: collection; strip the slash */
return path.substr(0, slash);
else
/* strange, better ignore it */
return {};
}
protected:
/* virtual methods from PropfindOperation */
void OnDavResponse(DavResponse &&r) override {
if (r.status != 200)
return;
std::string href = CurlUnescape(GetEasy(), r.href.c_str());
const auto name = HrefToEscapedName(href.c_str());
if (name.data() == nullptr)
return;
entries.emplace_front(name);
auto &info = entries.front().info;
info = StorageFileInfo(r.collection
? StorageFileInfo::Type::DIRECTORY
: StorageFileInfo::Type::REGULAR);
info.size = r.length;
info.mtime = r.mtime;
}
};
std::unique_ptr<StorageDirectoryReader>
CurlStorage::OpenDirectory(std::string_view uri_utf8)
{
std::string uri = MapUTF8(uri_utf8);
/* collection URIs must end with a slash */
if (uri.back() != '/')
uri.push_back('/');
return HttpListDirectoryOperation(*curl, uri.c_str()).Perform();
}
static std::unique_ptr<Storage>
CreateCurlStorageURI(EventLoop &event_loop, const char *uri)
{
return std::make_unique<CurlStorage>(event_loop, uri);
}
static constexpr const char *curl_prefixes[] = {
"http://", "https://", nullptr
};
const StoragePlugin curl_storage_plugin = {
"curl",
curl_prefixes,
CreateCurlStorageURI,
};