CurlStorage.cxx 13.3 KB
Newer Older
1
/*
Max Kellermann's avatar
Max Kellermann committed
2
 * Copyright 2003-2021 The Music Player Daemon Project
3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
 * http://www.musicpd.org
 *
 * This program 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 2 of the License, or
 * (at your option) any later version.
 *
 * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 */

#include "CurlStorage.hxx"
#include "storage/StoragePlugin.hxx"
#include "storage/StorageInterface.hxx"
#include "storage/FileInfo.hxx"
#include "storage/MemoryDirectoryReader.hxx"
25
#include "lib/curl/Init.hxx"
26 27
#include "lib/curl/Global.hxx"
#include "lib/curl/Slist.hxx"
28
#include "lib/curl/String.hxx"
29 30
#include "lib/curl/Request.hxx"
#include "lib/curl/Handler.hxx"
31
#include "lib/curl/Escape.hxx"
32 33
#include "lib/expat/ExpatParser.hxx"
#include "fs/Traits.hxx"
34
#include "event/DeferEvent.hxx"
35 36
#include "thread/Mutex.hxx"
#include "thread/Cond.hxx"
37
#include "time/Parser.hxx"
38
#include "util/ASCII.hxx"
39 40
#include "util/RuntimeError.hxx"
#include "util/StringCompare.hxx"
41
#include "util/StringFormat.hxx"
Max Kellermann's avatar
Max Kellermann committed
42
#include "util/UriExtract.hxx"
43

44
#include <cassert>
45 46
#include <memory>
#include <string>
47
#include <utility>
48 49 50 51

class CurlStorage final : public Storage {
	const std::string base;

52
	CurlInit curl;
53 54 55 56

public:
	CurlStorage(EventLoop &_loop, const char *_base)
		:base(_base),
57
		 curl(_loop) {}
58 59

	/* virtual methods from class Storage */
60
	StorageFileInfo GetInfo(std::string_view uri_utf8, bool follow) override;
61

62
	std::unique_ptr<StorageDirectoryReader> OpenDirectory(std::string_view uri_utf8) override;
63

64
	[[nodiscard]] std::string MapUTF8(std::string_view uri_utf8) const noexcept override;
65

66
	[[nodiscard]] std::string_view MapToRelativeUTF8(std::string_view uri_utf8) const noexcept override;
67 68 69
};

std::string
70
CurlStorage::MapUTF8(std::string_view uri_utf8) const noexcept
71
{
72
	if (uri_utf8.empty())
73 74
		return base;

75
	std::string path_esc = CurlEscapeUriPath(uri_utf8);
76
	return PathTraitsUTF8::Build(base, path_esc);
77 78
}

79 80
std::string_view
CurlStorage::MapToRelativeUTF8(std::string_view uri_utf8) const noexcept
81
{
82
	return PathTraitsUTF8::Relative(base,
83
					CurlUnescape(uri_utf8));
84 85
}

86 87 88
class BlockingHttpRequest : protected CurlResponseHandler {
	DeferEvent defer_start;

89 90 91 92 93 94 95 96 97 98 99 100
	std::exception_ptr postponed_error;

	bool done = false;

protected:
	CurlRequest request;

	Mutex mutex;
	Cond cond;

public:
	BlockingHttpRequest(CurlGlobal &curl, const char *uri)
101 102
		:defer_start(curl.GetEventLoop(),
			     BIND_THIS_METHOD(OnDeferredStart)),
103 104
		 request(curl, uri, *this) {
		// TODO: use CurlInputStream's configuration
105
	}
106

107
	void DeferStart() noexcept {
108
		/* start the transfer inside the IOThread */
109
		defer_start.Schedule();
110 111 112
	}

