CurlStorage.cxx 13 KB
Newer Older
1
/*
Max Kellermann's avatar
Max Kellermann committed
2
 * Copyright 2003-2020 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 60 61

	/* virtual methods from class Storage */
	StorageFileInfo GetInfo(const char *uri_utf8, bool follow) override;

62
	std::unique_ptr<StorageDirectoryReader> OpenDirectory(const char *uri_utf8) override;
63

64
	std::string MapUTF8(const char *uri_utf8) const noexcept override;
65

66
	const char *MapToRelativeUTF8(const char *uri_utf8) const noexcept override;
67 68 69
};

std::string
70
CurlStorage::MapUTF8(const char *uri_utf8) const noexcept
71 72 73 74 75 76
{
	assert(uri_utf8 != nullptr);

	if (StringIsEmpty(uri_utf8))
		return base;

77
	std::string path_esc = CurlEscapeUriPath(uri_utf8);
78
	return PathTraitsUTF8::Build(base.c_str(), path_esc.c_str());
79 80 81
}

const char *
82
CurlStorage::MapToRelativeUTF8(const char *uri_utf8) const noexcept
83
{
84 85
	return PathTraitsUTF8::Relative(base.c_str(),
					CurlUnescape(uri_utf8).c_str());
86 87
}

88 89 90
class BlockingHttpRequest : protected CurlResponseHandler {
	DeferEvent defer_start;

91 92 93 94 95 96 97 98 99 100 101 102
	std::exception_ptr postponed_error;

	bool done = false;

protected:
	CurlRequest request;

	Mutex mutex;
	Cond cond;

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

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

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

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

122 123 124 125
	CURL *GetEasy() noexcept {
		return request.Get();
	}

126 127 128 129 130 131
protected:
	void SetDone() {
		assert(!done);

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

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

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

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

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

171
	[[nodiscard]] bool Check() const {
172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198
		return !href.empty();
	}
};

static unsigned
ParseStatus(const char *s)
{
	/* skip the "HTTP/1.1" prefix */
	const char *space = strchr(s, ' ');
	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
		return ParseTimePoint(s, "%a, %d %b %Y %T %Z");
199
	} catch (...) {
200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221
		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());
}

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

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());
}

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

	DavResponse response;

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

266
		request_headers.Append(StringFormat<40>("depth: %u", depth));
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
				  "<a:prop><a:resourcetype/></a:prop>"
274 275 276 277
				  "<a:prop><a:getcontenttype/></a:prop>"
				  "<a:prop><a:getcontentlength/></a:prop>"
				  "</a:propfind>");

278 279 280
		// TODO: send request body
	}

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 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 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 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424
		LockSetDone();
	}

	/* virtual methods from CommonExpatParser */
	void StartElement(const XML_Char *name,
			  gcc_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:|href") == 0)
				state = State::HREF;
			else 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) {
				FinishResponse();
				state = State::ROOT;
			}

			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::RESPONSE;
			break;

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

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

		case State::LENGTH:
			if (strcmp(name, "DAV:|getcontentlength") == 0)
				state = State::RESPONSE;
			break;
		}
	}

	void CharacterData(const XML_Char *s, int len) final {
		switch (state) {
		case State::ROOT:
		case State::RESPONSE:
		case State::TYPE:
			break;

		case State::HREF:
			response.href.assign(s, len);
			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)
425 426
		:PropfindOperation(curl, uri, 0),
		 info(StorageFileInfo::Type::OTHER) {
427 428 429
	}

	const StorageFileInfo &Perform() {
430
		DeferStart();
431 432 433 434 435 436 437 438 439 440 441 442 443 444
		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;
445
		info.mtime = r.mtime;
446 447 448 449 450 451 452 453
	}
};

StorageFileInfo
CurlStorage::GetInfo(const char *uri_utf8, gcc_unused bool follow)
{
	// TODO: escape the given URI

454
	const auto uri = MapUTF8(uri_utf8);
455 456 457 458
	return HttpGetInfoOperation(*curl, uri.c_str()).Perform();
}

gcc_pure
459
static std::string_view
460
UriPathOrSlash(const char *uri) noexcept
461
{
462 463
	auto path = uri_get_path(uri);
	if (path.data() == nullptr)
464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480
		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(UriPathOrSlash(uri)) {}

481
	std::unique_ptr<StorageDirectoryReader> Perform() {
482
		DeferStart();
483 484 485 486 487
		Wait();
		return ToReader();
	}

private:
488 489
	std::unique_ptr<StorageDirectoryReader> ToReader() {
		return std::make_unique<MemoryStorageDirectoryReader>(std::move(entries));
490 491 492 493 494 495 496
	}

	/**
	 * Convert a "href" attribute (which may be an absolute URI)
	 * to the base file name.
	 */
	gcc_pure
497
	StringView HrefToEscapedName(const char *href) const noexcept {
498
		StringView path = uri_get_path(href);
499 500 501
		if (path == nullptr)
			return nullptr;

502 503 504 505 506
		/* kludge: ignoring case in this comparison to avoid
		   false negatives if the web server uses a different
		   case in hex digits in escaped characters; TODO:
		   implement properly */
		path = StringAfterPrefixIgnoreCase(path, base_path.c_str());
507
		if (path == nullptr || path.empty())
508 509
			return nullptr;

510
		const char *slash = path.Find('/');
511 512 513
		if (slash == nullptr)
			/* regular file */
			return path;
514
		else if (slash == &path.back())
515
			/* trailing slash: collection; strip the slash */
516
			return {path.data, slash};
517 518 519 520 521 522 523 524 525 526 527 528 529 530 531
		else
			/* strange, better ignore it */
			return nullptr;
	}

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

		const auto escaped_name = HrefToEscapedName(r.href.c_str());
		if (escaped_name.IsNull())
			return;

532
		entries.emplace_front(CurlUnescape(GetEasy(), escaped_name));
533 534

		auto &info = entries.front().info;
535 536 537
		info = StorageFileInfo(r.collection
				       ? StorageFileInfo::Type::DIRECTORY
				       : StorageFileInfo::Type::REGULAR);
538
		info.size = r.length;
539
		info.mtime = r.mtime;
540 541 542
	}
};

543
std::unique_ptr<StorageDirectoryReader>
544 545
CurlStorage::OpenDirectory(const char *uri_utf8)
{
546
	std::string uri = MapUTF8(uri_utf8);
547 548 549 550 551 552 553 554

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

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

555
static std::unique_ptr<Storage>
556 557
CreateCurlStorageURI(EventLoop &event_loop, const char *uri)
{
558 559
	if (!StringStartsWithCaseASCII(uri, "http://") &&
	    !StringStartsWithCaseASCII(uri, "https://"))
560 561
		return nullptr;

562
	return std::make_unique<CurlStorage>(event_loop, uri);
563 564 565 566 567 568
}

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