// Matrix Construct // // Copyright (C) Matrix Construct Developers, Authors & Contributors // Copyright (C) 2016-2018 Jason Volk // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above // copyright notice and this permission notice is present in all copies. The // full license for this software is available in the LICENSE file. using namespace ircd; mapi::header IRCD_MODULE { "Web hook Handler" }; conf::item webhook_secret { { "name", "webhook.secret" } }; conf::item webhook_user { { "name", "webhook.user" } }; conf::item webhook_room { { "name", "webhook.room" } }; conf::item webhook_url { { "name", "webhook.url" }, { "default", "/webhook" } }; conf::item webhook_status_verbose { { "name", "webhook.github.status.verbose" }, { "default", true }, }; resource webhook_resource { string_view{webhook_url}, { "Webhook Resource", webhook_resource.DIRECTORY } }; static resource::response post__webhook(client &, const resource::request &); resource::method webhook_post { webhook_resource, "POST", post__webhook }; static void github_handle(client &, const resource::request &); static void appveyor_handle(client &, const resource::request &); static void dockerhub_handle(client &, const resource::request &); resource::response post__webhook(client &client, const resource::request &request) { const http::headers &headers { request.head.headers }; if(http::has(headers, "X-GitHub-Event")) github_handle(client, request); else if(http::has(headers, "X-Appveyor-Secret")) appveyor_handle(client, request); else if(startswith(request.head.content_type, "application/json")) dockerhub_handle(client, request); return resource::response { client, http::OK }; } static bool github_validate(const string_view &sig, const const_buffer &content, const string_view &secret); static std::string github_url(const json::string &url); static json::string github_find_commit_hash(const json::object &content); static json::string github_find_issue_number(const json::object &content); static std::pair github_find_party(const json::object &content); static std::pair github_find_repo(const json::object &content); static bool github_handle__milestone(std::ostream &, const json::object &content); static bool github_handle__gollum(std::ostream &, const json::object &content); static bool github_handle__push(std::ostream &, const json::object &content); static bool github_handle__pull_request(std::ostream &, const json::object &content); static bool github_handle__issue_comment(std::ostream &, const json::object &content); static bool github_handle__commit_comment(std::ostream &, const json::object &content); static bool github_handle__issues(std::ostream &, const json::object &content); static bool github_handle__watch(std::ostream &, const json::object &content); static bool github_handle__star(std::ostream &, const json::object &content); static bool github_handle__label(std::ostream &, const json::object &content); static bool github_handle__organization(std::ostream &, const json::object &content); static bool github_handle__status(std::ostream &, const json::object &content); static bool github_handle__repository(std::ostream &, const json::object &content); static bool github_handle__delete(std::ostream &, const json::object &content); static bool github_handle__create(std::ostream &, const json::object &content); static bool github_handle__ping(std::ostream &, const json::object &content); static std::ostream & github_heading(std::ostream &, const string_view &type, const json::object &content); void github_handle(client &client, const resource::request &request) { if(!string_view(webhook_room)) return; if(!string_view(webhook_user)) return; const http::headers &headers { request.head.headers }; const auto sig { headers.at("X-Hub-Signature") }; if(!github_validate(sig, request.content, webhook_secret)) throw http::error { http::UNAUTHORIZED, "X-Hub-Signature verification failed" }; const string_view &type { headers.at("X-GitHub-Event") }; const string_view &delivery { headers.at("X-GitHub-Delivery") }; const unique_buffer buf { 48_KiB }; std::stringstream out; pubsetbuf(out, buf); github_heading(out, type, request.content); const bool ok { type == "ping"? github_handle__ping(out, request.content): type == "push"? github_handle__push(out, request.content): type == "pull_request"? github_handle__pull_request(out, request.content): type == "issues"? github_handle__issues(out, request.content): type == "issue_comment"? github_handle__issue_comment(out, request.content): type == "commit_comment"? github_handle__commit_comment(out, request.content): type == "watch"? github_handle__watch(out, request.content): type == "star"? github_handle__star(out, request.content): type == "label"? github_handle__label(out, request.content): type == "organization"? github_handle__organization(out, request.content): type == "status"? github_handle__status(out, request.content): type == "repository"? github_handle__repository(out, request.content): type == "create"? github_handle__create(out, request.content): type == "delete"? github_handle__delete(out, request.content): type == "gollum"? github_handle__gollum(out, request.content): type == "milestone"? github_handle__milestone(out, request.content): true // unhandled will just show heading }; if(!ok) return; const auto room_id { m::room_id(string_view(webhook_room)) }; const m::user::id::buf user_id { string_view(webhook_user), my_host() }; const fmt::bsprintf<512> alt_msg { "%s by %s to %s at %s", type, github_find_party(request.content).first, github_find_repo(request.content).first, github_find_commit_hash(request.content), }; const auto evid { m::msghtml(room_id, user_id, view(out, buf), alt_msg, "m.notice") }; log::info { "Webhook [%s] '%s' delivered to %s %s", delivery, type, string_view{room_id}, string_view{evid} }; } static std::ostream & github_heading(std::ostream &out, const string_view &type, const json::object &content) { const json::object repository { content["repository"] }; const json::object organization { content["organization"] }; if(empty(repository)) { const auto url { github_url(organization["url"]) }; out << "" << json::string(organization["login"]) << ""; } else out << "" << json::string(repository["full_name"]) << ""; const auto commit_hash { github_find_commit_hash(content) }; if(commit_hash && type == "push") out << " "; else if(commit_hash && type == "pull_request") out << " "; else if(commit_hash) out << " "; if(commit_hash) out << commit_hash.substr(0, 8) << ""; const string_view issue_number { github_find_issue_number(content) }; if(issue_number) out << " #" << issue_number << ""; else out << " " << type; const auto party { github_find_party(content) }; out << " by " << "" << party.first << ""; return out; } bool github_handle__gollum(std::ostream &out, const json::object &content) { const json::array pages { content["pages"] }; const auto count { size(pages) }; out << " to " << "" << count << "" << " page" << (count != 1? "s" : "") << ":" ; for(const json::object page : pages) { const json::string &action { page["action"] }; const json::string sha { page["sha"] }; out << "
" << "" << sha.substr(0, 8) << "" << " " << action << " " << "" << "" << json::string(page["title"]) << "" << "" ; if(page["summary"] && page["summary"] != "null") { out << " " << "
" << "
"
			;

			static const auto delim("\\r\\n");
			const json::string body(page["summary"]);
			ircd::tokens(body, delim, [&out]
			(const string_view &line)
			{
				out << line << "
"; }); out << "" << "
" << "
" ; } } return true; } bool github_handle__milestone(std::ostream &out, const json::object &content) { const json::string &action { content["action"] }; const json::object milestone { content["milestone"] }; out << " " << action << " " << "" << "" << json::string(milestone["title"]) << "" << "" << ' ' ; const json::string &state { milestone["state"] }; if(state == "open") out << "" ; else if(state == "closed") out << "" ; out << " " << state << " " << "" ; out << ' ' << "
"
	<< json::string(milestone["description"])
	<< "
" ; out << ' ' << "Issues" << ' ' << "open" << " " << "" << "" << milestone["open_issues"] << "" << "" << ' ' << "closed" << ' ' << "" << "" << milestone["closed_issues"] << "" << "" ; return true; } bool github_handle__push(std::ostream &out, const json::object &content) { const bool created { content.get("created", false) }; const bool deleted { content.get("deleted", false) }; const bool forced { content.get("forced", false) }; const json::array commits { content["commits"] }; const auto count { size(commits) }; if(!count && deleted) { out << " "; if(content["ref"]) out << " " << json::string(content["ref"]); out << " deleted"; return true; } if(!count && !webhook_status_verbose) return false; if(content["ref"]) { const json::string ref(content["ref"]); out << " " << " " << token_last(ref, '/'); } out << " " << "" << count << " commits" << ""; if(content["forced"] == "true") out << " (rebase)"; out << "
";
	for(ssize_t i(count - 1); i >= 0; --i)
	{
		const json::object &commit(commits.at(i));
		const json::string url(commit["url"]);
		const json::string id(commit["id"]);
		const auto sid(id.substr(0, 8));
		out << " "
		    << "" << sid << ""
		    << "";

		const json::object author(commit["author"]);
		out << " "
		    << json::string(author["name"])
		    << ""
		    ;

		const json::object committer(commit["committer"]);
		if(committer["email"] != author["email"])
			out << " via "
			    << json::string(committer["name"])
			    << ""
			    ;

		const json::string message(commit["message"]);
		const auto summary
		{
			split(message, "\\n").first
		};

		out << " "
		    << summary
		    ;

		out << "\n";
	}

	out << "
"; return true; } bool github_handle__pull_request(std::ostream &out, const json::object &content) { const json::object pr { content["pull_request"] }; if(pr["merged"] != "true") out << " " << "" << json::string(content["action"]) << "" ; if(pr["title"]) out << " " << "" << json::string(pr["title"]) << "" << " " << ' ' ; const json::object head { pr["head"] }; const json::object base { pr["base"] }; for(const json::object label : json::array(pr["labels"])) { out << " "; out << ""; out << ""; out << " "; out << json::string(label["name"]); out << " "; out << ""; out << ""; } if(pr["merged"] == "true") out << ' ' << "" << " " << "merged" << " " << "" ; if(pr.has("merged_by") && pr["merged_by"] != "null") { const json::object merged_by{pr["merged_by"]}; out << " " << "by " << "" << json::string(merged_by["login"]) << "" ; } const json::string &body { pr["body"] }; if(!empty(body)) out << ' ' << "
"
		    << body
		    << "
" << ' ' ; else out << ' ' << "
" ; if(pr.has("commits")) out << ' ' << " " << "" << pr["commits"] << ' ' << "" << "commits" << "" << "" ; if(pr.has("comments")) out << ' ' << " " << "" << pr["comments"] << ' ' << "" << "comments" << "" << "" ; if(pr.has("changed_files")) out << ' ' << " " << "" << pr["changed_files"] << ' ' << "" << "files" << "" << "" ; if(pr.has("additions")) out << ' ' << " " << "" << "" << "++" << "" << pr["additions"] << "" ; if(pr.has("deletions")) out << ' ' << "" << "" << "--" << "" << pr["deletions"] << "" ; if(pr["merged"] == "false") switch(hash(pr["mergeable"])) { default: case "null"_: break; case "true"_: out << ' ' << "" << "" << " " << "NO CONFLICTS" << " " << "" << "" ; break; case "false"_: out << ' ' << "" << "" << " " << "MERGE CONFLICT" << " " << "" << "" ; break; } return true; } bool github_handle__issues(std::ostream &out, const json::object &content) { const json::string &action { content["action"] }; out << " " << "" << action << "" ; const json::object issue { content["issue"] }; switch(hash(action)) { case "assigned"_: case "unassigned"_: { const json::object assignee { content["assignee"] }; out << " " << "" << json::string(assignee["login"]) << "" ; break; } } out << " " << "" << "" << json::string(issue["title"]) << "" << "" ; for(const json::object label : json::array(issue["labels"])) { out << " "; out << ""; out << ""; out << " "; out << json::string(label["name"]); out << " "; out << ""; out << ""; } if(action == "opened") { out << " " << "
" << "
"
		    ;

		static const auto delim("\\r\\n");
		const json::string body(issue["body"]);
		ircd::tokens(body, delim, [&out]
		(const string_view &line)
		{
			out << line << "
"; }); out << "" << "
" << "
" ; } else if(action == "labeled") { // quiet these messages for now until we can figure out how to reduce // noise around issue opens. return false; const json::object label { content["label"] }; out << "
    "; out << "
  • added: "; out << ""; out << ""; out << " "; out << json::string(label["name"]); out << " "; out << ""; out << ""; out << "
  • "; out << "
"; } else if(action == "unlabeled") { // quiet these messages for now until we can figure out how to reduce // noise around issue opens. return false; const json::object label { content["label"] }; out << "
    "; out << "
  • removed: "; out << ""; out << ""; out << " "; out << json::string(label["name"]); out << " "; out << ""; out << ""; out << "
  • "; out << "
"; } else if(action == "milestoned") { const json::object &milestone { content["milestone"] }; out << "
    " << "
  • " << "" << json::string(milestone["title"]) << "" << ' ' ; const json::string &state{milestone["state"]}; if(state == "open") out << "" ; else if(state == "closed") out << "" ; out << " " << state << " " << "" ; out << ' ' << " " << "Issues" << ' ' << "" << "" << milestone["open_issues"] << "" << "" << ' ' << "open" << ' ' << "" << "" << milestone["closed_issues"] << "" << "" << ' ' << "closed" << "
  • " << "
" ; } return true; } bool github_handle__issue_comment(std::ostream &out, const json::object &content) { const json::object issue { content["issue"] }; const json::object comment { content["comment"] }; const json::string action { content["action"] }; out << " "; switch(hash(action)) { case "created"_: out << "commented on"; break; default: out << action; break; } out << ""; out << " " << "" << "" << json::string(issue["title"]) << "" << "" ; if(action == "created") { out << " " << "
" << "
"
		    ;

		static const auto delim("\\r\\n");
		const json::string body(comment["body"]);
		ircd::tokens(body, delim, [&out]
		(const string_view &line)
		{
			out << line << "
"; }); out << "" << "
" << "
" ; } for(const json::object label : json::array(issue["labels"])) out << "" << "" << " " << json::string(label["name"]) << " " << "" << "" << " " ; return true; } bool github_handle__commit_comment(std::ostream &out, const json::object &content) { const json::object comment { content["comment"] }; const json::string action { content["action"] }; const json::string commit { comment["commit_id"] }; const json::string assoc { comment["author_association"] }; char assoc_buf[32]; if(assoc && assoc != "NONE") out << " [" << tolower(assoc_buf, assoc) << "]" ; out << " "; switch(hash(action)) { case "created"_: out << "commented on"; break; default: out << action; break; } out << ""; out << " " << "" << "" << trunc(commit, 8) << "" << "" ; if(action == "created") { out << " " << "
" ; const json::string body { comment["body"] }; static const auto delim("\\r\\n"); ircd::tokens(body, delim, [&out] (const string_view &line) { out << line << "
"; }); out << "" << "
" ; } return true; } bool github_handle__label(std::ostream &out, const json::object &content) { const json::string &action { content["action"] }; out << " " << "" << action << "" ; const json::object &label { content["label"] }; out << "
    "; out << "
  • "; out << ""; out << ""; out << "   "; out << json::string(label["name"]); out << "   "; out << ""; out << ""; out << "
  • "; out << "
"; if(action == "edited") { const json::object &changes { content["changes"] }; const json::object &color_obj { changes["color"] }; const json::string &color { empty(color_obj)? label["color"]: color_obj["from"] }; const json::object &name_obj { changes["name"] }; const json::string &name { empty(name_obj)? label["name"]: name_obj["from"] }; out << "from: "; out << "
    "; out << "
  • "; out << ""; out << ""; out << "   "; out << name; out << "   "; out << ""; out << ""; out << "
  • "; out << "
"; } return true; } bool github_handle__organization(std::ostream &out, const json::object &content) { const json::string &action { content["action"] }; const auto &action_words { split(action, '_') }; out << " " << ""; if(action_words.second) out << split(action, '_').second << " "; out << action_words.first << ""; if(action == "member_added") { const json::object &membership { content["membership"] }; const json::object &user { membership["user"] }; out << " " << "" << json::string(user["login"]) << "" ; out << " with role " << json::string(membership["role"]) ; } else if(action == "member_removed") { const json::object &membership { content["membership"] }; const json::object &user { membership["user"] }; out << " " << "" << json::string(user["login"]) << "" ; } else if(action == "member_invited") { const json::object &invitation { content["invitation"] }; const json::object &user { invitation["user"] }; out << " " << "" << json::string(user["login"]) << "" ; } return true; } bool github_handle__status(std::ostream &out, const json::object &content) { const m::user::id::buf _webhook_user { string_view{webhook_user}, my_host() }; const auto _webhook_room_id { m::room_id(string_view(webhook_room)) }; const m::room _webhook_room { _webhook_room_id }; const json::string &state { content["state"] }; // Find the message resulting from the push and react with the status. m::event::id::buf push_event_id; { const json::string &commit_hash { content["sha"] }; m::room::events it { _webhook_room }; static const auto type_match { [](const string_view &type) noexcept { return type == "m.room.message"; } }; const auto user_match { [&_webhook_user](const string_view &sender) noexcept { return sender && sender == _webhook_user; } }; const auto content_match { [&commit_hash](const json::object &content) { const json::string &body { content["body"] }; return has(body, "push") && has(body, commit_hash); } }; // Limit the search to a maximum of recent messages from the // webhook user and total messages so we don't run out of control // and scan the whole room history. int lim[2] { 512, 32 }; for(; it && lim[0] > 0 && lim[1] > 0; --it, --lim[0]) { if(!m::query(std::nothrow, it.event_idx(), "sender", user_match)) continue; --lim[1]; if(!m::query(std::nothrow, it.event_idx(), "type", type_match)) continue; if(!m::query(std::nothrow, it.event_idx(), "content", content_match)) continue; push_event_id = m::event_id(std::nothrow, it.event_idx()); break; } } if(push_event_id) switch(hash(state)) { case "error"_: m::annotate(_webhook_room, _webhook_user, push_event_id, "⭕"); break; case "failure"_: m::annotate(_webhook_room, _webhook_user, push_event_id, "🔴"); break; case "pending"_: m::annotate(_webhook_room, _webhook_user, push_event_id, "🟡"); break; case "success"_: m::annotate(_webhook_room, _webhook_user, push_event_id, "🟢"); break; } if(!webhook_status_verbose) switch(hash(state)) { case "error"_: return false; case "failure"_: break; case "pending"_: return false; case "success"_: return false; default: return false; } const json::string &description { content["description"] }; const string_view &url { content["target_url"] }; if(state == "success") out << " " << "" ; else if(state == "failure") out << " " << "" ; else if(state == "error") out << " " << "" ; out << " " << ""; out << "" << "" << "" << description << "" << "" << "" << " " << "" ; return true; } bool github_handle__watch(std::ostream &out, const json::object &content) { const json::string &action { content["action"] }; if(action != "started") return false; // There appears to be no way to distinguish between a genuine watch // button click and just a star; the watch event is sent for both. // Returning false just disables this event so there's no double-message. return false; } bool github_handle__star(std::ostream &out, const json::object &content) { const json::string &action { content["action"] }; if(action != "created") return false; return true; } bool github_handle__repository(std::ostream &out, const json::object &content) { const json::string &action { content["action"] }; out << ' ' << action; out << "
"
	    << json::string(content["description"])
	    << "
"; return true; } bool github_handle__create(std::ostream &out, const json::object &content) { const json::string &ref { content["ref"] }; const json::string &ref_type { content["ref_type"] }; out << ' ' << ref_type << ' ' << "" << ref << "" ; if(ref_type == "tag") out << ' ' << "🎉"; return true; } bool github_handle__delete(std::ostream &out, const json::object &content) { const json::string &ref { content["ref"] }; const json::string &ref_type { content["ref_type"] }; out << ' ' << ref_type << ' ' << "" << ref << "" ; return true; } bool github_handle__ping(std::ostream &out, const json::object &content) { out << "
"
	    << json::string(content["zen"])
	    << "
"; return true; } std::pair github_find_repo(const json::object &content) { const json::object repository { content["repository"] }; if(!empty(repository)) return { repository["full_name"], repository["html_url"] }; const json::object organization { content["organization"] }; return { organization["login"], organization["url"] }; } /// Researched from yestifico bot std::pair github_find_party(const json::object &content) { const json::object pull_request { content["pull_request"] }; const json::object user { pull_request["user"] }; if(!empty(user)) return { user["login"], user["html_url"] }; const json::object sender { content["sender"] }; return { sender["login"], sender["html_url"] }; } /// Researched from yestifico bot json::string github_find_issue_number(const json::object &content) { const json::object issue(content["issue"]); if(!empty(issue)) return issue["number"]; if(content["number"]) return content["number"]; return {}; } /// Researched from yestifico bot json::string github_find_commit_hash(const json::object &content) { if(content["sha"]) return content["sha"]; const json::object commit(content["commit"]); if(!empty(commit)) return commit["sha"]; const json::object head(content["head"]); if(!empty(head)) return head["commit"]; const json::object head_commit(content["head_commit"]); if(!empty(head_commit)) return head_commit["id"]; const json::object comment(content["comment"]); if(!empty(comment)) return comment["commit_id"]; if(content["commit"]) return content["commit"]; const json::object pr{content["pull_request"]}; const json::object prhead{pr["head"]}; if(prhead["sha"]) return prhead["sha"]; return {}; } std::string github_url(const json::string &url) { std::string base("https://"); return base + std::string(lstrip(url, "https://api.")); } bool github_validate(const string_view &sigheader, const const_buffer &content, const string_view &secret) try { const auto sig { split(sigheader, "=") }; crh::hmac hmac { sig.first, secret }; hmac.update(content); char ubuf[64], abuf[sizeof(ubuf) * 2]; if(unlikely(sizeof(ubuf) < hmac.length())) throw ircd::panic { "HMAC algorithm '%s' digest exceeds buffer size.", sig.first }; const const_buffer hmac_bin { hmac.finalize(ubuf) }; static_assert(sizeof(abuf) >= sizeof(ubuf) * 2); const string_view hmac_hex { u2a(abuf, hmac_bin) }; return hmac_hex == sig.second; } catch(const crh::error &e) { throw http::error { http::NOT_IMPLEMENTED, "The signature algorithm is not supported.", }; } /////////////////////////////////////////////////////////////////////////////// // // appveyor // void appveyor_handle(client &client, const resource::request &request) { const http::headers &headers { request.head.headers }; } /////////////////////////////////////////////////////////////////////////////// // // dockerhub // static void dockerhub_handle_push(std::ostream &out, std::ostream &alt, client &client, const resource::request &request); void dockerhub_handle(client &client, const resource::request &request) try { if(!string_view(webhook_room)) return; if(!string_view(webhook_user)) return; const auto callback_url { request["callback_url"] }; const unique_buffer buf[2] { { 48_KiB }, { 4_KiB }, }; std::stringstream out, alt; pubsetbuf(out, buf[0]); pubsetbuf(alt, buf[1]); if(request.has("push_data")) dockerhub_handle_push(out, alt, client, request); const auto room_id { m::room_id(string_view(webhook_room)) }; const m::user::id::buf user_id { string_view(webhook_user), my_host() }; const auto msg { view(out, buf[0]) }; const auto alt_msg { view(alt, buf[1]) }; const auto evid { m::msghtml(room_id, user_id, msg, alt_msg, "m.notice") }; log::info { "Webhook '%s' delivered to %s %s", "push"_sv, string_view{room_id}, string_view{evid}, }; } catch(const std::exception &e) { log::error { "dockerhub webhook :%s", e.what(), }; throw; } void dockerhub_handle_push(std::ostream &out, std::ostream &alt, client &client, const resource::request &request) { const json::object push_data { request["push_data"] }; const json::object repository { request["repository"] }; const json::string pusher { push_data["pusher"] }; const string_view pushed_at { push_data["pushed_at"] }; const json::string tag { push_data["tag"] }; const json::array images { push_data["images"] }; out << "" << json::string(repository["repo_name"]) << " push by " << pusher << " to " << tag << "" ; alt << json::string(repository["repo_name"]) << " push by " << pusher << " to " << tag ; }