Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53a5dffb26 | ||
|
|
74f3a77a84 | ||
|
|
a15f1ac223 | ||
|
|
19a626e237 | ||
|
|
43c2ee6b7b | ||
|
|
b1555bfcd5 | ||
|
|
d5541791b6 | ||
|
|
62b1cae0ab | ||
|
|
933a23c9c7 | ||
|
|
f2799349ab | ||
|
|
1d223cc16f | ||
|
|
b7101a403b | ||
|
|
493917d8b1 | ||
|
|
e12aec4ccd | ||
|
|
d4936e18ee | ||
|
|
beb9c0e959 | ||
|
|
14faae3fd1 | ||
|
|
6f1472addb | ||
|
|
2fa2c3afec | ||
|
|
6e938ba74c | ||
|
|
53a63367dc | ||
|
|
ddf5c7f665 | ||
|
|
4e331c7f14 |
@@ -21,7 +21,7 @@ from flask_gravatar import Gravatar
|
||||
import flask_menu as menu
|
||||
from flask_mail import Mail
|
||||
from flask_github import GitHub
|
||||
from flask_wtf.csrf import CsrfProtect
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from flask_flatpages import FlatPages
|
||||
from flask_babel import Babel
|
||||
import os, redis
|
||||
@@ -35,7 +35,7 @@ r = redis.Redis.from_url(app.config["REDIS_URL"])
|
||||
|
||||
menu.Menu(app=app)
|
||||
github = GitHub(app)
|
||||
csrf = CsrfProtect(app)
|
||||
csrf = CSRFProtect(app)
|
||||
mail = Mail(app)
|
||||
pages = FlatPages(app)
|
||||
babel = Babel(app)
|
||||
@@ -87,7 +87,7 @@ def flatpage(path):
|
||||
def check_for_ban():
|
||||
if current_user.is_authenticated:
|
||||
if current_user.rank == models.UserRank.BANNED:
|
||||
flash("You have been banned.", "error")
|
||||
flash("You have been banned.", "danger")
|
||||
logout_user()
|
||||
return redirect(url_for('user.login'))
|
||||
elif current_user.rank == models.UserRank.NOT_JOINED:
|
||||
|
||||
@@ -71,7 +71,7 @@ def admin_page():
|
||||
elif action == "restore":
|
||||
package = Package.query.get(request.form["package"])
|
||||
if package is None:
|
||||
flash("Unknown package", "error")
|
||||
flash("Unknown package", "danger")
|
||||
else:
|
||||
package.soft_deleted = False
|
||||
db.session.commit()
|
||||
@@ -115,7 +115,7 @@ def admin_page():
|
||||
db.session.commit()
|
||||
|
||||
else:
|
||||
flash("Unknown action: " + action, "error")
|
||||
flash("Unknown action: " + action, "danger")
|
||||
|
||||
deleted_packages = Package.query.filter_by(soft_deleted=True).all()
|
||||
return render_template("admin/list.html", deleted_packages=deleted_packages)
|
||||
@@ -132,11 +132,11 @@ def switch_user():
|
||||
if request.method == "POST" and form.validate():
|
||||
user = User.query.filter_by(username=form["username"].data).first()
|
||||
if user is None:
|
||||
flash("Unable to find user", "error")
|
||||
flash("Unable to find user", "danger")
|
||||
elif loginUser(user):
|
||||
return redirect(url_for("users.profile", username=current_user.username))
|
||||
else:
|
||||
flash("Unable to login as user", "error")
|
||||
flash("Unable to login as user", "danger")
|
||||
|
||||
|
||||
# Process GET or invalid POST
|
||||
|
||||
@@ -19,6 +19,7 @@ from flask import *
|
||||
from flask_user import *
|
||||
from . import bp
|
||||
from .auth import is_api_authd
|
||||
from .support import error, handleCreateRelease
|
||||
from app import csrf
|
||||
from app.models import *
|
||||
from app.utils import is_package_page
|
||||
@@ -71,6 +72,13 @@ def package_dependencies(package):
|
||||
return jsonify(ret)
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/releases/")
|
||||
@is_package_page
|
||||
def list_releases(package):
|
||||
releases = package.releases.filter_by(approved=True).all()
|
||||
return jsonify([ rel.getAsDictionary() for rel in releases ])
|
||||
|
||||
|
||||
@bp.route("/api/topics/")
|
||||
def topics():
|
||||
qb = QueryBuilder(request.args)
|
||||
@@ -113,5 +121,25 @@ def whoami(token):
|
||||
|
||||
@bp.route("/api/markdown/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
def clean_markdown():
|
||||
def markdown():
|
||||
return render_markdown(request.data.decode("utf-8"))
|
||||
|
||||
|
||||
@bp.route("/api/packages/<author>/<name>/releases/new/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
@is_package_page
|
||||
@is_api_authd
|
||||
def create_release(token, package):
|
||||
json = request.json
|
||||
if json is None:
|
||||
return error(400, "JSON post data is required")
|
||||
|
||||
for option in ["method", "title", "ref"]:
|
||||
if json.get(option) is None:
|
||||
return error(400, option + " is required in the POST data")
|
||||
|
||||
|
||||
if json["method"].lower() != "git":
|
||||
return error(400, "Release-creation methods other than git are not supported")
|
||||
|
||||
return handleCreateRelease(token, package, json["title"], json["ref"])
|
||||
|
||||
40
app/blueprints/api/support.py
Normal file
40
app/blueprints/api/support.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from app.models import PackageRelease, db, Permission
|
||||
from app.tasks.importtasks import makeVCSRelease
|
||||
from celery import uuid
|
||||
from flask import jsonify, make_response, url_for
|
||||
import datetime
|
||||
|
||||
|
||||
def error(status, message):
|
||||
return make_response(jsonify({ "success": False, "error": message }), status)
|
||||
|
||||
|
||||
def handleCreateRelease(token, package, title, ref):
|
||||
if not token.canOperateOnPackage(package):
|
||||
return error(403, "API token does not have access to the package")
|
||||
|
||||
if not package.checkPerm(token.owner, Permission.MAKE_RELEASE):
|
||||
return error(403, "Permission denied. Missing MAKE_RELEASE permission")
|
||||
|
||||
five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5)
|
||||
count = package.releases.filter(PackageRelease.releaseDate > five_minutes_ago).count()
|
||||
if count >= 2:
|
||||
return error(429, "Too many requests, please wait before trying again")
|
||||
|
||||
rel = PackageRelease()
|
||||
rel.package = package
|
||||
rel.title = title
|
||||
rel.url = ""
|
||||
rel.task_id = uuid()
|
||||
rel.min_rel = None
|
||||
rel.max_rel = None
|
||||
db.session.add(rel)
|
||||
db.session.commit()
|
||||
|
||||
makeVCSRelease.apply_async((rel.id, ref), task_id=rel.task_id)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"task": url_for("tasks.check", id=rel.task_id),
|
||||
"release": rel.getAsDictionary()
|
||||
})
|
||||
@@ -29,6 +29,8 @@ from wtforms.ext.sqlalchemy.fields import QuerySelectField
|
||||
|
||||
class CreateAPIToken(FlaskForm):
|
||||
name = StringField("Name", [InputRequired(), Length(1, 30)])
|
||||
package = QuerySelectField("Limit to package", allow_blank=True, \
|
||||
get_pk=lambda a: a.id, get_label=lambda a: a.title)
|
||||
submit = SubmitField("Save")
|
||||
|
||||
|
||||
@@ -70,6 +72,8 @@ def create_edit_token(username, id=None):
|
||||
access_token = session.pop("token_" + str(id), None)
|
||||
|
||||
form = CreateAPIToken(formdata=request.form, obj=token)
|
||||
form.package.query_factory = lambda: Package.query.filter_by(author=user).all()
|
||||
|
||||
if request.method == "POST" and form.validate():
|
||||
if is_new:
|
||||
token = APIToken()
|
||||
|
||||
264
app/blueprints/github/__init__.py
Normal file
264
app/blueprints/github/__init__.py
Normal file
@@ -0,0 +1,264 @@
|
||||
# Content DB
|
||||
# Copyright (C) 2018 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU 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 General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
bp = Blueprint("github", __name__)
|
||||
|
||||
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, Permission
|
||||
from app.utils import loginUser, randomString, abs_url_for
|
||||
from app.blueprints.api.support import error, handleCreateRelease
|
||||
import hmac, requests, json
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import SelectField, SubmitField
|
||||
|
||||
@bp.route("/github/start/")
|
||||
def start():
|
||||
return github.authorize("", redirect_uri=abs_url_for("github.callback"))
|
||||
|
||||
@bp.route("/github/callback/")
|
||||
@github.authorized_handler
|
||||
def callback(oauth_token):
|
||||
next_url = request.args.get("next")
|
||||
if oauth_token is None:
|
||||
flash("Authorization failed [err=gh-oauth-login-failed]", "danger")
|
||||
return redirect(url_for("user.login"))
|
||||
|
||||
# Get Github username
|
||||
url = "https://api.github.com/user"
|
||||
r = requests.get(url, headers={"Authorization": "token " + oauth_token})
|
||||
username = r.json()["login"]
|
||||
|
||||
# Get user by github username
|
||||
userByGithub = User.query.filter(func.lower(User.github_username) == func.lower(username)).first()
|
||||
|
||||
# If logged in, connect
|
||||
if current_user and current_user.is_authenticated:
|
||||
if userByGithub is None:
|
||||
current_user.github_username = username
|
||||
db.session.commit()
|
||||
flash("Linked github to account", "success")
|
||||
return redirect(url_for("homepage.home"))
|
||||
else:
|
||||
flash("Github account is already associated with another user", "danger")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
# If not logged in, log in
|
||||
else:
|
||||
if userByGithub is None:
|
||||
flash("Unable to find an account for that Github user", "danger")
|
||||
return redirect(url_for("users.claim"))
|
||||
elif loginUser(userByGithub):
|
||||
if not current_user.hasPassword():
|
||||
return redirect(next_url or url_for("users.set_password", optional=True))
|
||||
else:
|
||||
return redirect(next_url or url_for("homepage.home"))
|
||||
else:
|
||||
flash("Authorization failed [err=gh-login-failed]", "danger")
|
||||
return redirect(url_for("user.login"))
|
||||
|
||||
|
||||
@bp.route("/github/webhook/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
def webhook():
|
||||
json = request.json
|
||||
|
||||
# Get package
|
||||
github_url = "github.com/" + json["repository"]["full_name"]
|
||||
package = Package.query.filter(Package.repo.like("%{}%".format(github_url))).first()
|
||||
if package is None:
|
||||
return error(400, "Unknown package")
|
||||
|
||||
# Get all tokens for package
|
||||
possible_tokens = APIToken.query.filter_by(package=package).all()
|
||||
actual_token = None
|
||||
|
||||
#
|
||||
# Check signature
|
||||
#
|
||||
|
||||
header_signature = request.headers.get('X-Hub-Signature')
|
||||
if header_signature is None:
|
||||
return error(403, "Expected payload signature")
|
||||
|
||||
sha_name, signature = header_signature.split('=')
|
||||
if sha_name != 'sha1':
|
||||
return error(403, "Expected SHA1 payload signature")
|
||||
|
||||
for token in possible_tokens:
|
||||
mac = hmac.new(token.access_token.encode("utf-8"), msg=request.data, digestmod='sha1')
|
||||
|
||||
if hmac.compare_digest(str(mac.hexdigest()), signature):
|
||||
actual_token = token
|
||||
break
|
||||
|
||||
if actual_token is None:
|
||||
return error(403, "Invalid authentication")
|
||||
|
||||
if not package.checkPerm(actual_token.owner, Permission.APPROVE_RELEASE):
|
||||
return error(403, "Only trusted members can use webhooks")
|
||||
|
||||
#
|
||||
# Check event
|
||||
#
|
||||
|
||||
event = request.headers.get("X-GitHub-Event")
|
||||
if event == "push":
|
||||
ref = json["after"]
|
||||
title = json["head_commit"]["message"].partition("\n")[0]
|
||||
elif event == "create" and json["ref_type"] == "tag":
|
||||
ref = json["ref"]
|
||||
title = ref
|
||||
elif event == "ping":
|
||||
return jsonify({ "success": True, "message": "Ping successful" })
|
||||
else:
|
||||
return error(400, "Unsupported event. Only 'push', `create:tag`, and 'ping' are supported.")
|
||||
|
||||
#
|
||||
# Perform release
|
||||
#
|
||||
|
||||
return handleCreateRelease(actual_token, package, title, ref)
|
||||
|
||||
|
||||
class SetupWebhookForm(FlaskForm):
|
||||
event = SelectField("Event Type", choices=[('create', 'New tag or GitHub release'), ('push', 'Push')])
|
||||
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)
|
||||
|
||||
if not package.checkPerm(current_user, Permission.APPROVE_RELEASE):
|
||||
flash("Only trusted members can use webhooks", "danger")
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
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=abs_url_for("github.callback_webhook", pid=pid))
|
||||
|
||||
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 != "create":
|
||||
abort(500)
|
||||
|
||||
if handleMakeWebhook(gh_user, gh_repo, package, \
|
||||
current_user.github_access_token, event, token):
|
||||
flash("Successfully created webhook", "success")
|
||||
return redirect(package.getDetailsURL())
|
||||
else:
|
||||
return redirect(url_for("github.setup_webhook", pid=package.id))
|
||||
|
||||
return render_template("github/setup_webhook.html", \
|
||||
form=form, package=package)
|
||||
|
||||
|
||||
def handleMakeWebhook(gh_user, gh_repo, package, oauth, event, token):
|
||||
url = "https://api.github.com/repos/{}/{}/hooks".format(gh_user, gh_repo)
|
||||
headers = {
|
||||
"Authorization": "token " + oauth
|
||||
}
|
||||
data = {
|
||||
"name": "web",
|
||||
"active": True,
|
||||
"events": [event],
|
||||
"config": {
|
||||
"url": abs_url_for("github.webhook"),
|
||||
"content_type": "json",
|
||||
"secret": token.access_token
|
||||
},
|
||||
}
|
||||
|
||||
# First check that the webhook doesn't already exist
|
||||
r = requests.get(url, headers=headers)
|
||||
|
||||
if r.status_code == 401 or r.status_code == 403:
|
||||
current_user.github_access_token = None
|
||||
db.session.commit()
|
||||
return False
|
||||
|
||||
if r.status_code != 200:
|
||||
flash("Failed to create webhook, received response from Github " +
|
||||
str(r.status_code) + ": " +
|
||||
str(r.json().get("message")), "danger")
|
||||
return False
|
||||
|
||||
for hook in r.json():
|
||||
if hook.get("config") and hook["config"].get("url") and \
|
||||
hook["config"]["url"] == data["config"]["url"]:
|
||||
flash("Failed to create webhook, as it already exists", "danger")
|
||||
return False
|
||||
|
||||
|
||||
# Create it
|
||||
r = requests.post(url, headers=headers, data=json.dumps(data))
|
||||
|
||||
if r.status_code == 201:
|
||||
db.session.add(token)
|
||||
db.session.commit()
|
||||
|
||||
return True
|
||||
|
||||
elif r.status_code == 401 or r.status_code == 403:
|
||||
current_user.github_access_token = None
|
||||
db.session.commit()
|
||||
|
||||
return False
|
||||
|
||||
else:
|
||||
flash("Failed to create webhook, received response from Github " +
|
||||
str(r.status_code) + ": " +
|
||||
str(r.json().get("message")), "danger")
|
||||
return False
|
||||
67
app/blueprints/gitlab/__init__.py
Normal file
67
app/blueprints/gitlab/__init__.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# ContentDB
|
||||
# Copyright (C) 2020 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU 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 General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
bp = Blueprint("gitlab", __name__)
|
||||
|
||||
from app import csrf
|
||||
from app.models import Package, APIToken, Permission
|
||||
from app.blueprints.api.support import error, handleCreateRelease
|
||||
|
||||
|
||||
@bp.route("/gitlab/webhook/", methods=["POST"])
|
||||
@csrf.exempt
|
||||
def webhook():
|
||||
json = request.json
|
||||
|
||||
# Get package
|
||||
gitlab_url = "gitlab.com/{}/{}".format(json["project"]["namespace"], json["project"]["name"])
|
||||
package = Package.query.filter(Package.repo.like("%{}%".format(gitlab_url))).first()
|
||||
if package is None:
|
||||
return error(400, "Unknown package")
|
||||
|
||||
# Get all tokens for package
|
||||
secret = request.headers.get("X-Gitlab-Token")
|
||||
if secret is None:
|
||||
return error(403, "Token required")
|
||||
|
||||
token = APIToken.query.filter_by(access_token=secret).first()
|
||||
if secret is None:
|
||||
return error(403, "Invalid authentication")
|
||||
|
||||
if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
|
||||
return error(403, "Only trusted members can use webhooks")
|
||||
|
||||
#
|
||||
# Check event
|
||||
#
|
||||
|
||||
event = json["event_name"]
|
||||
if event == "push":
|
||||
ref = json["after"]
|
||||
title = ref[:5]
|
||||
elif event == "tag_push":
|
||||
ref = json["ref"]
|
||||
title = ref.replace("refs/tags/", "")
|
||||
else:
|
||||
return error(400, "Unsupported event. Only 'push' and 'tag_push' are supported.")
|
||||
|
||||
#
|
||||
# Perform release
|
||||
#
|
||||
|
||||
return handleCreateRelease(token, package, title, ref)
|
||||
@@ -50,7 +50,7 @@ def create_edit_editrequest_page(package, id=None):
|
||||
abort(403)
|
||||
|
||||
if erequest.status != 0:
|
||||
flash("Can't edit EditRequest, it has already been merged or rejected", "error")
|
||||
flash("Can't edit EditRequest, it has already been merged or rejected", "danger")
|
||||
return redirect(erequest.getURL())
|
||||
|
||||
edited_package = Package(package)
|
||||
@@ -127,7 +127,7 @@ def view_editrequest_page(package, id):
|
||||
@is_package_page
|
||||
def approve_editrequest_page(package, id):
|
||||
if not package.checkPerm(current_user, Permission.APPROVE_CHANGES):
|
||||
flash("You don't have permission to do that.", "error")
|
||||
flash("You don't have permission to do that.", "danger")
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
erequest = EditRequest.query.get(id)
|
||||
@@ -135,7 +135,7 @@ def approve_editrequest_page(package, id):
|
||||
abort(404)
|
||||
|
||||
if erequest.status != 0:
|
||||
flash("Edit request has already been resolved", "error")
|
||||
flash("Edit request has already been resolved", "danger")
|
||||
|
||||
else:
|
||||
erequest.status = 1
|
||||
@@ -152,7 +152,7 @@ def approve_editrequest_page(package, id):
|
||||
@is_package_page
|
||||
def reject_editrequest_page(package, id):
|
||||
if not package.checkPerm(current_user, Permission.APPROVE_CHANGES):
|
||||
flash("You don't have permission to do that.", "error")
|
||||
flash("You don't have permission to do that.", "danger")
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
erequest = EditRequest.query.get(id)
|
||||
@@ -160,7 +160,7 @@ def reject_editrequest_page(package, id):
|
||||
abort(404)
|
||||
|
||||
if erequest.status != 0:
|
||||
flash("Edit request has already been resolved", "error")
|
||||
flash("Edit request has already been resolved", "danger")
|
||||
|
||||
else:
|
||||
erequest.status = 2
|
||||
|
||||
@@ -66,6 +66,7 @@ def list_all():
|
||||
|
||||
topics = None
|
||||
if qb.search and not query.has_next:
|
||||
qb.show_discarded = True
|
||||
topics = qb.buildTopicQuery().all()
|
||||
|
||||
tags = Tag.query.all()
|
||||
@@ -160,7 +161,7 @@ def download(package):
|
||||
not "text/html" in request.accept_mimetypes:
|
||||
return "", 204
|
||||
else:
|
||||
flash("No download available.", "error")
|
||||
flash("No download available.", "danger")
|
||||
return redirect(package.getDetailsURL())
|
||||
else:
|
||||
PackageRelease.query.filter_by(id=release.id).update({
|
||||
@@ -172,7 +173,7 @@ def download(package):
|
||||
|
||||
|
||||
class PackageForm(FlaskForm):
|
||||
name = StringField("Name (Technical)", [InputRequired(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||
name = StringField("Name (Technical)", [InputRequired(), Length(1, 20), Regexp("^[a-z0-9_]+$", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
|
||||
title = StringField("Title (Human-readable)", [InputRequired(), Length(3, 50)])
|
||||
short_desc = StringField("Short Description (Plaintext)", [InputRequired(), Length(1,200)])
|
||||
desc = TextAreaField("Long Description (Markdown)", [Optional(), Length(0,10000)])
|
||||
@@ -203,11 +204,11 @@ def create_edit(author=None, name=None):
|
||||
else:
|
||||
author = User.query.filter_by(username=author).first()
|
||||
if author is None:
|
||||
flash("Unable to find that user", "error")
|
||||
flash("Unable to find that user", "danger")
|
||||
return redirect(url_for("packages.create_edit"))
|
||||
|
||||
if not author.checkPerm(current_user, Permission.CHANGE_AUTHOR):
|
||||
flash("Permission denied", "error")
|
||||
flash("Permission denied", "danger")
|
||||
return redirect(url_for("packages.create_edit"))
|
||||
|
||||
else:
|
||||
@@ -239,7 +240,7 @@ def create_edit(author=None, name=None):
|
||||
if package.soft_deleted:
|
||||
Package.query.filter_by(name=form["name"].data, author_id=author.id).delete()
|
||||
else:
|
||||
flash("Package already exists!", "error")
|
||||
flash("Package already exists!", "danger")
|
||||
return redirect(url_for("packages.create_edit"))
|
||||
|
||||
package = Package()
|
||||
@@ -312,10 +313,10 @@ def create_edit(author=None, name=None):
|
||||
@is_package_page
|
||||
def approve(package):
|
||||
if not package.checkPerm(current_user, Permission.APPROVE_NEW):
|
||||
flash("You don't have permission to do that.", "error")
|
||||
flash("You don't have permission to do that.", "danger")
|
||||
|
||||
elif package.approved:
|
||||
flash("Package has already been approved", "error")
|
||||
flash("Package has already been approved", "danger")
|
||||
|
||||
else:
|
||||
package.approved = True
|
||||
@@ -340,7 +341,7 @@ def remove(package):
|
||||
|
||||
if "delete" in request.form:
|
||||
if not package.checkPerm(current_user, Permission.DELETE_PACKAGE):
|
||||
flash("You don't have permission to do that.", "error")
|
||||
flash("You don't have permission to do that.", "danger")
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
package.soft_deleted = True
|
||||
@@ -355,7 +356,7 @@ def remove(package):
|
||||
return redirect(url)
|
||||
elif "unapprove" in request.form:
|
||||
if not package.checkPerm(current_user, Permission.UNAPPROVE_PACKAGE):
|
||||
flash("You don't have permission to do that.", "error")
|
||||
flash("You don't have permission to do that.", "danger")
|
||||
return redirect(package.getDetailsURL())
|
||||
|
||||
package.approved = False
|
||||
|
||||
@@ -142,7 +142,7 @@ def new():
|
||||
if "pid" in request.args:
|
||||
package = Package.query.get(int(request.args.get("pid")))
|
||||
if package is None:
|
||||
flash("Unable to find that package!", "error")
|
||||
flash("Unable to find that package!", "danger")
|
||||
|
||||
# Don't allow making orphan threads on approved packages for now
|
||||
if package is None:
|
||||
@@ -156,12 +156,12 @@ def new():
|
||||
|
||||
# Check that user can make the thread
|
||||
if not package.checkPerm(current_user, Permission.CREATE_THREAD):
|
||||
flash("Unable to create thread!", "error")
|
||||
flash("Unable to create thread!", "danger")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
# Only allow creating one thread when not approved
|
||||
elif is_review_thread and package.review_thread is not None:
|
||||
flash("A review thread already exists!", "error")
|
||||
flash("A review thread already exists!", "danger")
|
||||
return redirect(url_for("threads.view", id=package.review_thread.id))
|
||||
|
||||
elif not current_user.canOpenThreadRL():
|
||||
|
||||
@@ -2,4 +2,4 @@ from flask import Blueprint
|
||||
|
||||
bp = Blueprint("users", __name__)
|
||||
|
||||
from . import githublogin, profile
|
||||
from . import profile, claim
|
||||
|
||||
98
app/blueprints/users/claim.py
Normal file
98
app/blueprints/users/claim.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# Content DB
|
||||
# Copyright (C) 2018 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU 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 General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from . import bp
|
||||
from flask import redirect, render_template, session, request, flash, url_for
|
||||
from flask_user import current_user
|
||||
from app.models import db, User, UserRank
|
||||
from app.utils import randomString, loginUser, rank_required
|
||||
from app.tasks.forumtasks import checkForumAccount
|
||||
from app.tasks.phpbbparser import getProfile
|
||||
|
||||
@bp.route("/user/claim/", methods=["GET", "POST"])
|
||||
def claim():
|
||||
username = request.args.get("username")
|
||||
if username is None:
|
||||
username = ""
|
||||
else:
|
||||
method = request.args.get("method")
|
||||
user = User.query.filter_by(forums_username=username).first()
|
||||
if user and user.rank.atLeast(UserRank.NEW_MEMBER):
|
||||
flash("User has already been claimed", "danger")
|
||||
return redirect(url_for("users.claim"))
|
||||
elif user is None and method == "github":
|
||||
flash("Unable to get Github username for user", "danger")
|
||||
return redirect(url_for("users.claim"))
|
||||
elif user is None:
|
||||
flash("Unable to find that user", "danger")
|
||||
return redirect(url_for("users.claim"))
|
||||
|
||||
if user is not None and method == "github":
|
||||
return redirect(url_for("github.start"))
|
||||
|
||||
token = None
|
||||
if "forum_token" in session:
|
||||
token = session["forum_token"]
|
||||
else:
|
||||
token = randomString(32)
|
||||
session["forum_token"] = token
|
||||
|
||||
if request.method == "POST":
|
||||
ctype = request.form.get("claim_type")
|
||||
username = request.form.get("username")
|
||||
|
||||
if username is None or len(username.strip()) < 2:
|
||||
flash("Invalid username", "danger")
|
||||
elif ctype == "github":
|
||||
task = checkForumAccount.delay(username)
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.claim", username=username, method="github")))
|
||||
elif ctype == "forum":
|
||||
user = User.query.filter_by(forums_username=username).first()
|
||||
if user is not None and user.rank.atLeast(UserRank.NEW_MEMBER):
|
||||
flash("That user has already been claimed!", "danger")
|
||||
return redirect(url_for("users.claim"))
|
||||
|
||||
# Get signature
|
||||
sig = None
|
||||
try:
|
||||
profile = getProfile("https://forum.minetest.net", username)
|
||||
sig = profile.signature
|
||||
except IOError:
|
||||
flash("Unable to get forum signature - does the user exist?", "danger")
|
||||
return redirect(url_for("users.claim", username=username))
|
||||
|
||||
# Look for key
|
||||
if token in sig:
|
||||
if user is None:
|
||||
user = User(username)
|
||||
user.forums_username = username
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
if loginUser(user):
|
||||
return redirect(url_for("users.set_password"))
|
||||
else:
|
||||
flash("Unable to login as user", "danger")
|
||||
return redirect(url_for("users.claim", username=username))
|
||||
|
||||
else:
|
||||
flash("Could not find the key in your signature!", "danger")
|
||||
return redirect(url_for("users.claim", username=username))
|
||||
else:
|
||||
flash("Unknown claim type", "danger")
|
||||
|
||||
return render_template("users/claim.html", username=username, key=token)
|
||||
@@ -1,74 +0,0 @@
|
||||
# Content DB
|
||||
# Copyright (C) 2018 rubenwardy
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU 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 General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask import *
|
||||
from flask_user import *
|
||||
from flask_login import login_user, logout_user
|
||||
from sqlalchemy import func
|
||||
import flask_menu as menu
|
||||
from flask_github import GitHub
|
||||
from . import bp
|
||||
from app import github
|
||||
from app.models import *
|
||||
from app.utils import loginUser
|
||||
|
||||
@bp.route("/user/github/start/")
|
||||
def github_signin():
|
||||
return github.authorize("")
|
||||
|
||||
@bp.route("/user/github/callback/")
|
||||
@github.authorized_handler
|
||||
def github_authorized(oauth_token):
|
||||
next_url = request.args.get("next")
|
||||
if oauth_token is None:
|
||||
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})
|
||||
username = r.json()["login"]
|
||||
|
||||
# Get user by github username
|
||||
userByGithub = User.query.filter(func.lower(User.github_username) == func.lower(username)).first()
|
||||
|
||||
# If logged in, connect
|
||||
if current_user and current_user.is_authenticated:
|
||||
if userByGithub is None:
|
||||
current_user.github_username = username
|
||||
db.session.commit()
|
||||
flash("Linked github to account", "success")
|
||||
return redirect(url_for("homepage.home"))
|
||||
else:
|
||||
flash("Github account is already associated with another user", "danger")
|
||||
return redirect(url_for("homepage.home"))
|
||||
|
||||
# If not logged in, log in
|
||||
else:
|
||||
if userByGithub is None:
|
||||
flash("Unable to find an account for that Github user", "error")
|
||||
return redirect(url_for("users.claim"))
|
||||
elif loginUser(userByGithub):
|
||||
if not current_user.hasPassword():
|
||||
return redirect(next_url or url_for("users.set_password", optional=True))
|
||||
else:
|
||||
return redirect(next_url or url_for("homepage.home"))
|
||||
else:
|
||||
flash("Authorization failed [err=gh-login-failed]", "danger")
|
||||
return redirect(url_for("user.login"))
|
||||
@@ -71,7 +71,7 @@ def profile(username):
|
||||
if current_user.rank.atLeast(newRank):
|
||||
user.rank = form["rank"].data
|
||||
else:
|
||||
flash("Can't promote a user to a rank higher than yourself!", "error")
|
||||
flash("Can't promote a user to a rank higher than yourself!", "danger")
|
||||
|
||||
if user.checkPerm(current_user, Permission.CHANGE_EMAIL):
|
||||
newEmail = form["email"].data
|
||||
@@ -147,7 +147,7 @@ def send_email(username):
|
||||
next_url = url_for("users.profile", username=user.username)
|
||||
|
||||
if user.email is None:
|
||||
flash("User has no email address!", "error")
|
||||
flash("User has no email address!", "danger")
|
||||
return redirect(next_url)
|
||||
|
||||
form = SendEmailForm(request.form)
|
||||
@@ -214,91 +214,17 @@ def set_password():
|
||||
else:
|
||||
return redirect(url_for("user.login"))
|
||||
else:
|
||||
flash("Passwords do not match", "error")
|
||||
flash("Passwords do not match", "danger")
|
||||
|
||||
return render_template("users/set_password.html", form=form, optional=request.args.get("optional"))
|
||||
|
||||
|
||||
@bp.route("/user/claim/", methods=["GET", "POST"])
|
||||
def claim():
|
||||
username = request.args.get("username")
|
||||
if username is None:
|
||||
username = ""
|
||||
else:
|
||||
method = request.args.get("method")
|
||||
user = User.query.filter_by(forums_username=username).first()
|
||||
if user and user.rank.atLeast(UserRank.NEW_MEMBER):
|
||||
flash("User has already been claimed", "error")
|
||||
return redirect(url_for("users.claim"))
|
||||
elif user is None and method == "github":
|
||||
flash("Unable to get Github username for user", "error")
|
||||
return redirect(url_for("users.claim"))
|
||||
elif user is None:
|
||||
flash("Unable to find that user", "error")
|
||||
return redirect(url_for("users.claim"))
|
||||
|
||||
if user is not None and method == "github":
|
||||
return redirect(url_for("users.github_signin"))
|
||||
|
||||
token = None
|
||||
if "forum_token" in session:
|
||||
token = session["forum_token"]
|
||||
else:
|
||||
token = randomString(32)
|
||||
session["forum_token"] = token
|
||||
|
||||
if request.method == "POST":
|
||||
ctype = request.form.get("claim_type")
|
||||
username = request.form.get("username")
|
||||
|
||||
if username is None or len(username.strip()) < 2:
|
||||
flash("Invalid username", "error")
|
||||
elif ctype == "github":
|
||||
task = checkForumAccount.delay(username)
|
||||
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.claim", username=username, method="github")))
|
||||
elif ctype == "forum":
|
||||
user = User.query.filter_by(forums_username=username).first()
|
||||
if user is not None and user.rank.atLeast(UserRank.NEW_MEMBER):
|
||||
flash("That user has already been claimed!", "error")
|
||||
return redirect(url_for("users.claim"))
|
||||
|
||||
# Get signature
|
||||
sig = None
|
||||
try:
|
||||
profile = getProfile("https://forum.minetest.net", username)
|
||||
sig = profile.signature
|
||||
except IOError:
|
||||
flash("Unable to get forum signature - does the user exist?", "error")
|
||||
return redirect(url_for("users.claim", username=username))
|
||||
|
||||
# Look for key
|
||||
if token in sig:
|
||||
if user is None:
|
||||
user = User(username)
|
||||
user.forums_username = username
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
if loginUser(user):
|
||||
return redirect(url_for("users.set_password"))
|
||||
else:
|
||||
flash("Unable to login as user", "error")
|
||||
return redirect(url_for("users.claim", username=username))
|
||||
|
||||
else:
|
||||
flash("Could not find the key in your signature!", "error")
|
||||
return redirect(url_for("users.claim", username=username))
|
||||
else:
|
||||
flash("Unknown claim type", "error")
|
||||
|
||||
return render_template("users/claim.html", username=username, key=token)
|
||||
|
||||
@bp.route("/users/verify/")
|
||||
def verify_email():
|
||||
token = request.args.get("token")
|
||||
ver = UserEmailVerification.query.filter_by(token=token).first()
|
||||
if ver is None:
|
||||
flash("Unknown verification token!", "error")
|
||||
flash("Unknown verification token!", "danger")
|
||||
else:
|
||||
ver.user.email = ver.email
|
||||
db.session.delete(ver)
|
||||
|
||||
@@ -5,3 +5,4 @@ title: Help
|
||||
* [Content Ratings and Flags](content_flags)
|
||||
* [Reporting Content](reporting)
|
||||
* [API](api)
|
||||
* [Creating Releases using Webhooks](release_webhooks)
|
||||
|
||||
@@ -23,6 +23,19 @@ You can use the `/api/whoami` to check authentication.
|
||||
* GET `/api/packages/` - See [Package Queries](#package-queries)
|
||||
* GET `/api/packages/<username>/<name>/`
|
||||
|
||||
### Releases
|
||||
|
||||
* GET `/api/packages/<username>/<name>/releases/`
|
||||
* POST `/api/packages/<username>/<name>/releases/new/`
|
||||
* Requires authentication.
|
||||
* `title`: human-readable name of the release.
|
||||
* `method`: Release-creation method, only `git` is supported.
|
||||
* `min_protocol`: (Optional) minimum Minetest protocol version. See [Minetest](#minetest).
|
||||
* `min_protocol`: (Optional) maximum Minetest protocol version. See [Minetest](#minetest).
|
||||
* If `git` release-creation method:
|
||||
* `ref` - git reference, eg: `master`.
|
||||
|
||||
|
||||
### Topics
|
||||
|
||||
* GET `/api/topics/` - Supports [Package Queries](#package-queries), and the following two options:
|
||||
|
||||
80
app/flatpages/help/release_webhooks.md
Normal file
80
app/flatpages/help/release_webhooks.md
Normal file
@@ -0,0 +1,80 @@
|
||||
title: Creating Releases using Webhooks
|
||||
|
||||
## What does this mean?
|
||||
|
||||
A webhook is a notification from one service to another. Put simply, a webhook
|
||||
is used to notify ContentDB that the git repository has changed.
|
||||
|
||||
ContentDB offers the ability to automatically create releases using webhooks
|
||||
from either Github or Gitlab. If you're not using either of those services,
|
||||
you can also use the [API](../api) to create releases.
|
||||
|
||||
The process is as follows:
|
||||
|
||||
1. The user creates an API Token and a webhook to use it. This can be done automatically
|
||||
for Github.
|
||||
2. The user pushes a commit to the git host (Gitlab or Github).
|
||||
3. The git host posts a webhook notification to ContentDB, using the API token assigned to it.
|
||||
4. ContentDB checks the API token and issues a new release.
|
||||
|
||||
<p class="alert alert-info">
|
||||
This feature is in beta, and is only available for Trusted Members.
|
||||
</p>
|
||||
|
||||
## Setting up
|
||||
|
||||
### Github (automatic)
|
||||
|
||||
1. Go to your package page.
|
||||
2. Make sure that the repository URL is set to a Github repository.
|
||||
Only github.com is supported.
|
||||
3. Go to "Create a release", and click "Setup webhook" at the top of the page.
|
||||
If you do not see this, either the repository isn't using Github or you do
|
||||
not have permission to use webhook releases (ie: you're not a Trusted Member).
|
||||
4. Grant ContentDB the ability to manage Webhooks.
|
||||
5. Set the event to either "New tag" or "Push". New tag is highlight recommended.
|
||||
|
||||
N.B.: GitHub uses tags to power GitHub Releases, meaning that creating a webhook
|
||||
on "new tag" will sync GitHub and ContentDB releases.
|
||||
|
||||
### GitHub (manual)
|
||||
|
||||
1. Create an API Token by visiting your profile and clicking "API Tokens: Manage".
|
||||
2. Copy the access token that was generated.
|
||||
3. Go to the repository's settings > Webhooks > Add Webhook.
|
||||
4. Set the payload URL to `https://content.minetest.net/github/webhook/`
|
||||
5. Set the content type to JSON.
|
||||
6. Set the secret to the access token that you copied.
|
||||
7. Set the events
|
||||
* If you want a rolling release, choose "just the push event".
|
||||
* Or if you want a stable release cycle based on tags,
|
||||
choose "Let me select" > Branch or tag creation.
|
||||
|
||||
### GitLab (manual)
|
||||
|
||||
1. Create an API Token by visiting your profile and clicking "API Tokens: Manage".
|
||||
2. Copy the access token that was generated.
|
||||
3. Go to the repository's settings > Integrations.
|
||||
4. Set the URL to `https://content.minetest.net/gitlab/webhook/`
|
||||
6. Set the secret token to the access token that you copied.
|
||||
7. Set the events
|
||||
* If you want a rolling release, choose "Push events".
|
||||
* Or if you want a stable release cycle based on tags,
|
||||
choose "Tag push events".
|
||||
|
||||
## Configuring
|
||||
|
||||
### Setting minimum and maximum Minetest versions
|
||||
|
||||
<p class="alert alert-info">
|
||||
This feature is unimplemented.
|
||||
</p>
|
||||
|
||||
1. Open up the conf file for the package.
|
||||
This will be `game.conf`, `mod.conf`, `modpack.conf`, or `texture_pack.conf`
|
||||
depending on the content type.
|
||||
2. Set `min_protocol` and `max_protocol` to the respective protocol numbers
|
||||
of the Minetest versions.
|
||||
* 0.4 = 32
|
||||
* 5.0 = 37
|
||||
* 5.1 = 38
|
||||
@@ -109,6 +109,9 @@ class Permission(enum.Enum):
|
||||
else:
|
||||
raise Exception("Non-global permission checked globally. Use Package.checkPerm or User.checkPerm instead.")
|
||||
|
||||
def display_name_default(context):
|
||||
return context.get_current_parameters()["username"]
|
||||
|
||||
class User(db.Model, UserMixin):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
@@ -123,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())
|
||||
@@ -130,7 +136,7 @@ class User(db.Model, UserMixin):
|
||||
# User information
|
||||
profile_pic = db.Column(db.String(255), nullable=True, server_default=None)
|
||||
active = db.Column("is_active", db.Boolean, nullable=False, server_default="0")
|
||||
display_name = db.Column(db.String(100), nullable=False, server_default="")
|
||||
display_name = db.Column(db.String(100), nullable=False, default=display_name_default)
|
||||
|
||||
# Links
|
||||
website_url = db.Column(db.String(255), nullable=True, default=None)
|
||||
@@ -146,7 +152,7 @@ class User(db.Model, UserMixin):
|
||||
tokens = db.relationship("APIToken", backref="owner", lazy="dynamic")
|
||||
replies = db.relationship("ThreadReply", backref="author", lazy="dynamic")
|
||||
|
||||
def __init__(self, username, active=False, email=None, password=""):
|
||||
def __init__(self, username=None, active=False, email=None, password=""):
|
||||
self.username = username
|
||||
self.email_confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000)
|
||||
self.display_name = username
|
||||
@@ -458,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:
|
||||
@@ -519,7 +550,7 @@ class Package(db.Model):
|
||||
"short_description": self.short_desc,
|
||||
"desc": self.desc,
|
||||
"type": self.type.toName(),
|
||||
"created_at": self.created_at,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
|
||||
"license": self.license.name,
|
||||
"media_license": self.media_license.name,
|
||||
@@ -770,6 +801,18 @@ class PackageRelease(db.Model):
|
||||
# If the release is approved, then the task_id must be null and the url must be present
|
||||
CK_approval_valid = db.CheckConstraint("not approved OR (task_id IS NULL AND (url = '') IS NOT FALSE)")
|
||||
|
||||
def getAsDictionary(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"url": self.url if self.url != "" else None,
|
||||
"release_date": self.releaseDate.isoformat(),
|
||||
"commit": self.commit_hash,
|
||||
"downloads": self.downloads,
|
||||
"min_protocol": self.min_rel and self.min_rel.protocol,
|
||||
"max_protocol": self.max_rel and self.max_rel.protocol
|
||||
}
|
||||
|
||||
def getEditURL(self):
|
||||
return url_for("packages.edit_release",
|
||||
author=self.package.author.username,
|
||||
@@ -861,12 +904,21 @@ class PackageScreenshot(db.Model):
|
||||
class APIToken(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
access_token = db.Column(db.String(34), unique=True)
|
||||
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
# owner is created using backref
|
||||
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
|
||||
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
|
||||
package = db.relationship("Package", foreign_keys=[package_id])
|
||||
|
||||
def canOperateOnPackage(self, package):
|
||||
return packages.count() == 0 or package in packages
|
||||
if self.package and self.package != package:
|
||||
return False
|
||||
|
||||
return package.author == self.owner
|
||||
|
||||
|
||||
class EditRequest(db.Model):
|
||||
|
||||
@@ -19,6 +19,7 @@ from flask import render_template, url_for
|
||||
from flask_mail import Message
|
||||
from app import mail
|
||||
from app.tasks import celery
|
||||
from app.utils import abs_url_for
|
||||
|
||||
@celery.task()
|
||||
def sendVerifyEmail(newEmail, token):
|
||||
@@ -34,7 +35,7 @@ def sendVerifyEmail(newEmail, token):
|
||||
If this was you, then please click this link to verify the address:
|
||||
|
||||
{}
|
||||
""".format(url_for('users.verify_email', token=token, _external=True))
|
||||
""".format(abs_url_for('users.verify_email', token=token))
|
||||
|
||||
msg.html = render_template("emails/verify.html", token=token)
|
||||
mail.send(msg)
|
||||
|
||||
@@ -156,8 +156,11 @@ def cloneRepo(urlstr, ref=None, recursive=False):
|
||||
repo = git.Repo.clone_from(gitUrl, gitDir, \
|
||||
progress=None, env=None, depth=1, recursive=recursive, kill_after_timeout=15)
|
||||
else:
|
||||
repo = git.Repo.clone_from(gitUrl, gitDir, \
|
||||
progress=None, env=None, depth=1, recursive=recursive, kill_after_timeout=15, b=ref)
|
||||
repo = git.Repo.init(gitDir)
|
||||
origin = repo.create_remote("origin", url=gitUrl)
|
||||
assert origin.exists()
|
||||
origin.fetch()
|
||||
origin.pull(ref)
|
||||
|
||||
return gitDir, repo
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
{{ render_field(form.name, placeholder="Human readable") }}
|
||||
{{ render_field(form.package) }}
|
||||
|
||||
{{ render_submit_field(form.submit) }}
|
||||
</form>
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
<li class="alert alert-{{category}} container">
|
||||
<span class="icon_message"></span>
|
||||
|
||||
{{ message|safe }}
|
||||
{{ message }}
|
||||
|
||||
<div style="clear: both;"></div>
|
||||
</li>
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
If this was you, then please click this link to verify the address:
|
||||
</p>
|
||||
|
||||
<a class="btn" href="{{ url_for('users.verify_email', token=token, _external=True) }}">
|
||||
<a class="btn" href="{{ abs_url_for('users.verify_email', token=token) }}">
|
||||
Confirm Email Address
|
||||
</a>
|
||||
|
||||
<p style="font-size: 80%;">
|
||||
Or paste this into your browser: {{ url_for('users.verify_email', token=token, _external=True) }}
|
||||
Or paste this into your browser: {{ abs_url_for('users.verify_email', token=token) }}
|
||||
<p>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -60,7 +60,7 @@ Sign in
|
||||
{% from "flask_user/_macros.html" import render_field, render_checkbox_field, render_submit_field %}
|
||||
<h2 class="card-header">{%trans%}Sign in with Github{%endtrans%}</h2>
|
||||
<div class="card-body">
|
||||
<a class="btn btn-primary" href="{{ url_for('users.github_signin') }}">GitHub</a>
|
||||
<a class="btn btn-primary" href="{{ url_for('github.start') }}">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
23
app/templates/github/setup_webhook.html
Normal file
23
app/templates/github/setup_webhook.html
Normal file
@@ -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 %}
|
||||
<h1 class="mt-0">{{ self.title() }}</h1>
|
||||
|
||||
<div class="alert alert-info">
|
||||
{{ _("You can delete the webhook at any time by going into Settings > Webhooks on the repository.") }}
|
||||
</div>
|
||||
|
||||
<form method="POST" action="" enctype="multipart/form-data">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
{{ render_field(form.event) }}
|
||||
|
||||
{{ render_submit_field(form.submit) }}
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -54,6 +54,7 @@
|
||||
<li{% if topic.wip %} class="wiptopic"{% endif %}>
|
||||
<a href="https://forum.minetest.net/viewtopic.php?t={{ topic.topic_id}}">{{ topic.title }}</a>
|
||||
{% if topic.wip %}[WIP]{% endif %}
|
||||
{% if topic.discarded %}[Old]{% endif %}
|
||||
{% if topic.name %}[{{ topic.name }}]{% endif %}
|
||||
{% if show_author %}
|
||||
by <a href="{{ url_for('users.profile', username=topic.author.username) }}">{{ topic.author.display_name }}</a>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
{% if package and package.approved and not package.checkPerm(current_user, "CHANGE_NAME") %}
|
||||
{{ render_field(form.name, class_="pkg_meta col-sm-3", readonly=True) }}
|
||||
{% else %}
|
||||
{{ render_field(form.name, class_="pkg_meta col-sm-3") }}
|
||||
{{ render_field(form.name, class_="pkg_meta col-sm-3", pattern="[a-z0-9_]+", title=_("Lower case letters (a-z), digits (0-9), and underscores (_) only")) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{{ render_field(form.short_desc, class_="pkg_meta") }}
|
||||
|
||||
@@ -5,6 +5,24 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="alert alert-info mb-4">
|
||||
<a class="float-right btn btn-sm btn-info" href="{{ url_for('flatpage', path='help/release_webhooks') }}">{{ _("Learn more") }}</a>
|
||||
{% if package.author == current_user and package.checkPerm(current_user, "APPROVE_RELEASE") and package.getIsOnGitHub() %}
|
||||
<a class="float-right btn btn-sm btn-info mr-2" href="{{ url_for('github.setup_webhook', pid=package.id) }}">{{ _("Setup webhook") }}</a>
|
||||
<i class="fas fa-info mr-2"></i>
|
||||
|
||||
{{ _("Set up a webhook on GitHub to create releases automatically.") }}
|
||||
{% elif package.repo %}
|
||||
<i class="fas fa-info mr-2"></i>
|
||||
|
||||
{{ _("You can create releases automatically when you push commits or tags to your repository.") }}
|
||||
{% else %}
|
||||
<i class="fas fa-info mr-2"></i>
|
||||
|
||||
{{ _("Using git will allow you to create releases automatically when you push code or tags.") }}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% from "macros/forms.html" import render_field, render_submit_field, render_radio_field %}
|
||||
<form method="POST" action="" enctype="multipart/form-data">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
{% if user.github_username %}
|
||||
<a href="https://github.com/{{ user.github_username }}">GitHub</a>
|
||||
{% elif user == current_user %}
|
||||
<a href="{{ url_for('users.github_signin') }}">Link Github</a>
|
||||
<a href="{{ url_for('github.start') }}">Link Github</a>
|
||||
{% endif %}
|
||||
|
||||
{% if user.website_url %}
|
||||
|
||||
12
app/utils.py
12
app/utils.py
@@ -22,6 +22,12 @@ from app.models import *
|
||||
from app import app
|
||||
import random, string, os, imghdr
|
||||
|
||||
|
||||
@app.template_filter()
|
||||
def abs_url_for(path, **kwargs):
|
||||
scheme = "https" if app.config["BASE_URL"][:5] == "https" else "http"
|
||||
return url_for(path, _external=True, _scheme=scheme, **kwargs)
|
||||
|
||||
def get_int_or_abort(v, default=None):
|
||||
try:
|
||||
return int(v or default)
|
||||
@@ -48,7 +54,7 @@ def randomString(n):
|
||||
|
||||
def doFileUpload(file, fileType, fileTypeDesc):
|
||||
if not file or file is None or file.filename == "":
|
||||
flash("No selected file", "error")
|
||||
flash("No selected file", "danger")
|
||||
return None, None
|
||||
|
||||
assert os.path.isdir(app.config["UPLOAD_DIR"]), "UPLOAD_DIR must exist"
|
||||
@@ -114,7 +120,7 @@ def loginUser(user):
|
||||
return False
|
||||
|
||||
if user.rank == UserRank.BANNED:
|
||||
flash("You have been banned.", "error")
|
||||
flash("You have been banned.", "danger")
|
||||
return False
|
||||
|
||||
user.active = True
|
||||
@@ -125,7 +131,7 @@ def loginUser(user):
|
||||
|
||||
# Check if user account has been disabled
|
||||
if not _call_or_get(user.is_active):
|
||||
flash("Your account has not been enabled.", "error")
|
||||
flash("Your account has not been enabled.", "danger")
|
||||
return False
|
||||
|
||||
# Use Flask-Login to sign in user
|
||||
|
||||
@@ -6,6 +6,7 @@ SECRET_KEY = ""
|
||||
WTF_CSRF_SECRET_KEY = ""
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = "sqlite:///../db.sqlite"
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
GITHUB_CLIENT_ID = ""
|
||||
GITHUB_CLIENT_SECRET = ""
|
||||
|
||||
24
migrations/versions/7a48dbd05780_.py
Normal file
24
migrations/versions/7a48dbd05780_.py
Normal file
@@ -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')
|
||||
26
migrations/versions/df66c78e6791_.py
Normal file
26
migrations/versions/df66c78e6791_.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: df66c78e6791
|
||||
Revises: a0f6c8743362
|
||||
Create Date: 2020-01-24 18:39:58.363417
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'df66c78e6791'
|
||||
down_revision = 'a0f6c8743362'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('api_token', sa.Column('package_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(None, 'api_token', 'package', ['package_id'], ['id'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_constraint(None, 'api_token', type_='foreignkey')
|
||||
op.drop_column('api_token', 'package_id')
|
||||
Reference in New Issue
Block a user