	void Wait() {
113
		std::unique_lock<Mutex> lock(mutex);
114
		cond.wait(lock, [this]{ return done; });
115 116 117 118 119

		if (postponed_error)
			std::rethrow_exception(postponed_error);
	}

120 121 122 123
	CURL *GetEasy() noexcept {
		return request.Get();
	}

124 125 126 127 128 129
protected:
	void SetDone() {
		assert(!done);

		request.Stop();
		done = true;
130
		cond.notify_one();
131 132 133 134 135 136 137 138
	}

	void LockSetDone() {
		const std::lock_guard<Mutex> lock(mutex);
		SetDone();
	}

private:
139 140
	/* DeferEvent callback */
	void OnDeferredStart() noexcept {
141 142
		assert(!done);

143 144 145 146 147
		try {
			request.Start();
		} catch (...) {
			OnError(std::current_exception());
		}
148 149 150
	}

	/* virtual methods from CurlResponseHandler */
151
	void OnError(std::exception_ptr e) noexcept final {
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168
		const std::lock_guard<Mutex> 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;

169
	[[nodiscard]] bool Check() const {
170 171 172 173 174 175 176 177
		return !href.empty();
	}
};

static unsigned
ParseStatus(const char *s)
{
	/* skip the "HTTP/1.1" prefix */
Rosen Penev's avatar
Rosen Penev committed
178
	const char *space = std::strchr(s, ' ');
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195
	if (space == nullptr)
		return 0;

	return strtoul(space + 1, nullptr, 10);
}

static unsigned
ParseStatus(const char *s, size_t length)
{
	return ParseStatus(std::string(s, length).c_str());
}

static std::chrono::system_clock::time_point
ParseTimeStamp(const char *s)
{
	try {
		// TODO: make this more robust
196
		return ParseTimePoint(s, "%a, %d %b %Y %T");
197
	} catch (...) {
198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219
		return std::chrono::system_clock::time_point::min();
	}
}

static std::chrono::system_clock::time_point
ParseTimeStamp(const char *s, size_t length)
{
	return ParseTimeStamp(std::string(s, length).c_str());
}

static uint64_t
ParseU64(const char *s)
{
	return strtoull(s, nullptr, 10);
}

static uint64_t
ParseU64(const char *s, size_t length)
{
	return ParseU64(std::string(s, length).c_str());
}

220 221 222 223
gcc_pure
static bool
IsXmlContentType(const char *content_type) noexcept
{
224 225
	return StringStartsWith(content_type, "text/xml") ||
		StringStartsWith(content_type, "application/xml");
226 227 228 229 230 231 232 233 234 235
}

gcc_pure
static bool
IsXmlContentType(const std::multimap<std::string, std::string> &headers) noexcept
{
	auto i = headers.find("content-type");
	return i != headers.end() && IsXmlContentType(i->second.c_str());
}

236 237 238 239 240 241 242 243 244 245
/**
 * 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,
246
		PROPSTAT,
247 248 249 250 251 252 253 254 255 256 257 258
		HREF,
		STATUS,
		TYPE,
		MTIME,
		LENGTH,
	} state = State::ROOT;

	DavResponse response;

public:
	PropfindOperation(CurlGlobal &_curl, const char *_uri, unsigned depth)
		:BlockingHttpRequest(_curl, _uri),
259
		 CommonExpatParser(ExpatNamespaceSeparator{'|'})
260 261
	{
		request.SetOption(CURLOPT_CUSTOMREQUEST, "PROPFIND");
262 263
		request.SetOption(CURLOPT_FOLLOWLOCATION, 1L);
		request.SetOption(CURLOPT_MAXREDIRS, 1L);
264

265
		request_headers.Append(StringFormat<40>("depth: %u", depth));
266
		request_headers.Append("content-type: text/xml");
267 268 269

		request.SetOption(CURLOPT_HTTPHEADER, request_headers.Get());

270 271 272
		request.SetOption(CURLOPT_POSTFIELDS,
				  "<?xml version=\"1.0\"?>\n"
				  "<a:propfind xmlns:a=\"DAV:\">"
273 274 275 276 277
				  "<a:prop>"
				  "<a:resourcetype/>"
				  "<a:getcontenttype/>"
				  "<a:getcontentlength/>"
				  "</a:prop>"
278
				  "</a:propfind>");
279 280
	}

281
	using BlockingHttpRequest::GetEasy;
282
	using BlockingHttpRequest::DeferStart;
283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301
	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,
		       std::multimap<std::string, std::string> &&headers) final {
		if (status != 207)
			throw FormatRuntimeError("Status %d from WebDAV server; expected \"207 Multi-Status\"",
						 status);

302
		if (!IsXmlContentType(headers))
303 304 305 306 307
			throw std::runtime_error("Unexpected Content-Type from WebDAV server");
	}

	void OnData(ConstBuffer<void> _data) final {
		const auto data = ConstBuffer<char>::FromVoid(_data);
308
		Parse(data.data, data.size);
309 310 311
	}

	void OnEnd() final {
312
		CompleteParse();
313 314 315 316 317
		LockSetDone();
	}

	/* virtual methods from CommonExpatParser */
	void StartElement(const XML_Char *name,
Rosen Penev's avatar
Rosen Penev committed
318
			  [[maybe_unused]] const XML_Char **attrs) final {
319 320 321 322 323 324 325
		switch (state) {
		case State::ROOT:
			if (strcmp(name, "DAV:|response") == 0)
				state = State::RESPONSE;
			break;

		case State::RESPONSE:
326 327 328
			if (strcmp(name, "DAV:|propstat") == 0)
				state = State::PROPSTAT;
			else if (strcmp(name, "DAV:|href") == 0)
329
				state = State::HREF;
330 331 332
			break;
		case State::PROPSTAT:
			if (strcmp(name, "DAV:|status") == 0)
333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363
				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;
			}
364 365 366 367 368 369 370
			break;

		case State::PROPSTAT:
			if (strcmp(name, "DAV:|propstat") == 0) {
				FinishResponse();
				state = State::RESPONSE;
			}
371 372 373 374 375 376 377 378 379 380

			break;

		case State::HREF:
			if (strcmp(name, "DAV:|href") == 0)
				state = State::RESPONSE;
			break;

		case State::STATUS:
			if (strcmp(name, "DAV:|status") == 0)
381
				state = State::PROPSTAT;
382 383 384 385
			break;

		case State::TYPE:
			if (strcmp(name, "DAV:|resourcetype") == 0)
386
				state = State::PROPSTAT;
387 388 389 390
			break;

		case State::MTIME:
			if (strcmp(name, "DAV:|getlastmodified") == 0)
391
				state = State::PROPSTAT;
392 393 394 395
			break;

		case State::LENGTH:
			if (strcmp(name, "DAV:|getcontentlength") == 0)
396
				state = State::PROPSTAT;
397 398 399 400 401 402 403
			break;
		}
	}

	void CharacterData(const XML_Char *s, int len) final {
		switch (state) {
		case State::ROOT:
404
		case State::PROPSTAT:
405 406 407 408 409
		case State::RESPONSE:
		case State::TYPE:
			break;

		case State::HREF:
410
			response.href.append(s, len);
411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435
			break;

		case State::STATUS:
			response.status = ParseStatus(s, len);
			break;

		case State::MTIME:
			response.mtime = ParseTimeStamp(s, len);
			break;

		case State::LENGTH:
			response.length = ParseU64(s, len);
			break;
		}
	}
};

