#include "mastodon.h" #include "src/activitypub/apactor.h" #include "src/list_item.h" #include "src/types.h" #include "src/activitypub/apactivity.h" #include "src/activitypub/apobject.h" #include "src/activitypub/apreblog.h" #include "src/activitypub/appost.h" #include "src/activitypub/apquestion.h" #include "src/activitypub/fields.h" #include #include #include #include #include #include #include #include MastodonArchive::MastodonArchive(const QString& filename) : Archive(filename) {} MastodonArchive::InitError MastodonArchive::init() { QFile outbox_file(main_filename); if (!outbox_file.open(QIODevice::ReadOnly | QIODevice::Text)) return FailedOpeningFile; QJsonParseError json_error; QJsonDocument outbox_json_document = QJsonDocument::fromJson(outbox_file.readAll(), &json_error); outbox_file.close(); if (json_error.error != QJsonParseError::NoError) return JsonParseError; if (outbox_json_document.isEmpty()) return JsonEmpty; if (outbox_json_document.isNull()) return JsonNull; if (outbox_json_document.isObject()) outbox_json = new QJsonObject(outbox_json_document.object()); else return JsonNotObject; // Do some more throughful checks to make sure that the JSON is actually valid and is a Mastodon data export (the only type supported currently) if (not json_check_item(outbox_json->value("@context"), "https://www.w3.org/ns/activitystreams")) return JsonNotActivityStream; if (outbox_json->value("orderedItems").isArray()) { outbox_items = new QJsonArray(outbox_json->value("orderedItems").toArray()); // we'll need it during Archive's lifetime } else return JsonParseError; archive_root_dir = QFileInfo(main_filename).absoluteDir(); return NoError; } MastodonArchive::~MastodonArchive() { delete outbox_json; outbox_json = nullptr; delete outbox_items; outbox_items = nullptr; } bool MastodonArchive::is_status_type_allowed(StatusType status_type, ViewStatusTypes allowed_types) { switch (status_type) { case PUBLIC: return allowed_types.includePublic; case UNLISTED: return allowed_types.includeUnlisted; case PRIVATE: return allowed_types.includePrivate; case DIRECT: return allowed_types.includeDirect; case REBLOG: return allowed_types.includeReblogs; default: return true; } } const QString MastodonArchive::get_instance_address() { // TODO: implement return "https://example.com"; } // specific to Mastodon ActivityStreams archives StatusType MastodonArchive::get_status_type(QJsonObject obj) { /* * public: * to: #Public * unlisted: * to: /followers * cc: #Public * followers: * to: /followers * direct: * to: the Actors mentioned */ StatusType status_type = UNKNOWN; bool is_private_or_unlisted = false; QJsonArray to; if (obj.value("to").isArray()) { to = obj.value("to").toArray(); // see https://www.w3.org/TR/activitypub/#public-addressing for (auto collection : to) { QString col = collection.toString(); if (col == "https://www.w3.org/ns/activitystreams#Public") { return PUBLIC; // status' privacy can only be promoted to a higher level } else if (col.endsWith("/followers")) // at least for Mastodon… is_private_or_unlisted = true; // private or unlisted } } if (obj.value("cc").isArray()) { QJsonArray cc = obj.value("cc").toArray(); for (auto collection : cc) { QString col = collection.toString(); if (col == "https://www.w3.org/ns/activitystreams#Public" and is_private_or_unlisted) { return UNLISTED; // status' privacy can only be promoted to a higher level } } if (is_private_or_unlisted) return PRIVATE; else if (to.size() > 0) return DIRECT; else if (to.size() == 0 and cc.size() == 0) // sending a direct message to no one or to another actor that doesn't exist anymore return DIRECT; } return status_type; } // Note: No need to create an AP… class for that (unless we want to precache the Activities but that would be very slow and use a lot of memory) void MastodonArchive::update_status_list(ViewStatusTypes allowed_types, QListWidget *parent) { int i = 0; for (auto&& item : *outbox_items) { if (item.isObject()){ QJsonObject obj = item.toObject(); if (not obj.value("type").isString()) // this shouldn't happen, but you never know goto next_item; QString activity_type = obj.value("type").toString(); if (not (activity_type == "Create" or activity_type == "Announce")) // ignoring everything that isn't a Create or Announce activity for now goto next_item; // Determine the status' type // NOTE: keep StatusType status_type; if (activity_type == "Announce") status_type = REBLOG; else status_type = get_status_type(obj); if (not is_status_type_allowed(status_type, allowed_types)) goto next_item; if (activity_type == "Create" and obj.value("object").isObject()) { QJsonObject activity = obj.value("object").toObject(); bool has_attachment = false; if (activity.contains("attachment") and not activity["attachment"].toArray().isEmpty()) has_attachment = true; if (allowed_types.onlyWithAttachment and not has_attachment) goto next_item; if (activity.value("content").isString()) { // Strip HTML for display in list, according to https://stackoverflow.com/a/12157835 QTextDocument strip_html; strip_html.setHtml(activity.value("content").toString()); StatusListItem *item = new StatusListItem(strip_html.toPlainText(), status_type, has_attachment, this, parent, i); } } else if (activity_type == "Announce" and obj["object"].isString()) { StatusListItem *item = new StatusListItem(activity_type, REBLOG, false, this, parent, i); } } next_item: ++i; } } QStringList MastodonArchive::get_status_object_list(QJsonObject obj, QString element_name) { QStringList list; if (obj.contains(element_name)) { QJsonArray elem = obj.value(element_name).toArray(); for (auto collection : elem) list.append(collection.toString()); } return list; } QStringList MastodonArchive::get_status_object_language_list(QJsonObject obj, QString element_name = "contentMap") { QStringList list; if (obj.contains(element_name) and obj.value(element_name).isObject()) { QJsonObject content_map = obj.value(element_name).toObject(); QStringList languages = content_map.keys(); for (auto lang : languages) list.append(lang); } return list; } std::vector MastodonArchive::get_status_attachments_list(QJsonValueRef attachments_ref) { std::vector list; QJsonArray attachments = attachments_ref.toArray(); for (auto attachment_ref : attachments) { APAttachmentFields element; QJsonObject attachment = attachment_ref.toObject(); if (attachment.contains("url")) { QString url = attachment["url"].toString(); // NOTE: This will continue to be done by the archive parser when building the APAttachmentFields object!! SO DO NOT MOVE // Initialize attachment_dir if it hasn't been done if (attachment_dir_have_to_find) find_attachment_dir(url); // Also add a "/", after the attachment dir path, to make sure that the url starts with one as sometimes the ones in the json do not. We could make this prettier (but a bit slower) by only adding it conditionally. This might be worth it as the path can be copied and opened in the browser. url.prepend(attachment_dir.absolutePath() + "/"); element.path = url; element.filename = QFileInfo(url).fileName(); } element.media_type = attachment["mediaType"].toString(); element.name = attachment["name"].toString(); list.push_back(element); } return list; } APActivityPtr MastodonArchive::get_activity(ArchiveItemRef index, Hinting_t hinting) { // the JSON AP Activity QJsonObject activity = outbox_items->at(index).toObject(); APActivityFields act_fields = { .visibility = hinting.status_type }; QString obj_url; // The post APObjectFields obj_fields; APObjectType obj_type = APObjectType::UNKNOWN; if (activity.contains("type") and activity["type"].isString()) { QString type = activity["type"].toString(); if (type == "Create") act_fields.type = APActivityType::CREATE; else if (type == "Announce") act_fields.type = APActivityType::ANNOUNCE; else act_fields.type = APActivityType::UNKNOWN; } if (activity.contains("published") and activity["published"].isString()) act_fields.published = activity["published"].toString(); if (activity.contains("id") and activity["id"].isString()) act_fields.object_url = activity["id"].toString(); if (activity.contains("actor")) act_fields.by_actor = activity["actor"].toString(); // returns Null string if actor is not a string act_fields.to_actors = get_status_object_list(activity, "to"); act_fields.cc_actors = get_status_object_list(activity, "cc"); if (activity["object"].isObject()) { // the JSON AP Object QJsonObject object = activity["object"].toObject(); { QString type = object["type"].toString(); if (type == "Note") obj_type = APObjectType::NOTE; else if (type == "Question") obj_type = APObjectType::QUESTION; } obj_fields.visibility = get_status_type(object); if (object.contains("summary")) obj_fields.summary = object["summary"].toString(); obj_fields.attachments = get_status_attachments_list(object["attachment"]); obj_fields.web_url = object["url"].toString(); obj_fields.object_url = object["atomUri"].toString(); // atomUri? obj_fields.reply_to_url = object["inReplyTo"].toString(); if (object["content"].isString()) { obj_fields.content = object["content"].toString(); obj_fields.languages = get_status_object_language_list(object); } obj_fields.to_actors = get_status_object_list(object, "to"); obj_fields.cc_actors = get_status_object_list(object, "cc"); obj_fields.by_actor = object["attributedTo"].toString(); obj_fields.published = object["published"].toString(); if (obj_type == APObjectType::QUESTION) { obj_fields.question = { .end_time = object["endTime"].toString(), .closed_time = object["closed"].toString(), .total_votes = object["votersCount"].toInt() }; for (QJsonValue elem : object["oneOf"].toArray()) obj_fields.question.poll_options.push_back({ (elem["type"].toString() == "Note") ? elem["name"].toString() : "?", elem["replies"].toObject()["totalItems"].toInt() }); } } else if (activity["object"].isString()) obj_url = activity["object"].toString(); if (not obj_url.isNull()) act_fields.object = std::make_shared(APReblogFields {obj_url, act_fields.visibility}); else switch(obj_type) { case APObjectType::UNKNOWN: case APObjectType::NOTE: act_fields.object = std::make_shared(obj_fields); break; case APObjectType::QUESTION: act_fields.object = std::make_shared(obj_fields); break; } // TODO: it is currently a waste to create this APActivity object that will be immediately destroyed but it allows us to extend archive parsing to something that will become abstract (and an abstract base class) and separate from display (the final goal) which will allow us to add other sources that feed us with posts, reblogs and Actor information. furthermore, these objects can be cached for reuse in a session. return std::make_shared(act_fields); } // TODO: make this use an APActivity object that will be present as an StatusListItem member. const QString MastodonArchive::get_html_status_text(ArchiveItemRef index) { QString text(""); QJsonObject obj = outbox_items->at(index).toObject(); if (obj["object"].isObject()) { QJsonObject activity = obj["object"].toObject(); if (activity.contains("summary")) { QString summary_text = activity["summary"].toString(); if (not summary_text.isEmpty()) text.append(QString("

