From c7f381b098dbfe5d1432741d2b26c0a9f648157b Mon Sep 17 00:00:00 2001 From: Martin Rotter Date: Mon, 24 Jun 2024 07:09:37 +0200 Subject: [PATCH] build reddit plugin, fix encoding in oauth handler --- CMakeLists.txt | 1 + src/librssguard-reddit/CMakeLists.txt | 31 + src/librssguard-reddit/plugin.json | 5 + .../src/3rd-party/mimesis/mimesis.cpp | 1271 +++++++++++++++++ .../src/3rd-party/mimesis/mimesis.hpp | 181 +++ .../3rd-party/mimesis/quoted-printable.cpp | 63 + .../3rd-party/mimesis/quoted-printable.hpp | 25 + src/librssguard-reddit/src/definitions.h | 23 + .../src/gui/formeditredditaccount.cpp | 67 + .../src/gui/formeditredditaccount.h | 29 + .../src/gui/redditaccountdetails.cpp | 120 ++ .../src/gui/redditaccountdetails.h | 44 + .../src/gui/redditaccountdetails.ui | 202 +++ src/librssguard-reddit/src/redditcategory.cpp | 20 + src/librssguard-reddit/src/redditcategory.h | 27 + .../src/redditentrypoint.cpp | 50 + src/librssguard-reddit/src/redditentrypoint.h | 26 + .../src/redditnetworkfactory.cpp | 322 +++++ .../src/redditnetworkfactory.h | 61 + .../src/redditserviceroot.cpp | 142 ++ .../src/redditserviceroot.h | 54 + .../src/redditsubscription.cpp | 32 + .../src/redditsubscription.h | 28 + 23 files changed, 2824 insertions(+) create mode 100644 src/librssguard-reddit/CMakeLists.txt create mode 100644 src/librssguard-reddit/plugin.json create mode 100644 src/librssguard-reddit/src/3rd-party/mimesis/mimesis.cpp create mode 100644 src/librssguard-reddit/src/3rd-party/mimesis/mimesis.hpp create mode 100644 src/librssguard-reddit/src/3rd-party/mimesis/quoted-printable.cpp create mode 100644 src/librssguard-reddit/src/3rd-party/mimesis/quoted-printable.hpp create mode 100644 src/librssguard-reddit/src/definitions.h create mode 100644 src/librssguard-reddit/src/gui/formeditredditaccount.cpp create mode 100644 src/librssguard-reddit/src/gui/formeditredditaccount.h create mode 100644 src/librssguard-reddit/src/gui/redditaccountdetails.cpp create mode 100644 src/librssguard-reddit/src/gui/redditaccountdetails.h create mode 100644 src/librssguard-reddit/src/gui/redditaccountdetails.ui create mode 100644 src/librssguard-reddit/src/redditcategory.cpp create mode 100644 src/librssguard-reddit/src/redditcategory.h create mode 100644 src/librssguard-reddit/src/redditentrypoint.cpp create mode 100644 src/librssguard-reddit/src/redditentrypoint.h create mode 100644 src/librssguard-reddit/src/redditnetworkfactory.cpp create mode 100644 src/librssguard-reddit/src/redditnetworkfactory.h create mode 100644 src/librssguard-reddit/src/redditserviceroot.cpp create mode 100644 src/librssguard-reddit/src/redditserviceroot.h create mode 100644 src/librssguard-reddit/src/redditsubscription.cpp create mode 100644 src/librssguard-reddit/src/redditsubscription.h diff --git a/CMakeLists.txt b/CMakeLists.txt index c456fc16e..f75db7e9e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -326,6 +326,7 @@ add_subdirectory(src/librssguard-feedly) add_subdirectory(src/librssguard-gmail) add_subdirectory(src/librssguard-greader) add_subdirectory(src/librssguard-ttrss) +add_subdirectory(src/librssguard-reddit) add_subdirectory(src/librssguard-nextcloud) # GUI executable. diff --git a/src/librssguard-reddit/CMakeLists.txt b/src/librssguard-reddit/CMakeLists.txt new file mode 100644 index 000000000..1e872701d --- /dev/null +++ b/src/librssguard-reddit/CMakeLists.txt @@ -0,0 +1,31 @@ +include(../cmake_plugins.cmake) +set(PLUGIN_TARGET "rssguard-reddit") + +set(SOURCES + src/definitions.h + src/gui/formeditredditaccount.cpp + src/gui/formeditredditaccount.h + src/gui/redditaccountdetails.cpp + src/gui/redditaccountdetails.h + src/redditcategory.cpp + src/redditcategory.h + src/redditentrypoint.cpp + src/redditentrypoint.h + src/redditnetworkfactory.cpp + src/redditnetworkfactory.h + src/redditserviceroot.cpp + src/redditserviceroot.h + src/redditsubscription.cpp + src/redditsubscription.h + + src/3rd-party/mimesis/mimesis.cpp + src/3rd-party/mimesis/mimesis.hpp + src/3rd-party/mimesis/quoted-printable.cpp + src/3rd-party/mimesis/quoted-printable.hpp +) + +set(UI_FILES + src/gui/redditaccountdetails.ui +) + +prepare_rssguard_plugin(${PLUGIN_TARGET}) diff --git a/src/librssguard-reddit/plugin.json b/src/librssguard-reddit/plugin.json new file mode 100644 index 000000000..de726edd3 --- /dev/null +++ b/src/librssguard-reddit/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "Reddit", + "author": "Martin Rotter", + "website": "https://github.com/martinrotter/rssguard" +} \ No newline at end of file diff --git a/src/librssguard-reddit/src/3rd-party/mimesis/mimesis.cpp b/src/librssguard-reddit/src/3rd-party/mimesis/mimesis.cpp new file mode 100644 index 000000000..c0cf25710 --- /dev/null +++ b/src/librssguard-reddit/src/3rd-party/mimesis/mimesis.cpp @@ -0,0 +1,1271 @@ +// For license of this file, see /LICENSE.md. + +/* Mimesis -- a library for parsing and creating RFC2822 messages + Copyright © 2017 Guus Sliepen + + Mimesis is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License as published by the Free + Software Foundation, either version 3 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 Lesser General Public License + along with this program. If not, see . + */ + +#include "mimesis.hpp" + +#include "quoted-printable.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace std; + +namespace Mimesis { + static const string base64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + static const signed char base64_inverse[256] = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, + -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, + 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + }; + + string base64_encode(string_view in) { + string out; + size_t outlen = ((in.size() + 2) / 3) * 4; + + out.reserve(outlen); + + size_t i; + const uint8_t* uin = (const uint8_t*)in.data(); + + for (i = 0; i < (in.size() / 3) * 3; i += 3) { + out.push_back(base64[ (uin[i + 0] >> 2)]); + out.push_back(base64[(uin[i + 0] << 4 & 63) | (uin[i + 1] >> 4)]); + out.push_back(base64[(uin[i + 1] << 2 & 63) | (uin[i + 2] >> 6)]); + out.push_back(base64[(uin[i + 2] << 0 & 63) ]); + } + + while (i++ < in.size()) + out.push_back('='); + + return out; + } + + string base64_decode(string_view in) { + string out; + size_t estimate = (in.size() / 4) * 3; + + out.reserve(estimate); + + int i = 0; + uint32_t triplet = 0; + + for(uint8_t c: in) { + auto d = base64_inverse[c]; + + if (d == -1) { + if (c == '=') + break; + else + continue; + } + + triplet <<= 6; + triplet |= d; + + if((i & 3) == 3) { + out.push_back(static_cast(triplet >> 16)); + out.push_back(static_cast(triplet >> 8)); + out.push_back(static_cast(triplet)); + } + + i++; + } + + if((i & 3) == 3) { + out.push_back(static_cast(triplet >> 10)); + out.push_back(static_cast(triplet >> 2)); + } + else if((i & 3) == 2) { + out.push_back(static_cast(triplet >> 4)); + } + + return out; + } + + static std::random_device rnd; + + static string unquote(const string& str) { + if (str.empty() || str[0] != '"') + return str; + + string unquoted; + int quotes_wanted = 2; + + for (auto&& c: str) { + if (c == '"') { + if (--quotes_wanted) + continue; + + break; + } + + if (c == '\\') + continue; + + unquoted.push_back(c); + } + + return unquoted; + } + + static string quote(const string& str) { + bool do_quote = false; + + for (auto&& c: str) { + if (isalnum(c) || strchr("!#$%&'*+-/=?^_`{|}~", c)) + continue; + + do_quote = true; + break; + } + + if (!do_quote) + return str; + + string quoted = "\""; + + for (auto&& c: str) { + if (c == '\"' || c == '\\') + quoted.push_back('\\'); + + quoted.push_back(c); + } + + quoted.push_back('"'); + + return quoted; + } + + static bool streqi(const string& a, const string& b) { + if (a.size() != b.size()) + return false; + + for (size_t i = 0; i < a.size(); i++) + if (tolower(a[i]) != tolower(b[i])) + return false; + + return true; + } + + static bool streqi(const string& a, size_t offset_a, size_t len_a, const string& b) { + if (min(a.size() - offset_a, len_a) != b.size()) + return false; + + for (size_t i = 0; i < len_a; i++) + if (tolower(a[i + offset_a]) != tolower(b[i])) + return false; + + return true; + } + + static bool streqi(const string& a, size_t offset_a, size_t len_a, const string& b, size_t offset_b, size_t len_b) { + if (min(a.size() - offset_a, len_a) != min(b.size() - offset_b, len_b)) + return false; + + for (size_t i = 0; i < min(a.size() - offset_a, len_a); i++) + if (tolower(a[i + offset_a]) != tolower(b[i + offset_b])) + return false; + + return true; + } + + static string generate_boundary() { + unsigned int nonce[24 / sizeof(unsigned int)]; + + for (auto& val: nonce) + val = rnd(); + + return base64_encode(string_view(reinterpret_cast(nonce), sizeof nonce)); + } + + static bool is_boundary(const std::string& line, const std::string& boundary) { + if (boundary.empty()) + return false; + + if (line.compare(0, 2, "--")) + return false; + + if (line.compare(2, boundary.size(), boundary)) + return false; + + return true; + } + + static bool is_final_boundary(const std::string& line, const std::string& boundary) { + if (line.compare(2 + boundary.size(), 2, "--")) + return false; + + return is_boundary(line, boundary); + } + + static bool types_match(const std::string& a, const std::string& b) { + auto a_slash = a.find('/'); + auto b_slash = b.find('/'); + + if (a_slash == string::npos || b_slash == string::npos) + return streqi(a, 0, a_slash, b, 0, b_slash); + else + return streqi(a, b); + } + + static void set_value(string& str, const string& value) { + size_t semicolon = str.find(';'); + + if (semicolon == string::npos) + str = value; + else + str.replace(0, semicolon, value); + } + + static string get_value(const string& str) { + return str.substr(0, str.find(';')); + } + + static pair get_parameter_value_range(const string& str, const string& parameter) { + size_t start = 0; + size_t end = string::npos; + + // Find a semicolon, which marks the start of a parameter. + while((start = str.find(';', start)) != string::npos) { + start++; + while (isspace(str[start])) + start++; + + if (!streqi(str, start, parameter.size(), parameter)) { + // It's not the wanted parameter. + start = str.find('=', start); + while (isspace(str[start])) + start++; + + if (str[start] != '=') + continue; + + while (isspace(str[start])) + start++; + + // If it's a quoted parameter, skip over the quoted text. + if (str[start] == '"') { + start++; + while (start < str.size() && str[start] != '"') { + if (str[start] == '\\' && str.size() > start - 1) + start++; + + start++; + } + } + + continue; + } + + // Skip until we get to the value. + start += parameter.size(); + while (isspace(str[start])) + start++; + + if (str[start] != '=') + continue; + + start++; + while (isspace(str[start])) + start++; + + end = start; + if (str[end] == '"') { + // It's a quoted parameter. + end++; + while (end < str.size() && str[end] != '"') { + if (str[end] == '\\' && str.size() > end - 1) + end++; + + end++; + } + } + else { + while (end < str.size() && str[end] != ';' && !isspace(str[end])) + end++; + } + + break; + } + + return make_pair(start, end); + } + + static void set_parameter(string& str, const string& parameter, const string& value) { + auto range = get_parameter_value_range(str, parameter); + auto start = range.first; + auto end = range.second; + + if (start == string::npos) + str += "; " + parameter + "=" + quote(value); + else + str.replace(start, end - start, quote(value)); + } + + static string get_parameter(const string& str, const string& parameter) { + auto range = get_parameter_value_range(str, parameter); + auto start = range.first; + auto end = range.second; + + if (start == string::npos) + return {}; + + return unquote(str.substr(start, end - start)); + } + + static const string ending[2] = { "\n", "\r\n" }; + + Part::Part() : + headers(), + preamble(), + body(), + epilogue(), + parts(), + boundary(), + multipart(false), + crlf(true), + message(false) + {} + +// Loading and saving a whole MIME message + + string Part::load(istream& in, const string& parent_boundary) { + string line; + int ncrlf = 0; + int nlf = 0; + + while (getline(in, line)) { + if (is_boundary(line, parent_boundary)) + return line; + + if (line.size() && line.back() == '\r') { + ncrlf++; + line.erase(line.size() - 1); + } + else { + nlf++; + } + + if (line.empty()) + break; + + if (isspace(line[0])) { + if (headers.empty()) + throw runtime_error("invalid header line"); + + headers.back().second.append(line); + continue; + } + + size_t colon = string::npos; + + for (size_t i = 0; i < line.size(); ++i) { + if (line[i] == ':') { + colon = i; + break; + } + + if (line[i] < 33 || static_cast(line[i]) > 127) { + if (i == 4 && line[i] == ' ' && line.compare(0, 4, "From") == 0 && headers.empty()) { + colon = i; + break; + } + + throw runtime_error("invalid header line " + line + std::to_string(i)); + } + } + + if (colon == 0 || colon == string::npos) + throw runtime_error("invalid header line"); + + if (line[colon] != ':') + continue; + + auto start = colon + 1; + + while (start < line.size() && isspace(line[start])) + start++; + + // Empty header values are allowed for most fields. + + auto field = line.substr(0, colon); + auto value = line.substr(start); + + headers.emplace_back(field, value); + } + + crlf = ncrlf > nlf; + + const string content_type = get_header("Content-Type"); + + if (types_match(get_value(content_type), "multipart")) { + boundary = get_parameter(content_type, "boundary"); + if (boundary.empty()) + throw runtime_error("multipart but no boundary specified"); + + multipart = true; + } + else { + multipart = false; + } + + if (!multipart) { + while (getline(in, line)) { + if (is_boundary(line, parent_boundary)) + return line; + + line.push_back('\n'); + body.append(line); + } + } + else { + while (getline(in, line)) { + if (is_boundary(line, parent_boundary)) + return line; + + if (is_boundary(line, boundary)) + break; + + line.push_back('\n'); + preamble.append(line); + } + + while (true) { + parts.emplace_back(); + string last_line = parts.back().load(in, boundary); + + if (!is_boundary(last_line, boundary)) + throw runtime_error("invalid boundary"); + + if (is_final_boundary(last_line, boundary)) + break; + } + + while (getline(in, line)) { + if (is_boundary(line, parent_boundary)) + return line; + + line.push_back('\n'); + epilogue.append(line); + } + } + + if (in.bad()) + throw runtime_error("error reading message"); + + return {}; + } + + void Part::save(ostream& out) const { + bool has_headers = false; + + for (auto& header: headers) { + if (!header.second.empty()) { + out << header.first << ": " << header.second << ending[crlf]; + has_headers = true; + } + } + + if (message && !has_headers) + throw runtime_error("no headers specified"); + + out << ending[crlf]; + + if (parts.empty()) { + out << body; + } + else { + out << preamble; + for (auto& part: parts) { + out << "--" << boundary << ending[crlf]; + part.save(out); + } + + out << "--" << boundary << "--" << ending[crlf]; + out << epilogue; + } + } + + void Part::load(const string& filename) { + ifstream in(filename); + + if (!in.is_open()) + throw runtime_error("could not open message file"); + + load(in); + } + + void Part::save(const string& filename) const { + ofstream out(filename); + + if (!out.is_open()) + throw runtime_error("could not open message file"); + + save(out); + out.close(); + if (out.fail()) + throw runtime_error("could not write message file"); + } + + void Part::from_string(const string& data) { + istringstream in(data); + + load(in); + } + + string Part::to_string() const { + ostringstream out; + + save(out); + return out.str(); + } + + void Part::set_crlf(bool value) { + crlf = value; + } + +// Low-level access + + string charset_decode(const string& charset, string_view in) { + QTextCodec* custom_codec = QTextCodec::codecForName(charset.c_str()); + auto unic = custom_codec->toUnicode(string(in).c_str()); + + std::string utf8_text = unic.toUtf8().constData(); + + return utf8_text; + } + + string Part::get_body() const { + string result; + auto encoding = get_header_value("Content-Transfer-Encoding"); + + if (streqi(encoding, "quoted-printable")) + result = quoted_printable_decode(body); + + if (streqi(encoding, "base64")) + result = base64_decode(body); + else + result = body; + + if (is_mime_type("text")) { + auto charset = get_header_parameter("Content-Type", "charset"); + + if (!charset.empty() && !streqi(charset, "utf-8") && !streqi(charset, "us-ascii") && !streqi(charset, "ascii")) { + result = charset_decode(charset, result); + } + } + + return result; + } + + string Part::get_preamble() const { + return preamble; + } + + string Part::get_epilogue() const { + return epilogue; + } + + string Part::get_boundary() const { + return boundary; + } + + vector& Part::get_parts() { + return parts; + } + + const vector& Part::get_parts() const { + return parts; + } + + vector>& Part::get_headers() { + return headers; + } + + const vector>& Part::get_headers() const { + return headers; + } + + bool Part::is_multipart() const { + return multipart; + } + + bool Part::is_multipart(const std::string& subtype) const { + return multipart && get_header_value("Content-Type") == "multipart/" + subtype; + } + + bool Part::is_singlepart() const { + return !multipart; + } + + bool Part::is_singlepart(const std::string& type) const { + return !multipart && types_match(get_header_value("Content-Type"), type); + } + + bool Part::is_attachment() const { + return get_header_value("Content-Disposition") == "attachment"; + } + + bool Part::is_inline() const { + return get_header_value("Content-Disposition") == "inline"; + } + + void Part::set_body(const string& value) { + if (multipart) + throw runtime_error("Cannot set body of a multipart message"); + + body = value; + } + + void Part::set_preamble(const string& value) { + if (!multipart) + throw runtime_error("Cannot set preamble of a non-multipart message"); + + preamble = value; + } + + void Part::set_epilogue(const string& value) { + if (!multipart) + throw runtime_error("Cannot set epilogue of a non-multipart message"); + + epilogue = value; + } + + void Part::set_boundary(const std::string& value) { + boundary = value; + if (has_mime_type()) + set_header_parameter("Content-Type", "boundary", boundary); + } + + void Part::set_parts(const vector& value) { + if (!multipart) + throw runtime_error("Cannot set parts of a non-multipart message"); + + parts = value; + } + + void Part::set_headers(const vector>& value) { + headers = value; + } + + void Part::clear() { + headers.clear(); + preamble.clear(); + body.clear(); + epilogue.clear(); + parts.clear(); + boundary.clear(); + multipart = false; + } + + void Part::clear_body() { + body.clear(); + } + +// Header manipulation + + static bool iequals(const string& a, const string& b) { + if (a.size() != b.size()) + return false; + + for (size_t i = 0; i < a.size(); ++i) + if (tolower(a[i]) != tolower(b[i])) + return false; + + return true; + } + + string Part::get_header(const string& field) const { + for (const auto& header: headers) + if (iequals(header.first, field)) + return header.second; + + return {}; + } + + void Part::set_header(const string& field, const string& value) { + for (auto& header: headers) { + if (iequals(header.first, field)) { + header.second = value; + return; + } + } + + append_header(field, value); + } + + string& Part::operator[](const string& field) { + for (auto& header: headers) + if (iequals(header.first, field)) + return header.second; + + append_header(field, {}); + return headers.back().second; + } + + const string& Part::operator[](const string& field) const { + for (auto& header: headers) + if (iequals(header.first, field)) + return header.second; + + static string empty_string; + + return empty_string; + } + + void Part::append_header(const string& field, const string& value) { + headers.push_back(make_pair(field, value)); + } + + void Part::prepend_header(const string& field, const string& value) { + headers.insert(begin(headers), make_pair(field, value)); + } + + void Part::erase_header(const string& field) { + headers.erase(remove_if(begin(headers), end(headers), [&](pair& header) { + return header.first == field; + }), end(headers)); + } + + void Part::clear_headers() { + headers.clear(); + } + + string Part::get_header_value(const string& field) const { + return get_value(get_header(field)); + } + + string Part::get_header_parameter(const string& field, const string& parameter) const { + return get_parameter(get_header(field), parameter); + } + + void Part::set_header_value(const string& field, const string& value) { + for (auto& header: headers) { + if (iequals(header.first, field)) { + set_value(header.second, value); + return; + } + } + + append_header(field, value); + } + + void Part::set_header_parameter(const string& field, const string& parameter, const string& value) { + for (auto& header: headers) { + if (iequals(header.first, field)) { + set_parameter(header.second, parameter, value); + return; + } + } + + append_header(field, "; " + parameter + "=" + value); + } + + static string get_date_string(const chrono::system_clock::time_point& date = chrono::system_clock::now()) { + + QLocale loc("C"); + QDateTime dat; + + dat.setSecsSinceEpoch(date.time_since_epoch().count()); + + return loc.toString(dat, "ddd, MM MMM yyyy HH:mm:ss t").toStdString(); + + /*time_t t = chrono::system_clock::to_time_t(date); + struct tm tm{}; + localtime_r(&t, &tm); + char str[128]; + char *oldlocale = setlocale(LC_TIME, "C"); + size_t result = strftime(str, sizeof str, "%a, %d %b %Y %T %z", &tm); + setlocale(LC_TIME, oldlocale); + if (result == 0) + throw runtime_error("Could not convert date to string"); + return str; + */ + } + + void Part::add_received(const string& text, const chrono::system_clock::time_point& date) { + prepend_header("Received", text + "; " + get_date_string(date)); + } + + void Part::generate_msgid(const string& domain) { + auto now = chrono::system_clock::now(); + uint64_t buf[3]; + + buf[0] = ((uint64_t)rnd() << 32) | rnd(); + buf[1] = chrono::duration_cast(now.time_since_epoch()).count(); + buf[2] = ((uint64_t)rnd() << 32) | rnd(); + string msgid = "<" + base64_encode(string_view(reinterpret_cast(buf), sizeof buf)) + "@" + domain + ">"; + + set_header("Message-ID", msgid); + } + + void Part::set_date(const chrono::system_clock::time_point& date) { + set_header("Date", get_date_string(date)); + } + +// Part manipulation + + Part& Part::append_part(const Part& part) { + parts.push_back(part); + return parts.back(); + } + + Part& Part::prepend_part(const Part& part) { + parts.insert(begin(parts), part); + return parts.front(); + } + + void Part::clear_parts() { + parts.clear(); + } + + void Part::make_multipart(const string& subtype, const string& suggested_boundary) { + if (multipart) { + if (is_multipart(subtype)) + return; + + Part part; + + part.preamble = move(preamble); + part.epilogue = move(epilogue); + part.parts = move(parts); + part.boundary = move(boundary); + part.multipart = true; + part.set_header("Content-Type", get_header("Content-Type")); + part.set_header("Content-Disposition", get_header("Content-Disposition")); + erase_header("Content-Disposition"); + part.crlf = crlf; + parts.emplace_back(move(part)); + } + else { + multipart = true; + + if (message) + set_header("MIME-Version", "1.0"); + + if (!body.empty()) { + auto& part = append_part(); + + part.set_header("Content-Type", get_header("Content-Type")); + part.set_header("Content-Disposition", get_header("Content-Disposition")); + erase_header("Content-Disposition"); + part.body = move(body); + } + } + + if (!suggested_boundary.empty()) + set_boundary(suggested_boundary); + + if (boundary.empty()) + boundary = generate_boundary(); + + set_header("Content-Type", "multipart/" + subtype + "; boundary=" + boundary); + } + + bool Part::flatten() { + if (!multipart) + return true; + + if (parts.empty()) { + multipart = false; + return true; + } + + if (parts.size() > 1) + return false; + + auto& part = parts.front(); + + set_header("Content-Type", part.get_header("Content-Type")); + set_header("Content-Disposition", part.get_header("Content-Disposition")); + + if (part.multipart) { + parts = move(part.parts); + } + else { + multipart = false; + set_body(part.get_body()); + parts.clear(); + } + + return true; + } + +// Body and attachments + + string Part::get_mime_type() const { + return get_header_value("Content-Type"); + } + + void Part::set_mime_type(const std::string& type) { + return set_header_value("Content-Type", type); + } + + bool Part::is_mime_type(const std::string& type) const { + return types_match(get_mime_type(), type); + } + + bool Part::has_mime_type() const { + return !get_mime_type().empty(); + } + + const Part* Part::get_first_matching_part(function predicate) const { + if (!multipart) { + if (headers.empty() && body.empty()) + return nullptr; + + if (is_attachment()) + return nullptr; + } + + if (predicate(*this)) + return this; + + for (auto& part: parts) { + auto result = part.get_first_matching_part(predicate); + + if (result) + return result; + } + + return nullptr; + } + + Part* Part::get_first_matching_part(function predicate) { + auto result = ((const Part*)this)->get_first_matching_part(predicate); + + return const_cast(result); + } + + const Part* Part::get_first_matching_part(const string& type) const { + return get_first_matching_part([type](const Part& part) { + auto my_type = part.get_mime_type(); + return types_match(my_type.empty() ? "text/plain" : my_type, type); + }); + } + + Part* Part::get_first_matching_part(const string& type) { + auto result = ((const Part*)this)->get_first_matching_part(type); + + return const_cast(result); + } + + string Part::get_first_matching_body(const string& type) const { + const auto& part = get_first_matching_part(type); + + if (part) + return part->get_body(); + else + return {}; + } + + Part& Part::set_alternative(const string& subtype, const string& text) { + string type = "text/" + subtype; + Part* part = nullptr; + + // Try to put it in the body first. + if (!multipart) { + if (body.empty() || is_mime_type(type)) { + part = this; + } + else if (is_mime_type("text") && !is_attachment()) { + make_multipart("alternative"); + part = &append_part(); + } + else { + make_multipart("mixed"); + part = &prepend_part(); + } + } + else { + // If there is already a text/plain part, use that one. + part = get_first_matching_part(type); + if (part) { + part->set_mime_type(type); + part->set_body(text); + return *part; + } + + // If there is already a multipart/alternative with text, use that one. + part = get_first_matching_part([](const Part& part) { + return part.is_multipart("alternative") + && !part.parts.empty() + && part.get_first_matching_part("text"); + }); + if (part) + part = &part->append_part(); + + // If there is already inline text, make it multipart/alternative. + + if (!part && (part = get_first_matching_part("text"))) { + part->make_multipart("alternative"); + part = &part->append_part(); + } + + // Otherwise, assume we're multipart/mixed. + if (!part) + part = &prepend_part(); + } + + part->set_header("Content-Type", type); + part->set_body(text); + + return *part; + } + + void Part::set_plain(const string& text) { + set_alternative("plain", text); + } + + void Part::set_html(const string& html) { + set_alternative("html", html); + } + + string Part::get_plain() const { + return get_first_matching_body("text/plain"); + } + + string Part::get_html() const { + return get_first_matching_body("text/html"); + } + + string Part::get_text() const { + return get_first_matching_body("text"); + } + + Part& Part::attach(const Part& attachment) { + if (!multipart && body.empty()) { + if (attachment.message) { + set_header("Content-Type", "message/rfc822"); + body = attachment.to_string(); + } + else { + set_header("Content-Type", attachment.get_header("Content-Type")); + body = attachment.body; + } + + set_header("Content-Disposition", "attachment"); + return *this; + } + + make_multipart("mixed"); + auto& part = append_part(); + + if (attachment.message) { + part.set_header("Content-Type", "message/rfc822"); + part.body = attachment.to_string(); + } + else { + part.set_header("Content-Type", attachment.get_header("Content-Type")); + part.body = attachment.body; + } + + part.set_header("Content-Disposition", "attachment"); + return part; + } + + Part& Part::attach(const string& data, const string& type, const string& filename) { + if (!multipart && body.empty()) { + set_header("Content-Type", type.empty() ? "text/plain" : type); + set_header("Content-Disposition", "attachment"); + if (!filename.empty()) + set_header_parameter("Content-Disposition", "filename", filename); + + body = data; + return *this; + } + + make_multipart("mixed"); + auto& part = append_part(); + + part.set_header("Content-Type", type.empty() ? "text/plain" : type); + part.set_header("Content-Disposition", "attachment"); + if (!filename.empty()) + part.set_header_parameter("Content-Disposition", "filename", filename); + + part.set_body(data); + return part; + } + + Part& Part::attach(istream& in, const string& type, const string& filename) { + auto& part = attach("", type, filename); + char buffer[4096]; + + while (in.read(buffer, sizeof(buffer))) + part.body.append(buffer, sizeof(buffer)); + + part.body.append(buffer, in.gcount()); + return part; + } + + vector Part::get_attachments() const { + vector attachments; + + if (!multipart && get_header_value("Content-Disposition") == "attachment") { + attachments.push_back(this); + return attachments; + } + + for (auto& part: parts) { + auto sub = part.get_attachments(); + + attachments.insert(end(attachments), begin(sub), end(sub)); + } + + return attachments; + } + + void Part::simplify() { + if (!multipart) + return; + + for (auto& part: parts) + part.simplify(); + + parts.erase(remove_if(begin(parts), end(parts), [&](Part& part) { + return part.headers.empty() && part.body.empty(); + }), end(parts)); + + if (parts.empty()) { + if (message) { + erase_header("Content-Type"); + erase_header("Content-Disposition"); + multipart = false; + } + else { + clear(); + } + } + else if (parts.size() == 1) { + flatten(); + } + } + + void Part::clear_attachments() { + if (!multipart) { + if (get_header_value("Content-Disposition") == "attachment") { + if (message) { + erase_header("Content-Type"); + erase_header("Content-Disposition"); + body.clear(); + } + else { + clear(); + } + } + } + else { + for (auto& part: parts) + part.clear_attachments(); + + simplify(); + } + } + + void Part::clear_alternative(const string& type) { + bool cleared = false; + Part* part; + + while((part = get_first_matching_part(type))) { + part->clear(); + cleared = true; + } + + if (cleared) + simplify(); + } + + void Part::clear_text() { + clear_alternative("text"); + } + + void Part::clear_plain() { + clear_alternative("text/plain"); + } + + void Part::clear_html() { + clear_alternative("text/html"); + } + + bool Part::has_text() const { + return get_first_matching_part("text"); + } + + bool Part::has_plain() const { + return get_first_matching_part("text/plain"); + } + + bool Part::has_html() const { + return get_first_matching_part("text/html"); + } + + bool Part::has_attachments() const { + if (is_attachment()) + return true; + + for (auto& part: parts) + if (part.has_attachments()) + return true; + + return false; + } + +// RFC2822 messages + + Message::Message() { + message = true; + } + +// Comparison + + bool operator==(const Part& lhs, const Part& rhs) { + return lhs.crlf == rhs.crlf && lhs.multipart == rhs.multipart && + lhs.preamble == rhs.preamble && lhs.body == rhs.body && + lhs.epilogue == rhs.epilogue && lhs.boundary == rhs.boundary && + lhs.headers == rhs.headers && lhs.parts == rhs.parts; + } + + bool operator!=(const Part& lhs, const Part& rhs) { + return !(lhs == rhs); + } + +} diff --git a/src/librssguard-reddit/src/3rd-party/mimesis/mimesis.hpp b/src/librssguard-reddit/src/3rd-party/mimesis/mimesis.hpp new file mode 100644 index 000000000..686b23090 --- /dev/null +++ b/src/librssguard-reddit/src/3rd-party/mimesis/mimesis.hpp @@ -0,0 +1,181 @@ +// For license of this file, see /LICENSE.md. + +#pragma once + +/* Mimesis -- a library for parsing and creating RFC2822 messages + Copyright © 2017 Guus Sliepen + + Mimesis is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License as published by the Free + Software Foundation, either version 3 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 Lesser General Public License + along with this program. If not, see . + */ + +#include +#include +#include +#include +#include +#include + +namespace Mimesis { + + std::string base64_encode(std::string_view in); + std::string base64_decode(std::string_view in); + + class Part { + std::vector> headers; + std::string preamble; + std::string body; + std::string epilogue; + std::vector parts; + std::string boundary; + bool multipart; + bool crlf; + + protected: + bool message; + + public: + Part(); + friend bool operator==(const Part& lhs, const Part& rhs); + friend bool operator!=(const Part& lhs, const Part& rhs); + + // Loading and saving a whole MIME message + std::string load(std::istream& in, const std::string& parent_boundary = {}); + void load(const std::string& filename); + void save(std::ostream& out) const; + void save(const std::string& filename) const; + void from_string(const std::string& data); + std::string to_string() const; + + // Low-level access + std::string get_body() const; + std::string get_preamble() const; + std::string get_epilogue() const; + std::string get_boundary() const; + std::vector& get_parts(); + const std::vector& get_parts() const; + std::vector>& get_headers(); + const std::vector>& get_headers() const; + + bool is_multipart() const; + bool is_multipart(const std::string& subtype) const; + bool is_singlepart() const; + bool is_singlepart(const std::string& type) const; + + void set_body(const std::string& body); + void set_preamble(const std::string& preamble); + void set_epilogue(const std::string& epilogue); + void set_boundary(const std::string& boundary); + void set_parts(const std::vector& parts); + void set_headers(const std::vector>& headers); + + void clear(); + void clear_body(); + + // Header manipulation + std::string get_header(const std::string& field) const; + void set_header(const std::string& field, const std::string& value); + std::string& operator[](const std::string& field); + const std::string& operator[](const std::string& field) const; + + void append_header(const std::string& field, const std::string& value); + void prepend_header(const std::string& field, const std::string& value); + void erase_header(const std::string& field); + void clear_headers(); + + // Specialized header functions + std::string get_multipart_type() const; + std::string get_header_value(const std::string& field) const; + std::string get_header_parameter(const std::string& field, const std::string& parameter) const; + + void set_header_value(const std::string& field, const std::string& value); + void set_header_parameter(const std::string& field, const std::string& paramter, const std::string& value); + + void add_received(const std::string& domain, const std::chrono::system_clock::time_point& date = std::chrono::system_clock::now()); + void generate_msgid(const std::string& domain); + void set_date(const std::chrono::system_clock::time_point& date = std::chrono::system_clock::now()); + + // Part manipulation + Part& append_part(const Part& part = {}); + Part& prepend_part(const Part& part = {}); + + void clear_parts(); + void make_multipart(const std::string& type, const std::string& boundary = {}); + bool flatten(); + + std::string get_mime_type() const; + void set_mime_type(const std::string& type); + bool is_mime_type(const std::string& type) const; + bool has_mime_type() const; + + // Body and attachments + Part& set_alternative(const std::string& subtype, const std::string& text); + + void set_plain(const std::string& text); + void set_html(const std::string& text); + + const Part* get_first_matching_part(std::function predicate) const; + + Part* get_first_matching_part(std::function predicate); + const Part* get_first_matching_part(const std::string& type) const; + + Part* get_first_matching_part(const std::string& type); + std::string get_first_matching_body(const std::string& type) const; + std::string get_text() const; + std::string get_plain() const; + std::string get_html() const; + + Part& attach(const Part& attachment); + Part& attach(const std::string& data, const std::string& mime_type, const std::string& filename = {}); + Part& attach(std::istream& in, const std::string& mime_type, const std::string& filename = {}); + + std::vector get_attachments() const; + + void clear_alternative(const std::string& subtype); + void clear_text(); + void clear_plain(); + void clear_html(); + void clear_attachments(); + + void simplify(); + + bool has_text() const; + bool has_plain() const; + bool has_html() const; + bool has_attachments() const; + bool is_attachment() const; + bool is_inline() const; + + // Format manipulation + void set_crlf(bool value = true); + }; + + class Message : public Part { + public: + Message(); + }; + + bool operator==(const Part& lhs, const Part& rhs); + bool operator!=(const Part& lhs, const Part& rhs); + +} + +inline std::ostream& operator<<(std::ostream& out, const Mimesis::Part& part) { + part.save(out); + return out; +} + +inline std::istream& operator>>(std::istream& in, Mimesis::Part& part) { + part.load(in); + return in; +} diff --git a/src/librssguard-reddit/src/3rd-party/mimesis/quoted-printable.cpp b/src/librssguard-reddit/src/3rd-party/mimesis/quoted-printable.cpp new file mode 100644 index 000000000..045472d66 --- /dev/null +++ b/src/librssguard-reddit/src/3rd-party/mimesis/quoted-printable.cpp @@ -0,0 +1,63 @@ +// For license of this file, see /LICENSE.md. + +/* Mimesis -- a library for parsing and creating RFC2822 messages + Copyright © 2017 Guus Sliepen + + Mimesis is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License as published by the Free + Software Foundation, either version 3 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 Lesser General Public License + along with this program. If not, see . + */ + +#include "quoted-printable.hpp" + +#include + +using namespace std; + +string quoted_printable_decode(string_view in) { + string out; + + out.reserve(in.size()); + + int decode = 0; + uint8_t val = 0; + + for (auto&& c: in) { + if (decode) { + if (c >= '0' && c <= '9') { + val <<= 4; + val |= c - '0'; + decode--; + } + else if (c >= 'A' && c <= 'F') { + val <<= 4; + val |= 10 + (c - 'A'); + decode--; + } + else { + decode = 0; + continue; + } + + if (decode == 0) + out.push_back(static_cast(val)); + } + else { + if (c == '=') + decode = 2; + else + out.push_back(c); + } + } + + return out; +} diff --git a/src/librssguard-reddit/src/3rd-party/mimesis/quoted-printable.hpp b/src/librssguard-reddit/src/3rd-party/mimesis/quoted-printable.hpp new file mode 100644 index 000000000..54c054af3 --- /dev/null +++ b/src/librssguard-reddit/src/3rd-party/mimesis/quoted-printable.hpp @@ -0,0 +1,25 @@ +// For license of this file, see /LICENSE.md. + +#pragma once + +/* Mimesis -- a library for parsing and creating RFC2822 messages + Copyright © 2017 Guus Sliepen + + Mimesis is free software; you can redistribute it and/or modify it under the + terms of the GNU Lesser General Public License as published by the Free + Software Foundation, either version 3 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 Lesser General Public License + along with this program. If not, see . + */ + +#include +#include + +std::string quoted_printable_decode(std::string_view in); diff --git a/src/librssguard-reddit/src/definitions.h b/src/librssguard-reddit/src/definitions.h new file mode 100644 index 000000000..b34493edc --- /dev/null +++ b/src/librssguard-reddit/src/definitions.h @@ -0,0 +1,23 @@ +// For license of this file, see /LICENSE.md. + +#ifndef REDDIT_DEFINITIONS_H +#define REDDIT_DEFINITIONS_H + +#define REDDIT_OAUTH_REDIRECT_URI_PORT 14499 +#define REDDIT_OAUTH_AUTH_URL "https://www.reddit.com/api/v1/authorize" +#define REDDIT_OAUTH_TOKEN_URL "https://www.reddit.com/api/v1/access_token" +#define REDDIT_OAUTH_SCOPE "identity mysubreddits read" + +#define REDDIT_REG_API_URL "https://www.reddit.com/prefs/apps" + +#define REDDIT_API_GET_PROFILE "https://oauth.reddit.com/api/v1/me" +#define REDDIT_API_SUBREDDITS "https://oauth.reddit.com/subreddits/mine/subscriber?limit=%1" +#define REDDIT_API_HOT "https://oauth.reddit.com%1hot?limit=%2&count=%3&g=%4" + +#define REDDIT_DEFAULT_BATCH_SIZE 100 +#define REDDIT_MAX_BATCH_SIZE 999 + +#define REDDIT_CONTENT_TYPE_HTTP "application/http" +#define REDDIT_CONTENT_TYPE_JSON "application/json" + +#endif // REDDIT_DEFINITIONS_H diff --git a/src/librssguard-reddit/src/gui/formeditredditaccount.cpp b/src/librssguard-reddit/src/gui/formeditredditaccount.cpp new file mode 100644 index 000000000..5ca00f678 --- /dev/null +++ b/src/librssguard-reddit/src/gui/formeditredditaccount.cpp @@ -0,0 +1,67 @@ +// For license of this file, see /LICENSE.md. + +#include "src/gui/formeditredditaccount.h" + +#include "miscellaneous/application.h" +#include "miscellaneous/iconfactory.h" +#include "network-web/oauth2service.h" +#include "src/gui/redditaccountdetails.h" +#include "src/redditserviceroot.h" + +FormEditRedditAccount::FormEditRedditAccount(QWidget* parent) + : FormAccountDetails(qApp->icons()->miscIcon(QSL("reddit")), parent), m_details(new RedditAccountDetails(this)) { + insertCustomTab(m_details, tr("Server setup"), 0); + activateTab(0); + + m_details->m_ui.m_txtUsername->setFocus(); + connect(m_details->m_ui.m_btnTestSetup, &QPushButton::clicked, this, [this]() { + m_details->testSetup(m_proxyDetails->proxy()); + }); +} + +void FormEditRedditAccount::apply() { + FormAccountDetails::apply(); + + bool using_another_acc = + m_details->m_ui.m_txtUsername->lineEdit()->text() != account()->network()->username(); + + // Make sure that the data copied from GUI are used for brand new login. + account()->network()->oauth()->logout(false); + account()->network()->oauth()->setClientId(m_details->m_ui.m_txtAppId->lineEdit()->text()); + account()->network()->oauth()->setClientSecret(m_details->m_ui.m_txtAppKey->lineEdit()->text()); + account()->network()->oauth()->setRedirectUrl(m_details->m_ui.m_txtRedirectUrl->lineEdit()->text(), + true); + + account()->network()->setUsername(m_details->m_ui.m_txtUsername->lineEdit()->text()); + account()->network()->setBatchSize(m_details->m_ui.m_spinLimitMessages->value()); + account()->network()->setDownloadOnlyUnreadMessages(m_details->m_ui.m_cbDownloadOnlyUnreadMessages + ->isChecked()); + + account()->saveAccountDataToDatabase(); + accept(); + + if (!m_creatingNew) { + if (using_another_acc) { + account()->completelyRemoveAllData(); + } + + account()->start(true); + } +} + +void FormEditRedditAccount::loadAccountData() { + FormAccountDetails::loadAccountData(); + + m_details->m_oauth = account()->network()->oauth(); + m_details->hookNetwork(); + + // Setup the GUI. + m_details->m_ui.m_txtAppId->lineEdit()->setText(m_details->m_oauth->clientId()); + m_details->m_ui.m_txtAppKey->lineEdit()->setText(m_details->m_oauth->clientSecret()); + m_details->m_ui.m_txtRedirectUrl->lineEdit()->setText(m_details->m_oauth->redirectUrl()); + + m_details->m_ui.m_txtUsername->lineEdit()->setText(account()->network()->username()); + m_details->m_ui.m_spinLimitMessages->setValue(account()->network()->batchSize()); + m_details->m_ui.m_cbDownloadOnlyUnreadMessages + ->setChecked(account()->network()->downloadOnlyUnreadMessages()); +} diff --git a/src/librssguard-reddit/src/gui/formeditredditaccount.h b/src/librssguard-reddit/src/gui/formeditredditaccount.h new file mode 100644 index 000000000..efc5ed95e --- /dev/null +++ b/src/librssguard-reddit/src/gui/formeditredditaccount.h @@ -0,0 +1,29 @@ +// For license of this file, see /LICENSE.md. + +#ifndef FORMEDITREDDITACCOUNT_H +#define FORMEDITREDDITACCOUNT_H + +#include "services/abstract/gui/formaccountdetails.h" + +#include "src/redditnetworkfactory.h" + +class RedditServiceRoot; +class RedditAccountDetails; + +class FormEditRedditAccount : public FormAccountDetails { + Q_OBJECT + + public: + explicit FormEditRedditAccount(QWidget* parent = nullptr); + + protected slots: + virtual void apply(); + + protected: + virtual void loadAccountData(); + + private: + RedditAccountDetails* m_details; +}; + +#endif // FORMEDITREDDITACCOUNT_H diff --git a/src/librssguard-reddit/src/gui/redditaccountdetails.cpp b/src/librssguard-reddit/src/gui/redditaccountdetails.cpp new file mode 100644 index 000000000..e5df1a4f5 --- /dev/null +++ b/src/librssguard-reddit/src/gui/redditaccountdetails.cpp @@ -0,0 +1,120 @@ +// For license of this file, see /LICENSE.md. + +#include "src/gui/redditaccountdetails.h" + +#include "exceptions/applicationexception.h" +#include "miscellaneous/application.h" +#include "network-web/oauth2service.h" +#include "network-web/webfactory.h" +#include "src/definitions.h" +#include "src/redditnetworkfactory.h" + +RedditAccountDetails::RedditAccountDetails(QWidget* parent) : QWidget(parent), m_oauth(nullptr), m_lastProxy({}) { + m_ui.setupUi(this); + + m_ui.m_lblInfo->setHelpText(tr("You have to fill in your client ID/secret and also fill in correct redirect URL."), + true); + m_ui.m_lblTestResult->setStatus(WidgetWithStatus::StatusType::Information, + tr("Not tested yet."), + tr("Not tested yet.")); + m_ui.m_lblTestResult->label()->setWordWrap(true); + m_ui.m_txtUsername->lineEdit()->setPlaceholderText(tr("User-visible username")); + + setTabOrder(m_ui.m_txtUsername->lineEdit(), m_ui.m_txtAppId); + setTabOrder(m_ui.m_txtAppId, m_ui.m_txtAppKey); + setTabOrder(m_ui.m_txtAppKey, m_ui.m_txtRedirectUrl); + setTabOrder(m_ui.m_txtRedirectUrl, m_ui.m_spinLimitMessages); + setTabOrder(m_ui.m_spinLimitMessages, m_ui.m_btnTestSetup); + + connect(m_ui.m_txtAppId->lineEdit(), &BaseLineEdit::textChanged, this, &RedditAccountDetails::checkOAuthValue); + connect(m_ui.m_txtAppKey->lineEdit(), &BaseLineEdit::textChanged, this, &RedditAccountDetails::checkOAuthValue); + connect(m_ui.m_txtRedirectUrl->lineEdit(), &BaseLineEdit::textChanged, this, &RedditAccountDetails::checkOAuthValue); + connect(m_ui.m_txtUsername->lineEdit(), &BaseLineEdit::textChanged, this, &RedditAccountDetails::checkUsername); + connect(m_ui.m_btnRegisterApi, &QPushButton::clicked, this, &RedditAccountDetails::registerApi); + + emit m_ui.m_txtUsername->lineEdit()->textChanged(m_ui.m_txtUsername->lineEdit()->text()); + emit m_ui.m_txtAppId->lineEdit()->textChanged(m_ui.m_txtAppId->lineEdit()->text()); + emit m_ui.m_txtAppKey->lineEdit()->textChanged(m_ui.m_txtAppKey->lineEdit()->text()); + emit m_ui.m_txtRedirectUrl->lineEdit()->textChanged(m_ui.m_txtAppKey->lineEdit()->text()); + + hookNetwork(); +} + +void RedditAccountDetails::testSetup(const QNetworkProxy& custom_proxy) { + m_oauth->logout(true); + m_oauth->setClientId(m_ui.m_txtAppId->lineEdit()->text()); + m_oauth->setClientSecret(m_ui.m_txtAppKey->lineEdit()->text()); + m_oauth->setRedirectUrl(m_ui.m_txtRedirectUrl->lineEdit()->text(), true); + + m_lastProxy = custom_proxy; + m_oauth->login(); +} + +void RedditAccountDetails::checkUsername(const QString& username) { + if (username.isEmpty()) { + m_ui.m_txtUsername->setStatus(WidgetWithStatus::StatusType::Error, tr("No username entered.")); + } + else { + m_ui.m_txtUsername->setStatus(WidgetWithStatus::StatusType::Ok, tr("Some username entered.")); + } +} + +void RedditAccountDetails::onAuthFailed() { + m_ui.m_lblTestResult->setStatus(WidgetWithStatus::StatusType::Error, + tr("You did not grant access."), + tr("There was error during testing.")); +} + +void RedditAccountDetails::onAuthError(const QString& error, const QString& detailed_description) { + Q_UNUSED(error) + + m_ui.m_lblTestResult->setStatus(WidgetWithStatus::StatusType::Error, + tr("There is error: %1").arg(detailed_description), + tr("There was error during testing.")); +} + +void RedditAccountDetails::onAuthGranted() { + m_ui.m_lblTestResult->setStatus(WidgetWithStatus::StatusType::Ok, + tr("Tested successfully. You may be prompted to login once more."), + tr("Your access was approved.")); + + try { + RedditNetworkFactory fac; + + fac.setOauth(m_oauth); + + auto resp = fac.me(m_lastProxy); + + m_ui.m_txtUsername->lineEdit()->setText(resp[QSL("name")].toString()); + } + catch (const ApplicationException& ex) { + qCriticalNN << LOGSEC_REDDIT << "Failed to obtain profile with error:" << QUOTE_W_SPACE_DOT(ex.message()); + } +} + +void RedditAccountDetails::hookNetwork() { + connect(m_oauth, &OAuth2Service::tokensRetrieved, this, &RedditAccountDetails::onAuthGranted); + connect(m_oauth, &OAuth2Service::tokensRetrieveError, this, &RedditAccountDetails::onAuthError); + connect(m_oauth, &OAuth2Service::authFailed, this, &RedditAccountDetails::onAuthFailed); +} + +void RedditAccountDetails::registerApi() { + qApp->web()->openUrlInExternalBrowser(QSL(REDDIT_REG_API_URL)); +} + +void RedditAccountDetails::checkOAuthValue(const QString& value) { + auto* line_edit = qobject_cast(sender()->parent()); + + if (line_edit != nullptr) { + if (value.isEmpty()) { +#if defined(REDDIT_OFFICIAL_SUPPORT) + line_edit->setStatus(WidgetWithStatus::StatusType::Ok, tr("Preconfigured client ID/secret will be used.")); +#else + line_edit->setStatus(WidgetWithStatus::StatusType::Error, tr("Empty value is entered.")); +#endif + } + else { + line_edit->setStatus(WidgetWithStatus::StatusType::Ok, tr("Some value is entered.")); + } + } +} diff --git a/src/librssguard-reddit/src/gui/redditaccountdetails.h b/src/librssguard-reddit/src/gui/redditaccountdetails.h new file mode 100644 index 000000000..b383a7eb7 --- /dev/null +++ b/src/librssguard-reddit/src/gui/redditaccountdetails.h @@ -0,0 +1,44 @@ +// For license of this file, see /LICENSE.md. + +#ifndef REDDITACCOUNTDETAILS_H +#define REDDITACCOUNTDETAILS_H + +#include + +#include "ui_redditaccountdetails.h" + +#include + +class OAuth2Service; + +class RedditAccountDetails : public QWidget { + Q_OBJECT + + friend class FormEditRedditAccount; + + public: + explicit RedditAccountDetails(QWidget* parent = nullptr); + + public slots: + void testSetup(const QNetworkProxy& custom_proxy); + + private slots: + void registerApi(); + void checkOAuthValue(const QString& value); + void checkUsername(const QString& username); + void onAuthFailed(); + void onAuthError(const QString& error, const QString& detailed_description); + void onAuthGranted(); + + private: + void hookNetwork(); + + private: + Ui::RedditAccountDetails m_ui; + + // Pointer to live OAuth. + OAuth2Service* m_oauth; + QNetworkProxy m_lastProxy; +}; + +#endif // REDDITACCOUNTDETAILS_H diff --git a/src/librssguard-reddit/src/gui/redditaccountdetails.ui b/src/librssguard-reddit/src/gui/redditaccountdetails.ui new file mode 100644 index 000000000..da0a54a31 --- /dev/null +++ b/src/librssguard-reddit/src/gui/redditaccountdetails.ui @@ -0,0 +1,202 @@ + + + RedditAccountDetails + + + + 0 + 0 + 431 + 259 + + + + + + + Username + + + + + + + + + + + 0 + 1 + + + + OAuth 2.0 settings + + + + + + Client ID + + + m_txtAppId + + + + + + + + + + Client secret + + + m_txtAppKey + + + + + + + + + + Redirect URL + + + m_txtRedirectUrl + + + + + + + + + + + + Get my credentials + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + + Only download newest X articles per feed + + + m_spinLimitMessages + + + + + + + + 140 + 16777215 + + + + + + + + + + + + &Login + + + + + + + Qt::RightToLeft + + + + + + + + + Qt::Vertical + + + + 410 + 0 + + + + + + + + Download unread articles only + + + + + + + + LineEditWithStatus + QWidget +
lineeditwithstatus.h
+ 1 +
+ + LabelWithStatus + QWidget +
labelwithstatus.h
+ 1 +
+ + MessageCountSpinBox + QSpinBox +
messagecountspinbox.h
+
+ + HelpSpoiler + QWidget +
helpspoiler.h
+ 1 +
+
+ + m_btnRegisterApi + m_cbDownloadOnlyUnreadMessages + m_spinLimitMessages + m_btnTestSetup + + + +
diff --git a/src/librssguard-reddit/src/redditcategory.cpp b/src/librssguard-reddit/src/redditcategory.cpp new file mode 100644 index 000000000..599bef323 --- /dev/null +++ b/src/librssguard-reddit/src/redditcategory.cpp @@ -0,0 +1,20 @@ +// For license of this file, see /LICENSE.md. + +#include "src/redditcategory.h" + +RedditCategory::RedditCategory(Type type, RootItem* parent_item) + : Category(parent_item), m_type(type) { + updateTitle(); +} + +RedditCategory::Type RedditCategory::type() const { + return m_type; +} + +void RedditCategory::updateTitle() { + switch (m_type) { + case Type::Subscriptions: + setTitle(tr("Subscriptions")); + break; + } +} diff --git a/src/librssguard-reddit/src/redditcategory.h b/src/librssguard-reddit/src/redditcategory.h new file mode 100644 index 000000000..99d6bd64e --- /dev/null +++ b/src/librssguard-reddit/src/redditcategory.h @@ -0,0 +1,27 @@ +// For license of this file, see /LICENSE.md. + +#ifndef REDDITCATEGORY_H +#define REDDITCATEGORY_H + +#include "services/abstract/category.h" + +class RedditCategory : public Category { + Q_OBJECT + + public: + enum class Type { + Subscriptions = 1 + }; + + explicit RedditCategory(Type type = Type::Subscriptions, RootItem* parent_item = nullptr); + + Type type() const; + + private: + void updateTitle(); + + private: + Type m_type; +}; + +#endif // REDDITCATEGORY_H diff --git a/src/librssguard-reddit/src/redditentrypoint.cpp b/src/librssguard-reddit/src/redditentrypoint.cpp new file mode 100644 index 000000000..46a5e85e5 --- /dev/null +++ b/src/librssguard-reddit/src/redditentrypoint.cpp @@ -0,0 +1,50 @@ +// For license of this file, see /LICENSE.md. + +#include "src/redditentrypoint.h" + +#include "database/databasequeries.h" +#include "definitions/definitions.h" +#include "miscellaneous/application.h" +#include "miscellaneous/iconfactory.h" +#include "src/gui/formeditredditaccount.h" +#include "src/redditserviceroot.h" + +#include + +RedditEntryPoint::RedditEntryPoint(QObject* parent) : QObject(parent) {} + +RedditEntryPoint::~RedditEntryPoint() { + qDebugNN << LOGSEC_GMAIL << "Destructing" << QUOTE_W_SPACE(QSL(SERVICE_CODE_NEXTCLOUD)) << "plugin."; +} + +ServiceRoot* RedditEntryPoint::createNewRoot() const { + FormEditRedditAccount form_acc(qApp->mainFormWidget()); + + return form_acc.addEditAccount(); +} + +QList RedditEntryPoint::initializeSubtree() const { + QSqlDatabase database = qApp->database()->driver()->connection(QSL("RedditEntryPoint")); + + return DatabaseQueries::getAccounts(database, code()); +} + +QString RedditEntryPoint::name() const { + return QObject::tr("Reddit (WIP, no real functionality yet)"); +} + +QString RedditEntryPoint::code() const { + return QSL(SERVICE_CODE_REDDIT); +} + +QString RedditEntryPoint::description() const { + return QObject::tr("Simplistic Reddit client."); +} + +QString RedditEntryPoint::author() const { + return QSL(APP_AUTHOR); +} + +QIcon RedditEntryPoint::icon() const { + return qApp->icons()->miscIcon(QSL("reddit")); +} diff --git a/src/librssguard-reddit/src/redditentrypoint.h b/src/librssguard-reddit/src/redditentrypoint.h new file mode 100644 index 000000000..a8d583d25 --- /dev/null +++ b/src/librssguard-reddit/src/redditentrypoint.h @@ -0,0 +1,26 @@ +// For license of this file, see /LICENSE.md. + +#ifndef REDDITENTRYPOINT_H +#define REDDITENTRYPOINT_H + +#include "services/abstract/serviceentrypoint.h" + +class RedditEntryPoint : public QObject, public ServiceEntryPoint { + Q_OBJECT + Q_PLUGIN_METADATA(IID "io.github.martinrotter.rssguard.reddit" FILE "plugin.json") + Q_INTERFACES(ServiceEntryPoint) + + public: + explicit RedditEntryPoint(QObject* parent = nullptr); + virtual ~RedditEntryPoint(); + + virtual ServiceRoot* createNewRoot() const; + virtual QList initializeSubtree() const; + virtual QString name() const; + virtual QString code() const; + virtual QString description() const; + virtual QString author() const; + virtual QIcon icon() const; +}; + +#endif // REDDITENTRYPOINT_H diff --git a/src/librssguard-reddit/src/redditnetworkfactory.cpp b/src/librssguard-reddit/src/redditnetworkfactory.cpp new file mode 100644 index 000000000..6d31af1f9 --- /dev/null +++ b/src/librssguard-reddit/src/redditnetworkfactory.cpp @@ -0,0 +1,322 @@ +// For license of this file, see /LICENSE.md. + +#include "src/redditnetworkfactory.h" + +#include "database/databasequeries.h" +#include "definitions/definitions.h" +#include "exceptions/applicationexception.h" +#include "exceptions/networkexception.h" +#include "miscellaneous/application.h" +#include "miscellaneous/settings.h" +#include "network-web/networkfactory.h" +#include "network-web/oauth2service.h" +#include "src/definitions.h" +#include "src/redditserviceroot.h" +#include "src/redditsubscription.h" + +#include +#include +#include +#include +#include +#include + +RedditNetworkFactory::RedditNetworkFactory(QObject* parent) + : QObject(parent), m_service(nullptr), m_username(QString()), m_batchSize(REDDIT_DEFAULT_BATCH_SIZE), + m_downloadOnlyUnreadMessages(false), m_oauth2(new OAuth2Service(QSL(REDDIT_OAUTH_AUTH_URL), + QSL(REDDIT_OAUTH_TOKEN_URL), + {}, + {}, + QSL(REDDIT_OAUTH_SCOPE), + this)) { + initializeOauth(); +} + +void RedditNetworkFactory::setService(RedditServiceRoot* service) { + m_service = service; +} + +OAuth2Service* RedditNetworkFactory::oauth() const { + return m_oauth2; +} + +QString RedditNetworkFactory::username() const { + return m_username; +} + +int RedditNetworkFactory::batchSize() const { + return m_batchSize; +} + +void RedditNetworkFactory::setBatchSize(int batch_size) { + m_batchSize = batch_size; +} + +void RedditNetworkFactory::initializeOauth() { + m_oauth2->setUseHttpBasicAuthWithClientData(true); + m_oauth2->setRedirectUrl(QSL(OAUTH_REDIRECT_URI) + QL1C(':') + QString::number(REDDIT_OAUTH_REDIRECT_URI_PORT), true); + + connect(m_oauth2, &OAuth2Service::tokensRetrieveError, this, &RedditNetworkFactory::onTokensError); + connect(m_oauth2, &OAuth2Service::authFailed, this, &RedditNetworkFactory::onAuthFailed); + connect(m_oauth2, + &OAuth2Service::tokensRetrieved, + this, + [this](QString access_token, QString refresh_token, int expires_in) { + Q_UNUSED(expires_in) + Q_UNUSED(access_token) + + if (m_service != nullptr && !refresh_token.isEmpty()) { + QSqlDatabase database = qApp->database()->driver()->connection(metaObject()->className()); + + DatabaseQueries::storeNewOauthTokens(database, refresh_token, m_service->accountId()); + } + }); +} + +bool RedditNetworkFactory::downloadOnlyUnreadMessages() const { + return m_downloadOnlyUnreadMessages; +} + +void RedditNetworkFactory::setDownloadOnlyUnreadMessages(bool download_only_unread_messages) { + m_downloadOnlyUnreadMessages = download_only_unread_messages; +} + +void RedditNetworkFactory::setOauth(OAuth2Service* oauth) { + m_oauth2 = oauth; +} + +void RedditNetworkFactory::setUsername(const QString& username) { + m_username = username; +} + +QVariantHash RedditNetworkFactory::me(const QNetworkProxy& custom_proxy) { + QString bearer = m_oauth2->bearer().toLocal8Bit(); + + if (bearer.isEmpty()) { + throw ApplicationException(tr("you are not logged in")); + } + + QList> headers; + + headers.append(QPair(QSL(HTTP_HEADERS_AUTHORIZATION).toLocal8Bit(), + m_oauth2->bearer().toLocal8Bit())); + + int timeout = qApp->settings()->value(GROUP(Feeds), SETTING(Feeds::UpdateTimeout)).toInt(); + QByteArray output; + auto result = NetworkFactory::performNetworkOperation(QSL(REDDIT_API_GET_PROFILE), + timeout, + {}, + output, + QNetworkAccessManager::Operation::GetOperation, + headers, + false, + {}, + {}, + custom_proxy) + .m_networkError; + + if (result != QNetworkReply::NetworkError::NoError) { + throw NetworkException(result, output); + } + else { + QJsonDocument doc = QJsonDocument::fromJson(output); + + return doc.object().toVariantHash(); + } +} + +QList RedditNetworkFactory::subreddits(const QNetworkProxy& custom_proxy) { + QString bearer = m_oauth2->bearer().toLocal8Bit(); + + if (bearer.isEmpty()) { + throw ApplicationException(tr("you are not logged in")); + } + + QList> headers; + + headers.append(QPair(QSL(HTTP_HEADERS_AUTHORIZATION).toLocal8Bit(), + m_oauth2->bearer().toLocal8Bit())); + + int timeout = qApp->settings()->value(GROUP(Feeds), SETTING(Feeds::UpdateTimeout)).toInt(); + QString after; + QList subs; + + do { + QByteArray output; + QString final_url = QSL(REDDIT_API_SUBREDDITS).arg(QString::number(100)); + + if (!after.isEmpty()) { + final_url += QSL("&after=%1").arg(after); + } + + auto result = NetworkFactory::performNetworkOperation(final_url, + timeout, + {}, + output, + QNetworkAccessManager::Operation::GetOperation, + headers, + false, + {}, + {}, + custom_proxy) + .m_networkError; + + if (result != QNetworkReply::NetworkError::NoError) { + throw NetworkException(result, output); + } + else { + QJsonDocument doc = QJsonDocument::fromJson(output); + QJsonObject root_doc = doc.object(); + + after = root_doc["data"].toObject()["after"].toString(); + + for (const QJsonValue& sub_val : root_doc["data"].toObject()["children"].toArray()) { + const auto sub_obj = sub_val.toObject()["data"].toObject(); + + RedditSubscription* new_sub = new RedditSubscription(); + + new_sub->setCustomId(sub_obj["id"].toString()); + new_sub->setTitle(sub_obj["title"].toString()); + new_sub->setDescription(sub_obj["public_description"].toString()); + + new_sub->setPrefixedName(sub_obj["url"].toString()); + + QPixmap icon; + QString icon_url = sub_obj["community_icon"].toString(); + + if (icon_url.isEmpty()) { + icon_url = sub_obj["icon_img"].toString(); + } + + if (icon_url.contains(QL1S("?"))) { + icon_url = icon_url.mid(0, icon_url.indexOf(QL1S("?"))); + } + + if (!icon_url.isEmpty() && + NetworkFactory::downloadIcon({{icon_url, true}}, timeout, icon, headers, custom_proxy) == + QNetworkReply::NetworkError::NoError) { + new_sub->setIcon(icon); + } + + subs.append(new_sub); + } + } + } + while (!after.isEmpty()); + + // posty dle jmena redditu + // https://oauth.reddit.com//new + // + // komenty pro post dle id postu + // https://oauth.reddit.com//comments/ + + return subs; +} + +QList RedditNetworkFactory::hot(const QString& sub_name, const QNetworkProxy& custom_proxy) { + QString bearer = m_oauth2->bearer().toLocal8Bit(); + + if (bearer.isEmpty()) { + throw ApplicationException(tr("you are not logged in")); + } + + QList> headers; + + headers.append(QPair(QSL(HTTP_HEADERS_AUTHORIZATION).toLocal8Bit(), + m_oauth2->bearer().toLocal8Bit())); + + int timeout = qApp->settings()->value(GROUP(Feeds), SETTING(Feeds::UpdateTimeout)).toInt(); + QString after; + QList msgs; + + int desired_count = batchSize(); + + do { + int next_batch = desired_count <= 0 ? 100 : std::min(100, int(desired_count - msgs.size())); + + QByteArray output; + QString final_url = + QSL(REDDIT_API_HOT).arg(sub_name, QString::number(next_batch), QString::number(msgs.size()), QSL("GLOBAL")); + + if (!after.isEmpty()) { + final_url += QSL("&after=%1").arg(after); + } + + auto result = NetworkFactory::performNetworkOperation(final_url, + timeout, + {}, + output, + QNetworkAccessManager::Operation::GetOperation, + headers, + false, + {}, + {}, + custom_proxy) + .m_networkError; + + if (result != QNetworkReply::NetworkError::NoError) { + throw NetworkException(result, output); + } + else { + QJsonDocument doc = QJsonDocument::fromJson(output); + QJsonObject root_doc = doc.object(); + + after = root_doc["data"].toObject()["after"].toString(); + + for (const QJsonValue& sub_val : root_doc["data"].toObject()["children"].toArray()) { + const auto msg_obj = sub_val.toObject()["data"].toObject(); + + Message new_msg; + + new_msg.m_customId = msg_obj["id"].toString(); + new_msg.m_title = msg_obj["title"].toString(); + new_msg.m_author = msg_obj["author"].toString(); + new_msg.m_createdFromFeed = true; + new_msg.m_created = + QDateTime::fromSecsSinceEpoch(msg_obj["created_utc"].toVariant().toLongLong(), Qt::TimeSpec::UTC); + new_msg.m_url = QSL("https://reddit.com") + msg_obj["permalink"].toString(); + new_msg.m_contents = + msg_obj["description_html"] + .toString(); // když prazdny, je poustnutej třeba obrazek či odkaz?, viz property "post_hint"? + new_msg.m_rawContents = QJsonDocument(msg_obj).toJson(QJsonDocument::JsonFormat::Compact); + + msgs.append(new_msg); + } + } + } + while (!after.isEmpty() && (desired_count <= 0 || desired_count > msgs.size())); + + // posty dle jmena redditu + // https://oauth.reddit.com//new + // + // komenty pro post dle id postu + // https://oauth.reddit.com//comments/ + + return msgs; +} + +void RedditNetworkFactory::onTokensError(const QString& error, const QString& error_description) { + Q_UNUSED(error) + + qApp->showGuiMessage(Notification::Event::LoginFailure, + {tr("Reddit: authentication error"), + tr("Click this to login again. Error is: '%1'").arg(error_description), + QSystemTrayIcon::MessageIcon::Critical}, + {}, + {tr("Login"), [this]() { + m_oauth2->setAccessToken(QString()); + m_oauth2->setRefreshToken(QString()); + m_oauth2->login(); + }}); +} + +void RedditNetworkFactory::onAuthFailed() { + qApp->showGuiMessage(Notification::Event::LoginFailure, + {tr("Reddit: authorization denied"), + tr("Click this to login again."), + QSystemTrayIcon::MessageIcon::Critical}, + {}, + {tr("Login"), [this]() { + m_oauth2->login(); + }}); +} diff --git a/src/librssguard-reddit/src/redditnetworkfactory.h b/src/librssguard-reddit/src/redditnetworkfactory.h new file mode 100644 index 000000000..3f02755ae --- /dev/null +++ b/src/librssguard-reddit/src/redditnetworkfactory.h @@ -0,0 +1,61 @@ +// For license of this file, see /LICENSE.md. + +#ifndef REDDITNETWORKFACTORY_H +#define REDDITNETWORKFACTORY_H + +#include "core/message.h" +#include "services/abstract/feed.h" +#include "services/abstract/rootitem.h" +#include "src/3rd-party/mimesis/mimesis.hpp" + +#include +#include + +class RootItem; +class RedditServiceRoot; +class OAuth2Service; +class Downloader; + +struct Subreddit {}; + +class RedditNetworkFactory : public QObject { + Q_OBJECT + + public: + explicit RedditNetworkFactory(QObject* parent = nullptr); + + void setService(RedditServiceRoot* service); + + OAuth2Service* oauth() const; + void setOauth(OAuth2Service* oauth); + + QString username() const; + void setUsername(const QString& username); + + int batchSize() const; + void setBatchSize(int batch_size); + + bool downloadOnlyUnreadMessages() const; + void setDownloadOnlyUnreadMessages(bool download_only_unread_messages); + + // API methods. + QVariantHash me(const QNetworkProxy& custom_proxy); + QList subreddits(const QNetworkProxy& custom_proxy); + QList hot(const QString& sub_name, const QNetworkProxy& custom_proxy); + + private slots: + void onTokensError(const QString& error, const QString& error_description); + void onAuthFailed(); + + private: + void initializeOauth(); + + private: + RedditServiceRoot* m_service; + QString m_username; + int m_batchSize; + bool m_downloadOnlyUnreadMessages; + OAuth2Service* m_oauth2; +}; + +#endif // REDDITNETWORKFACTORY_H diff --git a/src/librssguard-reddit/src/redditserviceroot.cpp b/src/librssguard-reddit/src/redditserviceroot.cpp new file mode 100644 index 000000000..00716397a --- /dev/null +++ b/src/librssguard-reddit/src/redditserviceroot.cpp @@ -0,0 +1,142 @@ +// For license of this file, see /LICENSE.md. + +#include "src/redditserviceroot.h" + +#include "database/databasequeries.h" +#include "miscellaneous/application.h" +#include "network-web/oauth2service.h" +#include "src/gui/formeditredditaccount.h" +#include "src/redditcategory.h" +#include "src/redditentrypoint.h" +#include "src/redditnetworkfactory.h" +#include "src/redditsubscription.h" + +#include + +RedditServiceRoot::RedditServiceRoot(RootItem* parent) + : ServiceRoot(parent), m_network(new RedditNetworkFactory(this)) { + m_network->setService(this); + setIcon(RedditEntryPoint().icon()); +} + +void RedditServiceRoot::updateTitle() { + setTitle(TextFactory::extractUsernameFromEmail(m_network->username()) + QSL(" (Reddit)")); +} + +RootItem* RedditServiceRoot::obtainNewTreeForSyncIn() const { + auto* root = new RootItem(); + + auto feeds = m_network->subreddits(networkProxy()); + + for (auto* feed : feeds) { + root->appendChild(feed); + } + + return root; +} + +QVariantHash RedditServiceRoot::customDatabaseData() const { + QVariantHash data = ServiceRoot::customDatabaseData(); + + data[QSL("username")] = m_network->username(); + data[QSL("batch_size")] = m_network->batchSize(); + data[QSL("download_only_unread")] = m_network->downloadOnlyUnreadMessages(); + data[QSL("client_id")] = m_network->oauth()->clientId(); + data[QSL("client_secret")] = m_network->oauth()->clientSecret(); + data[QSL("refresh_token")] = m_network->oauth()->refreshToken(); + data[QSL("redirect_uri")] = m_network->oauth()->redirectUrl(); + + return data; +} + +void RedditServiceRoot::setCustomDatabaseData(const QVariantHash& data) { + ServiceRoot::setCustomDatabaseData(data); + + m_network->setUsername(data[QSL("username")].toString()); + m_network->setBatchSize(data[QSL("batch_size")].toInt()); + m_network->setDownloadOnlyUnreadMessages(data[QSL("download_only_unread")].toBool()); + m_network->oauth()->setClientId(data[QSL("client_id")].toString()); + m_network->oauth()->setClientSecret(data[QSL("client_secret")].toString()); + m_network->oauth()->setRefreshToken(data[QSL("refresh_token")].toString()); + m_network->oauth()->setRedirectUrl(data[QSL("redirect_uri")].toString(), true); +} + +QList RedditServiceRoot::obtainNewMessages(Feed* feed, + const QHash& + stated_messages, + const QHash& tagged_messages) { + Q_UNUSED(stated_messages) + Q_UNUSED(tagged_messages) + Q_UNUSED(feed) + + QList messages = m_network->hot(qobject_cast(feed)->prefixedName(), networkProxy()); + + return messages; +} + +bool RedditServiceRoot::isSyncable() const { + return true; +} + +bool RedditServiceRoot::canBeEdited() const { + return true; +} + +void RedditServiceRoot::editItems(const QList& items) { + if (items.first()->kind() == RootItem::Kind::ServiceRoot) { + QScopedPointer p(qobject_cast(accountSetupDialog())); + + p->addEditAccount(this); + return; + } + + ServiceRoot::editItems(items); +} + +FormAccountDetails* RedditServiceRoot::accountSetupDialog() const { + return new FormEditRedditAccount(qApp->mainFormWidget()); +} + +bool RedditServiceRoot::supportsFeedAdding() const { + return false; +} + +bool RedditServiceRoot::supportsCategoryAdding() const { + return false; +} + +void RedditServiceRoot::start(bool freshly_activated) { + if (!freshly_activated) { + DatabaseQueries::loadRootFromDatabase(this); + loadCacheFromFile(); + } + + updateTitle(); + + if (getSubTreeFeeds().isEmpty()) { + m_network->oauth()->login([this]() { + syncIn(); + }); + } + else { + m_network->oauth()->login(); + } +} + +QString RedditServiceRoot::code() const { + return RedditEntryPoint().code(); +} + +QString RedditServiceRoot::additionalTooltip() const { + return ServiceRoot::additionalTooltip() + QSL("\n") + + tr("Authentication status: %1\n" + "Login tokens expiration: %2") + .arg(network()->oauth()->isFullyLoggedIn() ? tr("logged-in") : tr("NOT logged-in"), + network()->oauth()->tokensExpireIn().isValid() ? network()->oauth()->tokensExpireIn().toString() + : QSL("-")); +} + +void RedditServiceRoot::saveAllCachedData(bool ignore_errors) { + Q_UNUSED(ignore_errors) + auto msg_cache = takeMessageCache(); +} diff --git a/src/librssguard-reddit/src/redditserviceroot.h b/src/librssguard-reddit/src/redditserviceroot.h new file mode 100644 index 000000000..568faa1ac --- /dev/null +++ b/src/librssguard-reddit/src/redditserviceroot.h @@ -0,0 +1,54 @@ +// For license of this file, see /LICENSE.md. + +#ifndef REDDITSERVICEROOT_H +#define REDDITSERVICEROOT_H + +#include "services/abstract/cacheforserviceroot.h" +#include "services/abstract/serviceroot.h" + +class RedditNetworkFactory; + +class RedditServiceRoot : public ServiceRoot, public CacheForServiceRoot { + Q_OBJECT + + public: + explicit RedditServiceRoot(RootItem* parent = nullptr); + + void setNetwork(RedditNetworkFactory* network); + RedditNetworkFactory* network() const; + + virtual bool isSyncable() const; + virtual bool canBeEdited() const; + virtual void editItems(const QList& items); + virtual FormAccountDetails* accountSetupDialog() const; + virtual bool supportsFeedAdding() const; + virtual bool supportsCategoryAdding() const; + virtual void start(bool freshly_activated); + virtual QString code() const; + virtual QString additionalTooltip() const; + virtual void saveAllCachedData(bool ignore_errors); + virtual QVariantHash customDatabaseData() const; + virtual void setCustomDatabaseData(const QVariantHash& data); + virtual QList obtainNewMessages(Feed* feed, + const QHash& stated_messages, + const QHash& tagged_messages); + + protected: + virtual RootItem* obtainNewTreeForSyncIn() const; + + private: + void updateTitle(); + + private: + RedditNetworkFactory* m_network; +}; + +inline void RedditServiceRoot::setNetwork(RedditNetworkFactory* network) { + m_network = network; +} + +inline RedditNetworkFactory* RedditServiceRoot::network() const { + return m_network; +} + +#endif // REDDITSERVICEROOT_H diff --git a/src/librssguard-reddit/src/redditsubscription.cpp b/src/librssguard-reddit/src/redditsubscription.cpp new file mode 100644 index 000000000..796193e12 --- /dev/null +++ b/src/librssguard-reddit/src/redditsubscription.cpp @@ -0,0 +1,32 @@ +// For license of this file, see /LICENSE.md. + +#include "src/redditsubscription.h" + +#include "definitions/definitions.h" +#include "src/redditserviceroot.h" + +RedditSubscription::RedditSubscription(RootItem* parent) : Feed(parent), m_prefixedName(QString()) {} + +RedditServiceRoot* RedditSubscription::serviceRoot() const { + return qobject_cast(getParentServiceRoot()); +} + +QString RedditSubscription::prefixedName() const { + return m_prefixedName; +} + +void RedditSubscription::setPrefixedName(const QString& prefixed_name) { + m_prefixedName = prefixed_name; +} + +QVariantHash RedditSubscription::customDatabaseData() const { + QVariantHash data; + + data.insert(QSL("prefixed_name"), prefixedName()); + + return data; +} + +void RedditSubscription::setCustomDatabaseData(const QVariantHash& data) { + setPrefixedName(data.value(QSL("prefixed_name")).toString()); +} diff --git a/src/librssguard-reddit/src/redditsubscription.h b/src/librssguard-reddit/src/redditsubscription.h new file mode 100644 index 000000000..99e774305 --- /dev/null +++ b/src/librssguard-reddit/src/redditsubscription.h @@ -0,0 +1,28 @@ +// For license of this file, see /LICENSE.md. + +#ifndef REDDITSUBSCRIPTION_H +#define REDDITSUBSCRIPTION_H + +#include "services/abstract/feed.h" + +class RedditServiceRoot; + +class RedditSubscription : public Feed { + Q_OBJECT + + public: + explicit RedditSubscription(RootItem* parent = nullptr); + + RedditServiceRoot* serviceRoot() const; + + QString prefixedName() const; + void setPrefixedName(const QString& prefixed_name); + + virtual QVariantHash customDatabaseData() const; + virtual void setCustomDatabaseData(const QVariantHash& data); + + private: + QString m_prefixedName; +}; + +#endif // REDDITSUBSCRIPTION_H