/**
 * Obtain information about a single file using WebDAV PROPFIND.
 */
class HttpGetInfoOperation final : public PropfindOperation {
	StorageFileInfo info;

public:
	HttpGetInfoOperation(CurlGlobal &curl, const char *uri)
436 437
		:PropfindOperation(curl, uri, 0),
		 info(StorageFileInfo::Type::OTHER) {
438 439 440
	}

	const StorageFileInfo &Perform() {
441
		DeferStart();
442 443 444 445 446 447 448 449 450 451 452 453 454 455
		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;
456
		info.mtime = r.mtime;
457 458 459 460
	}
};

StorageFileInfo
461
CurlStorage::GetInfo(std::string_view uri_utf8, [[maybe_unused]] bool follow)
462 463 464
{
	// TODO: escape the given URI

465
	const auto uri = MapUTF8(uri_utf8);
466 467 468 469
	return HttpGetInfoOperation(*curl, uri.c_str()).Perform();
}

gcc_pure
470
static std::string_view
471
UriPathOrSlash(const char *uri) noexcept
472
{
473 474
	auto path = uri_get_path(uri);
	if (path.data() == nullptr)
475 476 477 478 479 480 481 482 483 484 485 486 487 488 489
		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),
490
		 base_path(CurlUnescape(GetEasy(), UriPathOrSlash(uri))) {}