CW: {{summary}}

").replace("{{summary}}", summary_text)); } if (activity["content"].isString()) { text.append(QString("
{{content}}
").replace("{{content}}", activity["content"].toString())); } } return text; } APActorPtr MastodonArchive::get_main_actor() { // Avoid recreating the Actor each time by caching it if (actor) return actor; // First, get the file QString actor_filepath = archive_root_dir.filePath("actor.json"); if (not QFile::exists(actor_filepath)) return nullptr; QFile actor_file(actor_filepath); if (!actor_file.open(QIODevice::ReadOnly | QIODevice::Text)) return nullptr; QJsonParseError json_error; QJsonDocument actor_json_document = QJsonDocument::fromJson(actor_file.readAll(), &json_error); actor_file.close(); if (json_error.error != QJsonParseError::NoError or actor_json_document.isNull() or actor_json_document.isEmpty() or not actor_json_document.isObject()) return nullptr; // Second, now check the JSON QJsonObject actor_json(actor_json_document.object()); // Do some more throughful checks to make sure that the JSON is actually valid // Please tell me if actor is not "actor.json" (also, i should instead first read actor.json to know the outbox file location, not the other way around as i've been doing since the beginning). Indeed, the checks are also quite random if (not json_check_item(actor_json.value("@context"), "https://www.w3.org/ns/activitystreams") or actor_json.value("outbox").toString() != "outbox.json") return nullptr; // Third, now parse the JSON :-D { APActorFields obj_fields; // If the key doesn't exist then toString() will return a null QString obj_fields.url = actor_json.value("id").toString(); obj_fields.username = actor_json.value("preferredUsername").toString(); obj_fields.display_name = actor_json.value("name").toString(); obj_fields.summary = actor_json.value("summary").toString(); obj_fields.manuallyApprovesFollowers = actor_json.value("manuallyApprovesFollowers").toBool(); obj_fields.discoverable = actor_json.value("discoverable").toBool(); obj_fields.joined_date = actor_json.value("published").toString(); if (actor_json.value("attachment").isArray()) { QJsonArray table = actor_json.value("attachment").toArray(); for (auto pair_obj : table) { QJsonObject pair = pair_obj.toObject(); // Not a PropertyValue, so better not continue with this pair (just in case it represents something else) if (pair.value("type").toString() != "PropertyValue") { qDebug() << "not a PropertyValue!"; continue; } if (pair.contains("name") && pair.contains("value")) { obj_fields.keys.append(pair.value("name").toString()); obj_fields.values.append(pair.value("value").toString()); } } } if (actor_json.value("alsoKnownAs").isArray()) { QJsonArray list = actor_json.value("alsoKnownAs").toArray(); for (auto id_obj : list) obj_fields.also_known_as.append(id_obj.toString()); } if (actor_json.value("icon").isObject()) { APAttachmentFields att_fields; QJsonObject attachment = actor_json.value("icon").toObject(); att_fields.media_type = attachment["mediaType"].toString(); att_fields.path = archive_root_dir.absoluteFilePath(attachment["url"].toString()); obj_fields.avatar = new APAttachment(att_fields); } if (actor_json.value("image").isObject()) { APAttachmentFields att_fields; QJsonObject attachment = actor_json.value("image").toObject(); att_fields.media_type = attachment["mediaType"].toString(); att_fields.path = archive_root_dir.absoluteFilePath(attachment["url"].toString()); obj_fields.header = new APAttachment(att_fields); } actor = std::make_shared(obj_fields); // TODO: query type. it seems to be always "Person", but maybe if the account is a bot, it changes? // The code also currently ignores possible featured tags and users } return actor; } void MastodonArchive::find_attachment_dir(QString example_attachment) { // Find the root directory name of the attachment QString root_name = example_attachment.split('/', Qt::SkipEmptyParts)[0]; // Iterate over each subdirectory of the archive to find the directory containing the attachments archive_root_dir.setFilter(QDir::Dirs|QDir::NoDotAndDotDot); QDirIterator it(archive_root_dir, QDirIterator::Subdirectories); while (it.hasNext()) { QString current_dir = it.next(); if (current_dir.section('/', -1) == root_name) { // We have found the directory // Remove the root_name component (that should be at the end) as it will be added back due to being part of the attachment url attachment_dir.setPath(current_dir.remove(root_name)); attachment_dir_have_to_find = false; if (not attachment_dir.exists()) // This shouldn't happen, but log it in case qDebug() << "Attachment dir does not exist!" << attachment_dir.canonicalPath(); return; } } // If the attachment directory wasn't found, it will be searched for next attachment url parsing as attachment_dir_have_to_find wasn't touched (and is still true) } bool MastodonArchive::json_check_item(const QJsonValue& value, const QString& item) { // This allows us to avoid having an "and" condition that has QJsonObject::contains() and simplifies the if statement to only having this current function if (value.type() == QJsonValue::Undefined) return false; if (value.toString() == item) return true; else if (value.isArray()) { QJsonArray array = value.toArray(); return array.contains(item); } else return false; }