1 #include "ttrssapi.h"
2 
3 #include <algorithm>
4 #include <cinttypes>
5 #include <cstring>
6 #include <thread>
7 #include <time.h>
8 
9 #include "3rd-party/json.hpp"
10 #include "logger.h"
11 #include "remoteapi.h"
12 #include "rss/feed.h"
13 #include "strprintf.h"
14 #include "utils.h"
15 
16 using json = nlohmann::json;
17 
18 namespace newsboat {
19 
TtRssApi(ConfigContainer * c)20 TtRssApi::TtRssApi(ConfigContainer* c)
21 	: RemoteApi(c)
22 {
23 	single = (cfg->get_configvalue("ttrss-mode") == "single");
24 	if (single) {
25 		auth_info = strprintf::fmt("%s:%s",
26 				cfg->get_configvalue("ttrss-login"),
27 				cfg->get_configvalue("ttrss-password"));
28 	} else {
29 		auth_info = "";
30 	}
31 	sid = "";
32 }
33 
~TtRssApi()34 TtRssApi::~TtRssApi() {}
35 
authenticate()36 bool TtRssApi::authenticate()
37 {
38 	if (auth_lock.try_lock()) {
39 		sid = retrieve_sid();
40 		auth_lock.unlock();
41 	} else {
42 		// wait for other thread to finish and return its result:
43 		auth_lock.lock();
44 		auth_lock.unlock();
45 	}
46 
47 	return sid != "";
48 }
49 
retrieve_sid()50 std::string TtRssApi::retrieve_sid()
51 {
52 	std::map<std::string, std::string> args;
53 
54 	Credentials cred = get_credentials("ttrss", "Tiny Tiny RSS");
55 	if (cred.user.empty() || cred.pass.empty()) {
56 		return "";
57 	}
58 
59 	args["user"] = single ? "admin" : cred.user.c_str();
60 	args["password"] = cred.pass.c_str();
61 	if (single) {
62 		auth_info = strprintf::fmt("%s:%s", cred.user, cred.pass);
63 	} else {
64 		auth_info = "";
65 	}
66 	json content = run_op("login", args);
67 
68 	if (content.is_null()) {
69 		return "";
70 	}
71 
72 	std::string sid;
73 	try {
74 		sid = content["session_id"];
75 	} catch (json::exception& e) {
76 		LOG(Level::INFO,
77 			"TtRssApi::retrieve_sid: couldn't extract session_id: "
78 			"%s",
79 			e.what());
80 	}
81 
82 	try {
83 		api_level = content["api_level"];
84 	} catch (json::exception& e) {
85 		LOG(Level::INFO,
86 			"TtRssApi::retrieve_sid: couldn't determine api_level "
87 			"from response: %s",
88 			e.what());
89 	}
90 
91 	LOG(Level::DEBUG, "TtRssApi::retrieve_sid: sid = '%s'", sid);
92 
93 	return sid;
94 }
95 
query_api_level()96 unsigned int TtRssApi::query_api_level()
97 {
98 	if (api_level == -1) {
99 		// api level was never queried. Do it now.
100 		std::map<std::string, std::string> args;
101 		json content = run_op("getApiLevel", args);
102 
103 		try {
104 			api_level = content["level"];
105 			LOG(Level::DEBUG,
106 				"TtRssApi::query_api_level: determined level: "
107 				"%d",
108 				api_level);
109 		} catch (json::exception& e) {
110 			// From
111 			// https://git.tt-rss.org/git/tt-rss/wiki/ApiReference
112 			// "Whether tt-rss returns error for this method (e.g.
113 			//  version:1.5.7 and below) client should assume API
114 			//  level 0."
115 			LOG(Level::DEBUG,
116 				"TtRssApi::query_api_level: failed to "
117 				"determine "
118 				"level, assuming 0");
119 			api_level = 0;
120 		}
121 	}
122 	return api_level;
123 }
124 
run_op(const std::string & op,const std::map<std::string,std::string> & args,bool try_login,CURL * cached_handle)125 json TtRssApi::run_op(const std::string& op,
126 	const std::map<std::string, std::string>& args,
127 	bool try_login, /* = true */
128 	CURL* cached_handle /* = nullptr */)
129 {
130 	std::string url =
131 		strprintf::fmt("%s/api/", cfg->get_configvalue("ttrss-url"));
132 
133 	// First build the request payload
134 	std::string req_data;
135 	{
136 		json requestparam;
137 
138 		requestparam["op"] = op;
139 		if (!sid.empty()) {
140 			requestparam["sid"] = sid;
141 		}
142 
143 		// Note: We are violating the upstream-api's types here by
144 		// packing all information
145 		//       into strings. If things start to break, this would be a
146 		//       good place to start.
147 		for (const auto& arg : args) {
148 			requestparam[arg.first] = arg.second;
149 		}
150 
151 		req_data = requestparam.dump();
152 	}
153 
154 	std::string result = utils::retrieve_url(
155 			url, cfg, auth_info, &req_data, utils::HTTPMethod::POST, cached_handle);
156 
157 	LOG(Level::DEBUG,
158 		"TtRssApi::run_op(%s,...): post=%s reply = %s",
159 		op,
160 		req_data,
161 		result);
162 
163 	json reply;
164 	try {
165 		reply = json::parse(result);
166 	} catch (json::parse_error& e) {
167 		LOG(Level::ERROR,
168 			"TtRssApi::run_op: reply failed to parse: %s",
169 			result);
170 		return json(nullptr);
171 	}
172 
173 	int status;
174 	try {
175 		status = reply.at("status");
176 	} catch (json::exception& e) {
177 		LOG(Level::ERROR,
178 			"TtRssApi::run_op: no status code: %s",
179 			e.what());
180 		return json(nullptr);
181 	}
182 
183 	json content;
184 	try {
185 		content = reply.at("content");
186 	} catch (json::exception& e) {
187 		LOG(Level::ERROR,
188 			"TtRssApi::run_op: no content part in answer from "
189 			"server");
190 		return json(nullptr);
191 	}
192 
193 	if (status != 0) {
194 		if (content["error"] == "NOT_LOGGED_IN" && try_login) {
195 			if (authenticate()) {
196 				return run_op(op, args, false, cached_handle);
197 			} else {
198 				return json(nullptr);
199 			}
200 		} else {
201 			LOG(Level::ERROR,
202 				"TtRssApi::run_op: status: %d, error: '%s'",
203 				status,
204 				content["error"].dump());
205 			return json(nullptr);
206 		}
207 	}
208 
209 	return content;
210 }
211 
feed_from_json(const json & jfeed,const std::vector<std::string> & addtags)212 TaggedFeedUrl TtRssApi::feed_from_json(const json& jfeed,
213 	const std::vector<std::string>& addtags)
214 {
215 	const int feed_id = jfeed["id"];
216 	const std::string feed_title = jfeed["title"];
217 	const std::string feed_url = jfeed["feed_url"];
218 
219 	std::vector<std::string> tags;
220 	// automatically tag by feedtitle
221 	tags.push_back(std::string("~") + feed_title);
222 
223 	// add additional tags
224 	tags.insert(tags.end(), addtags.cbegin(), addtags.cend());
225 
226 	auto url = strprintf::fmt("%s#%d", feed_url, feed_id);
227 	return TaggedFeedUrl(url, tags);
228 	// TODO: cache feed_id -> feed_url (or feed_url -> feed_id ?)
229 }
230 
parse_category_id(const json & jcatid)231 int TtRssApi::parse_category_id(const json& jcatid)
232 {
233 	int cat_id;
234 	// TTRSS (commit "b0113adac42383b8039eb92ccf3ee2ec0ee70346") returns a
235 	// string for regular items and an integer for predefined categories
236 	// (like -1)
237 	if (jcatid.is_string()) {
238 		cat_id = std::stoi(jcatid.get<std::string>());
239 	} else {
240 		cat_id = jcatid;
241 	}
242 	return cat_id;
243 }
244 
get_subscribed_urls()245 std::vector<TaggedFeedUrl> TtRssApi::get_subscribed_urls()
246 {
247 	std::vector<TaggedFeedUrl> feeds;
248 
249 	json categories =
250 		run_op("getCategories", std::map<std::string, std::string>());
251 
252 	if (query_api_level() >= 2) {
253 		// getFeeds with cat_id -3 since 1.5.0, so at least since
254 		// api-level 2
255 		std::map<int, std::string> category_names;
256 		for (const auto& cat : categories) {
257 			std::string cat_name = cat["title"];
258 			int cat_id = parse_category_id(cat["id"]);
259 			category_names[cat_id] = cat_name;
260 		}
261 
262 		std::map<std::string, std::string> args;
263 		// All feeds, excluding virtual feeds (e.g. Labels and such)
264 		args["cat_id"] = "-3";
265 		json feedlist = run_op("getFeeds", args);
266 
267 		if (feedlist.is_null()) {
268 			LOG(Level::ERROR,
269 				"TtRssApi::get_subscribed_urls: Failed to "
270 				"retrieve feedlist");
271 			return feeds;
272 		}
273 
274 		for (json& feed : feedlist) {
275 			const int cat_id = feed["cat_id"];
276 			std::vector<std::string> tags;
277 			if (cat_id > 0) {
278 				tags.push_back(category_names[cat_id]);
279 			}
280 			feeds.push_back(feed_from_json(feed, tags));
281 		}
282 
283 	} else {
284 		try {
285 			// first fetch feeds within no category
286 			fetch_feeds_per_category(json(nullptr), feeds);
287 
288 			// then fetch the feeds of all categories
289 			for (const auto& i : categories) {
290 				fetch_feeds_per_category(i, feeds);
291 			}
292 		} catch (json::exception& e) {
293 			LOG(Level::ERROR,
294 				"TtRssApi::get_subscribed_urls:"
295 				" Failed to determine subscribed urls: %s",
296 				e.what());
297 			return std::vector<TaggedFeedUrl>();
298 		}
299 	}
300 
301 	return feeds;
302 }
303 
add_custom_headers(curl_slist **)304 void TtRssApi::add_custom_headers(curl_slist** /* custom_headers */)
305 {
306 	// nothing required
307 }
308 
mark_all_read(const std::string & feed_url)309 bool TtRssApi::mark_all_read(const std::string& feed_url)
310 {
311 	std::map<std::string, std::string> args;
312 	args["feed_id"] = url_to_id(feed_url);
313 	json content = run_op("catchupFeed", args);
314 
315 	if (content.is_null()) {
316 		return false;
317 	}
318 
319 	return true;
320 }
321 
mark_article_read(const std::string & guid,bool read)322 bool TtRssApi::mark_article_read(const std::string& guid, bool read)
323 {
324 	// Do this in a thread, as we don't care about the result enough to wait
325 	// for it.
326 	std::thread t{[=]()
327 	{
328 		LOG(Level::DEBUG,
329 			"TtRssApi::mark_article_read: inside thread, marking "
330 			"thread as read...");
331 
332 		// Call the TtRssApi's update_article function as a thread.
333 		this->update_article(guid, 2, read ? 0 : 1);
334 	}};
335 	t.detach();
336 	return true;
337 }
338 
update_article_flags(const std::string & oldflags,const std::string & newflags,const std::string & guid)339 bool TtRssApi::update_article_flags(const std::string& oldflags,
340 	const std::string& newflags,
341 	const std::string& guid)
342 {
343 	std::string star_flag = cfg->get_configvalue("ttrss-flag-star");
344 	std::string publish_flag = cfg->get_configvalue("ttrss-flag-publish");
345 	bool success = true;
346 
347 	if (star_flag.length() > 0) {
348 		if (strchr(oldflags.c_str(), star_flag[0]) == nullptr &&
349 			strchr(newflags.c_str(), star_flag[0]) != nullptr) {
350 			success = star_article(guid, true);
351 		} else if (strchr(oldflags.c_str(), star_flag[0]) != nullptr &&
352 			strchr(newflags.c_str(), star_flag[0]) == nullptr) {
353 			success = star_article(guid, false);
354 		}
355 	}
356 
357 	if (publish_flag.length() > 0) {
358 		if (strchr(oldflags.c_str(), publish_flag[0]) == nullptr &&
359 			strchr(newflags.c_str(), publish_flag[0]) != nullptr) {
360 			success = publish_article(guid, true);
361 		} else if (strchr(oldflags.c_str(), publish_flag[0]) !=
362 			nullptr &&
363 			strchr(newflags.c_str(), publish_flag[0]) == nullptr) {
364 			success = publish_article(guid, false);
365 		}
366 	}
367 
368 	return success;
369 }
370 
fetch_feed(const std::string & id,CURL * cached_handle)371 rsspp::Feed TtRssApi::fetch_feed(const std::string& id, CURL* cached_handle)
372 {
373 	rsspp::Feed f;
374 
375 	f.rss_version = rsspp::Feed::TTRSS_JSON;
376 
377 	std::map<std::string, std::string> args;
378 	args["feed_id"] = id;
379 	args["show_content"] = "1";
380 	args["include_attachments"] = "1";
381 	json content = run_op("getHeadlines", args, true, cached_handle);
382 
383 	if (content.is_null()) {
384 		return f;
385 	}
386 
387 	if (!content.is_array()) {
388 		LOG(Level::ERROR,
389 			"TtRssApi::fetch_feed: content is not an array");
390 		return f;
391 	}
392 
393 	LOG(Level::DEBUG,
394 		"TtRssApi::fetch_feed: %" PRIu64 " items",
395 		static_cast<uint64_t>(content.size()));
396 
397 	try {
398 		for (const auto& item_obj : content) {
399 			rsspp::Item item;
400 
401 			if (!item_obj["title"].is_null()) {
402 				item.title = item_obj["title"];
403 			}
404 
405 			if (!item_obj["link"].is_null()) {
406 				item.link = item_obj["link"];
407 			}
408 
409 			if (!item_obj["author"].is_null()) {
410 				item.author = item_obj["author"];
411 			}
412 
413 			if (!item_obj["content"].is_null()) {
414 				item.content_encoded = item_obj["content"];
415 			}
416 
417 			if (!item_obj["attachments"].is_null()) {
418 				for (const json& a : item_obj["attachments"]) {
419 					if (!a["content_url"].is_null() && !a["content_type"].is_null()
420 						&& newsboat::utils::is_valid_podcast_type(a["content_type"])) {
421 						item.enclosure_type =
422 							a["content_type"];
423 						item.enclosure_url = a["content_url"];
424 						break;
425 					}
426 				}
427 			}
428 
429 			int id = item_obj["id"];
430 			item.guid = strprintf::fmt("%d", id);
431 
432 			bool unread = item_obj["unread"];
433 			if (unread) {
434 				item.labels.push_back("ttrss:unread");
435 			} else {
436 				item.labels.push_back("ttrss:read");
437 			}
438 
439 			int updated_time = item_obj["updated"];
440 			time_t updated = static_cast<time_t>(updated_time);
441 
442 			item.pubDate = utils::mt_strf_localtime(
443 					"%a, %d %b %Y %H:%M:%S %z",
444 					updated);
445 			item.pubDate_ts = updated;
446 
447 			f.items.push_back(item);
448 		}
449 	} catch (json::exception& e) {
450 		LOG(Level::ERROR,
451 			"Exception occurred while parsing feeed: ",
452 			e.what());
453 	}
454 
455 	std::sort(f.items.begin(),
456 		f.items.end(),
457 	[](const rsspp::Item& a, const rsspp::Item& b) {
458 		return a.pubDate_ts > b.pubDate_ts;
459 	});
460 
461 	return f;
462 }
463 
fetch_feeds_per_category(const json & cat,std::vector<TaggedFeedUrl> & feeds)464 void TtRssApi::fetch_feeds_per_category(const json& cat,
465 	std::vector<TaggedFeedUrl>& feeds)
466 {
467 	json cat_name;
468 
469 	if (cat.is_null()) {
470 		// As uncategorized is a category itself (id = 0) and the
471 		// default value for a getFeeds is id = 0, the feeds in
472 		// uncategorized will appear twice
473 		return;
474 	}
475 
476 	std::string cat_id;
477 	// TTRSS (commit "b0113adac42383b8039eb92ccf3ee2ec0ee70346") returns a
478 	// string for regular items and an integer for predefined categories
479 	// (like -1)
480 	if (cat["id"].is_string()) {
481 		cat_id = cat["id"];
482 	} else {
483 		int i = cat["id"];
484 		cat_id = std::to_string(i);
485 	}
486 
487 	// ignore special categories, for now
488 	if (std::stoi(cat_id) < 0) {
489 		return;
490 	}
491 
492 	cat_name = cat["title"];
493 	LOG(Level::DEBUG,
494 		"TtRssApi::fetch_feeds_per_category: fetching id = %s title = "
495 		"%s",
496 		cat_id,
497 		cat_name.is_null() ? "<null>" : cat_name.get<std::string>());
498 
499 	std::map<std::string, std::string> args;
500 	args["cat_id"] = cat_id;
501 
502 	json feed_list_obj = run_op("getFeeds", args);
503 
504 	if (feed_list_obj.is_null()) {
505 		return;
506 	}
507 
508 	// Automatically provide the category as a tag
509 	std::vector<std::string> tags;
510 	if (!cat_name.is_null()) {
511 		tags.push_back(cat_name.get<std::string>());
512 	}
513 
514 	for (json& feed : feed_list_obj) {
515 		feeds.push_back(feed_from_json(feed, tags));
516 	}
517 }
518 
star_article(const std::string & guid,bool star)519 bool TtRssApi::star_article(const std::string& guid, bool star)
520 {
521 	return update_article(guid, 0, star ? 1 : 0);
522 }
523 
publish_article(const std::string & guid,bool publish)524 bool TtRssApi::publish_article(const std::string& guid, bool publish)
525 {
526 	return update_article(guid, 1, publish ? 1 : 0);
527 }
528 
update_article(const std::string & guid,int field,int mode)529 bool TtRssApi::update_article(const std::string& guid, int field, int mode)
530 {
531 	std::map<std::string, std::string> args;
532 	args["article_ids"] = guid;
533 	args["field"] = std::to_string(field);
534 	args["mode"] = std::to_string(mode);
535 	json content = run_op("updateArticle", args);
536 
537 	if (content.is_null()) {
538 		return false;
539 	}
540 
541 	return true;
542 }
543 
url_to_id(const std::string & url)544 std::string TtRssApi::url_to_id(const std::string& url)
545 {
546 	const std::string::size_type pound = url.find_first_of('#');
547 	if (pound == std::string::npos) {
548 		return "";
549 	} else {
550 		return url.substr(pound + 1);
551 	}
552 }
553 
554 } // namespace newsboat
555