diff --git a/store/app.py b/store/app.py index 6837588f..d0cd86da 100644 --- a/store/app.py +++ b/store/app.py @@ -1,3 +1,4 @@ +import re import toml import base64 import hashlib @@ -7,7 +8,9 @@ import random import urllib import json import sys +from slugify import slugify from flask import Flask, send_from_directory, render_template, session, redirect, request +from github import Github, InputGitAuthor app = Flask(__name__) catalog = json.load(open("apps.json")) @@ -15,14 +18,24 @@ catalog['categories'] = {c['id']:c for c in catalog['categories']} try: config = toml.loads(open("config.toml").read()) - DISCOURSE_SSO_SECRET = config["DISCOURSE_SSO_SECRET"] - DISCOURSE_SSO_ENDPOINT = config["DISCOURSE_SSO_ENDPOINT"] - CALLBACK_URL_AFTER_LOGIN_ON_DISCOURSE = config["CALLBACK_URL_AFTER_LOGIN_ON_DISCOURSE"] except Exception as e: print("You should create a config.toml with the appropriate key/values, cf config.toml.example") - print(e) sys.exit(1) +mandatory_config_keys = [ + "DISCOURSE_SSO_SECRET", + "DISCOURSE_SSO_ENDPOINT", + "CALLBACK_URL_AFTER_LOGIN_ON_DISCOURSE", + "GITHUB_LOGIN", + "GITHUB_TOKEN", + "GITHUB_EMAIL", +] + +for key in mandatory_config_keys: + if key not in config: + print(f"Missing key in config.toml: {key}") + sys.exit(1) + if config.get("DEBUG"): app.debug = True app.config["DEBUG"] = True @@ -48,7 +61,7 @@ category_color = { for id_, category in catalog['categories'].items(): category["color"] = category_color[id_] -wishlist = json.load(open("wishlist.json")) +wishlist = toml.load(open("../wishlist.toml")) # This is the secret key used for session signing app.secret_key = ''.join([str(random.randint(0, 9)) for i in range(99)]) @@ -113,6 +126,95 @@ def browse_wishlist(): return render_template("wishlist.html", user=session.get('user', {}), wishlist=wishlist) +@app.route('/wishlist/add', methods=['GET', 'POST']) +def add_to_wishlist(): + if request.method == "POST": + + user = session.get('user', {}) + if not user: + errormsg = "You must be logged in to submit an app to the wishlist" + return render_template("wishlist_add.html", user=session.get('user', {}), errormsg=errormsg) + + name = request.form['name'].strip().replace("\n", "") + description = request.form['description'].strip().replace("\n", "") + upstream = request.form['upstream'].strip().replace("\n", "") + website = request.form['website'].strip().replace("\n", "") + + checks = [ + (len(name) >= 3, "App name should be at least 3 characters"), + (len(name) <= 30, "App name should be less than 30 characters"), + (len(description) >= 5, "App name should be at least 5 characters"), + (len(description) <= 100, "App name should be less than 100 characters"), + (len(upstream) >= 10, "Upstream code repo URL should be at least 10 characters"), + (len(upstream) <= 150, "Upstream code repo URL should be less than 150 characters"), + (len(website) <= 150, "Website URL should be less than 150 characters"), + (re.match(r"^[\w\.\-\(\)\ ]+$", name), "App name contains special characters"), + ] + + for check, errormsg in checks: + if not check: + return render_template("wishlist_add.html", user=session.get('user', {}), errormsg=errormsg) + + slug = slugify(name) + + github = Github((config["GITHUB_LOGIN"], config["GITHUB_TOKEN"])) + author = InputGitAuthor(config["GITHUB_LOGIN"], config["GITHUB_EMAIL"]) + repo = github.get_repo("Yunohost/apps") + current_wishlist_rawtoml = repo.get_contents("wishlist.toml", ref="app-store") # FIXME : ref=repo.default_branch) + current_wishlist_sha = current_wishlist_rawtoml.sha + current_wishlist_rawtoml = current_wishlist_rawtoml.decoded_content.decode() + new_wishlist = toml.loads(current_wishlist_rawtoml) + + if slug in new_wishlist: + return render_template("wishlist_add.html", user=session.get('user', {}), errormsg=f"An entry with the name {slug} already exists in the wishlist") + + new_wishlist[slug] = { + "name": name, + "description": description, + "upstream": upstream, + "website": website, + } + + new_wishlist = dict(sorted(new_wishlist.items())) + new_wishlist_rawtoml = toml.dumps(new_wishlist) + new_branch = f"add-to-wishlist-{slug}" + try: + # Get the commit base for the new branch, and create it + commit_sha = repo.get_branch("app-store").commit.sha # FIXME app-store -> repo.default_branch + repo.create_git_ref(ref=f"refs/heads/{new_branch}", sha=commit_sha) + except: + print("... Branch already exists, skipping") + return False + + message = f"Add {name} to wishlist" + repo.update_file( + "wishlist.toml", + message=message, + content=new_wishlist, + sha=current_wishlist_sha, + branch=new_branch, + author=author, + ) + + # Wait a bit to preserve the API rate limit + time.sleep(1.5) + + body = f""" +### Add {name} to wishlist + +- [ ] Confirm app is self-hostable and generally makes sense to possibly integrate in YunoHost +- [ ] Confirm app's license is opensource/free software (or not-totally-free, case by case TBD) +- [ ] Description describes concisely what the app is/does + """ + + # Open the PR + pr = self.repo.create_pull( + title=message, body=body, head=new_branch, base="app-store" # FIXME app-store -> repo.default_branch + ) + + else: + return render_template("wishlist_add.html", user=session.get('user', {}), errormsg=None) + ################################################ @@ -123,11 +225,11 @@ def create_nonce_and_build_url_to_login_on_discourse_sso(): nonce = ''.join([str(random.randint(0, 9)) for i in range(99)]) - url_data = {"nonce": nonce, "return_sso_url": CALLBACK_URL_AFTER_LOGIN_ON_DISCOURSE} + url_data = {"nonce": nonce, "return_sso_url": config["CALLBACK_URL_AFTER_LOGIN_ON_DISCOURSE"]} url_encoded = urllib.parse.urlencode(url_data) payload = base64.b64encode(url_encoded.encode()).decode() - sig = hmac.new(DISCOURSE_SSO_SECRET.encode(), msg=payload.encode(), digestmod=hashlib.sha256).hexdigest() + sig = hmac.new(config["DISCOURSE_SSO_SECRET"].encode(), msg=payload.encode(), digestmod=hashlib.sha256).hexdigest() data = {"sig": sig, "sso": payload} - url = f"{DISCOURSE_SSO_ENDPOINT}?{urllib.parse.urlencode(data)}" + url = f"{config['DISCOURSE_SSO_ENDPOINT']}?{urllib.parse.urlencode(data)}" return nonce, url diff --git a/store/config.toml.example b/store/config.toml.example index f6e56171..b029ef74 100644 --- a/store/config.toml.example +++ b/store/config.toml.example @@ -3,3 +3,6 @@ DISCOURSE_SSO_SECRET = "abcdefghijklmnopqrstuvwxyz1234567890" DISCOURSE_SSO_ENDPOINT = "https://forum.yunohost.org/session/sso_provider" CALLBACK_URL_AFTER_LOGIN_ON_DISCOURSE = "http://localhost:5000/sso_login_callback" DEBUG = false +GITHUB_LOGIN = "yunohost-bot" +GITHUB_EMAIL = "yunohost [at] yunohost.org" # Replace the [at] by actual @ +GITHUB_TOKEN = "superSecretToken" diff --git a/store/requirements.txt b/store/requirements.txt index 5aad892a..655dbae3 100644 --- a/store/requirements.txt +++ b/store/requirements.txt @@ -1 +1,4 @@ Flask==2.3.2 +python-slugify +PyGithub +toml diff --git a/store/templates/wishlist.html b/store/templates/wishlist.html index a4651183..28f0da7f 100644 --- a/store/templates/wishlist.html +++ b/store/templates/wishlist.html @@ -25,8 +25,8 @@ Add an app to the wishlist @@ -55,7 +55,7 @@ - {% for infos in wishlist %} + {% for app, infos in wishlist.items() %} {{ infos['name'] }} @@ -67,7 +67,7 @@ href="{{ infos['website'] }}" class="inline-block" > - + {% endif %} @@ -77,14 +77,14 @@ href="{{ infos['upstream'] }}" class="inline-block" > - + {% endif %} Vote diff --git a/store/templates/wishlist_add.html b/store/templates/wishlist_add.html new file mode 100644 index 00000000..6d298fb9 --- /dev/null +++ b/store/templates/wishlist_add.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block main %} +
+

+ Add an application to the wishlist +

+
+ + +
+ + {% if not user %} + + {% endif %} + + + + {% if errormsg %} + + {% endif %} + + +
+ + + + + + + Please be concise and focus on what the app does. No need to repeat "[App] is ...". No need to state that it is free/open-source or self-hosted (otherwise it wouldn't be packaged for YunoHost). Avoid marketing stuff like 'the most', or vague properties like 'easy', 'simple', 'lightweight'. + + + + + + + Please do not just copy-paste the code repository URL. If the project has no proper website, then leave the field empty. + + + +
+
+ +{% endblock %}