From e12aec4ccdef7752fb87bd857a48eb4587016573 Mon Sep 17 00:00:00 2001 From: rubenwardy Date: Fri, 24 Jan 2020 23:19:06 +0000 Subject: [PATCH] Add automatic GitHub webhook creation --- app/blueprints/github/__init__.py | 106 ++++++++++++++++++++++-- app/models.py | 28 +++++++ app/templates/base.html | 2 +- app/templates/github/setup_webhook.html | 23 +++++ app/templates/packages/view.html | 9 ++ migrations/versions/7a48dbd05780_.py | 24 ++++++ 6 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 app/templates/github/setup_webhook.html create mode 100644 migrations/versions/7a48dbd05780_.py diff --git a/app/blueprints/github/__init__.py b/app/blueprints/github/__init__.py index 6bf63a7..d05dffc 100644 --- a/app/blueprints/github/__init__.py +++ b/app/blueprints/github/__init__.py @@ -18,19 +18,22 @@ from flask import Blueprint bp = Blueprint("github", __name__) -from flask import redirect, url_for, request, flash, abort -from flask_user import current_user +from flask import redirect, url_for, request, flash, abort, render_template, jsonify +from flask_user import current_user, login_required from sqlalchemy import func from flask_github import GitHub from app import github, csrf from app.models import db, User, APIToken, Package -from app.utils import loginUser +from app.utils import loginUser, randomString from app.blueprints.api.support import error, handleCreateRelease -import hmac +import hmac, requests, json + +from flask_wtf import FlaskForm +from wtforms import SelectField, SubmitField @bp.route("/github/start/") def start(): - return github.authorize("") + return github.authorize("", redirect_uri=url_for("github.callback")) @bp.route("/github/callback/") @github.authorized_handler @@ -40,8 +43,6 @@ def callback(oauth_token): flash("Authorization failed [err=gh-oauth-login-failed]", "danger") return redirect(url_for("user.login")) - import requests - # Get Github username url = "https://api.github.com/user" r = requests.get(url, headers={"Authorization": "token " + oauth_token}) @@ -121,11 +122,100 @@ def webhook(): if event == "push": title = json["head_commit"]["message"].partition("\n")[0] ref = json["after"] + elif event == "ping": + return jsonify({ "success": True, "message": "Ping successful" }) else: - return error(400, "Unknown event, expected 'push'") + return error(400, "Unsupported event. Only 'push' and 'ping' are supported.") # # Perform release # return handleCreateRelease(actual_token, package, title, ref) + + +class SetupWebhookForm(FlaskForm): + event = SelectField("Event Type", choices=[('push', 'Push'), ('tag', 'New tag')]) + submit = SubmitField("Save") + + +@bp.route("/github/callback/webhook/") +@github.authorized_handler +def callback_webhook(oauth_token=None): + pid = request.args.get("pid") + if pid is None: + abort(404) + + current_user.github_access_token = oauth_token + db.session.commit() + + return redirect(url_for("github.setup_webhook", pid=pid)) + + +@bp.route("/github/webhook/new/", methods=["GET", "POST"]) +@login_required +def setup_webhook(): + pid = request.args.get("pid") + if pid is None: + abort(404) + + package = Package.query.get(pid) + if package is None: + abort(404) + + gh_user, gh_repo = package.getGitHubFullName() + if gh_user is None or gh_repo is None: + flash("Unable to get Github full name from repo address", "danger") + return redirect(package.getDetailsURL()) + + if current_user.github_access_token is None: + return github.authorize("write:repo_hook", \ + redirect_uri=url_for("github.callback_webhook", pid=pid, _external=True)) + + form = SetupWebhookForm(formdata=request.form) + if request.method == "POST" and form.validate(): + token = APIToken() + token.name = "Github Webhook for " + package.title + token.owner = current_user + token.access_token = randomString(32) + token.package = package + + event = form.event.data + if event != "push" and event != "tag": + abort(500) + + # Create webhook + url = "https://api.github.com/repos/{}/{}/hooks".format(gh_user, gh_repo) + data = { + "name": "web", + "active": True, + "events": [event], + "config": { + "url": url_for("github.webhook", _external=True), + "content_type": "json", + "secret": token.access_token + }, + } + + headers = { + "Authorization": "token " + current_user.github_access_token + } + + r = requests.post(url, headers=headers, data=json.dumps(data)) + if r.status_code == 201: + db.session.add(token) + db.session.commit() + + return redirect(package.getDetailsURL()) + elif r.status_code == 403: + current_user.github_access_token = None + db.session.commit() + + return github.authorize("write:repo_hook", \ + redirect_uri=url_for("github.callback_webhook", pid=pid, _external=True)) + else: + flash("Failed to create webhook, received response from Github: " + + str(r.json().get("message") or r.status_code), "danger") + + return render_template("github/setup_webhook.html", \ + form=form, package=package) diff --git a/app/models.py b/app/models.py index 5eff2dd..86136f2 100644 --- a/app/models.py +++ b/app/models.py @@ -126,6 +126,9 @@ class User(db.Model, UserMixin): github_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True) forums_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True) + # Access token for webhook setup + github_access_token = db.Column(db.String(50), nullable=True, server_default=None) + # User email information email = db.Column(db.String(255), nullable=True, unique=True) email_confirmed_at = db.Column(db.DateTime()) @@ -461,6 +464,31 @@ class Package(db.Model): def getIsFOSS(self): return self.license.is_foss and self.media_license.is_foss + def getIsOnGitHub(self): + if self.repo is None: + return False + + url = urlparse(self.repo) + return url.netloc == "github.com" + + def getGitHubFullName(self): + if self.repo is None: + return None + + url = urlparse(self.repo) + if url.netloc != "github.com": + return None + + import re + m = re.search(r"^\/([^\/]+)\/([^\/]+)\/?$", url.path) + if m is None: + return + + user = m.group(1) + repo = m.group(2).replace(".git", "") + + return (user,repo) + def getSortedDependencies(self, is_hard=None): query = self.dependencies if is_hard is not None: diff --git a/app/templates/base.html b/app/templates/base.html index cfe2dfc..4db84d7 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -111,7 +111,7 @@
  • - {{ message|safe }} + {{ message }}
  • diff --git a/app/templates/github/setup_webhook.html b/app/templates/github/setup_webhook.html new file mode 100644 index 0000000..d0012e2 --- /dev/null +++ b/app/templates/github/setup_webhook.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block title %} + {{ _("Setup GitHub webhook") }} +{% endblock %} + +{% from "macros/forms.html" import render_field, render_submit_field, render_radio_field %} + +{% block content %} +

    {{ self.title() }}

    + +
    + {{ _("You can delete the webhook at any time by going into Settings > Webhooks on the repository.") }} +
    + +
    + {{ form.hidden_tag() }} + + {{ render_field(form.event) }} + + {{ render_submit_field(form.submit) }} +
    +{% endblock %} diff --git a/app/templates/packages/view.html b/app/templates/packages/view.html index 77b84da..e5ab1e4 100644 --- a/app/templates/packages/view.html +++ b/app/templates/packages/view.html @@ -364,6 +364,15 @@ + {% if package.getIsOnGitHub() %} +

    + + Set up a webhook + + to create releases automatically. +

    + {% endif %} +
    {% if package.approved and package.checkPerm(current_user, "CREATE_THREAD") %} diff --git a/migrations/versions/7a48dbd05780_.py b/migrations/versions/7a48dbd05780_.py new file mode 100644 index 0000000..8f3b96e --- /dev/null +++ b/migrations/versions/7a48dbd05780_.py @@ -0,0 +1,24 @@ +"""empty message + +Revision ID: 7a48dbd05780 +Revises: df66c78e6791 +Create Date: 2020-01-24 21:52:49.744404 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '7a48dbd05780' +down_revision = 'df66c78e6791' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('user', sa.Column('github_access_token', sa.String(length=50), nullable=True, server_default=None)) + + +def downgrade(): + op.drop_column('user', 'github_access_token')