491

492
	std::unique_ptr<StorageDirectoryReader> Perform() {
493
		DeferStart();
494 495 496 497 498
		Wait();
		return ToReader();
	}

private:
499 500
	std::unique_ptr<StorageDirectoryReader> ToReader() {
		return std::make_unique<MemoryStorageDirectoryReader>(std::move(entries));
501 502 503 504 505 506 507
	}

	/**
	 * Convert a "href" attribute (which may be an absolute URI)
	 * to the base file name.
	 */
	gcc_pure
508
	StringView HrefToEscapedName(const char *href) const noexcept {
509
		StringView path = uri_get_path(href);
510 511 512
		if (path == nullptr)
			return nullptr;

513 514
		/* kludge: ignoring case in this comparison to avoid
		   false negatives if the web server uses a different
515
		   case */
516 517
		path = StringAfterPrefixIgnoreCase(path, base_path.c_str());
		if (path == nullptr || path.empty())
518 519
			return nullptr;

520
		const char *slash = path.Find('/');
521 522
		if (slash == nullptr)
			/* regular file */
523 524
			return path;
		else if (slash == &path.back())
525
			/* trailing slash: collection; strip the slash */
526
			return {path.data, slash};
527 528 529 530 531 532 533 534 535 536 537
		else
			/* strange, better ignore it */
			return nullptr;
	}

protected:
	/* virtual methods from PropfindOperation */
	void OnDavResponse(DavResponse &&r) override {
		if (r.status != 200)
			return;

538 539 540
		std::string href = CurlUnescape(GetEasy(), r.href.c_str());
		const auto name = HrefToEscapedName(href.c_str());
		if (name.IsNull())
541 542
			return;

543
		entries.emplace_front(std::string(name.data, name.size));
544 545

		auto &info = entries.front().info;
546 547 548
		info = StorageFileInfo(r.collection
				       ? StorageFileInfo::Type::DIRECTORY
				       : StorageFileInfo::Type::REGULAR);
549
		info.size = r.length;
550
		info.mtime = r.mtime;
551 552 553
	}
};

554
std::unique_ptr<StorageDirectoryReader>
555
CurlStorage::OpenDirectory(std::string_view uri_utf8)
556
{
557
	std::string uri = MapUTF8(uri_utf8);
558 559 560 561 562 563 564 565

	/* collection URIs must end with a slash */
	if (uri.back() != '/')
		uri.push_back('/');

	return HttpListDirectoryOperation(*curl, uri.c_str()).Perform();
}

566
static std::unique_ptr<Storage>
567 568
CreateCurlStorageURI(EventLoop &event_loop, const char *uri)
{
569 570
	if (!StringStartsWithCaseASCII(uri, "http://") &&
	    !StringStartsWithCaseASCII(uri, "https://"))
571 572
		return nullptr;

573
	return std::make_unique<CurlStorage>(event_loop, uri);
574 575 576 577 578 579
}

const StoragePlugin curl_storage_plugin = {
	"curl",
	CreateCurlStorageURI,
};