import re import os import logging import zipfile import random import string from io import BytesIO from flask import ( Flask, render_template, render_template_string, request, redirect, send_file, make_response, session, ) from flask_wtf import FlaskForm from flask_babel import Babel, lazy_gettext as _ from wtforms import ( StringField, SelectField, SubmitField, TextAreaField, BooleanField, SelectMultipleField, HiddenField, ) from wtforms.validators import ( DataRequired, Optional, Regexp, URL, Length, ) YOLOGEN_VERSION = "0.11" LANGUAGES = {"en": _("English"), "fr": _("French")} ############################################################################### # App initialization, misc configs ############################################################################### logger = logging.getLogger() app = Flask(__name__, static_url_path="/static", static_folder="static") if app.config.get("DEBUG"): app.config["TEMPLATES_AUTO_RELOAD"] = True app.config["LANGUAGES"] = LANGUAGES app.config["GENERATOR_VERSION"] = YOLOGEN_VERSION # This is the secret key used for session signing app.secret_key = "".join(random.choice(string.ascii_lowercase) for i in range(32)) def get_locale(): return ( session.get("lang") or request.accept_languages.best_match(LANGUAGES.keys()) or "en" ) babel = Babel(app, locale_selector=get_locale) @app.context_processor def jinja_globals(): d = { "locale": get_locale(), } if app.config.get("DEBUG"): d["tailwind_local"] = open("static/tailwind-local.css").read() return d app.jinja_env.globals["is_hidden_field"] = lambda field: isinstance(field, HiddenField) @app.route("/lang/<lang>") def set_lang(lang=None): assert lang in app.config["LANGUAGES"].keys() session["lang"] = lang return make_response(redirect(request.referrer or "/")) ############################################################################### # Forms ############################################################################### class GeneralInfos(FlaskForm): app_id = StringField( _("Application identifier (id)"), description=_("Small caps and without spaces"), validators=[DataRequired(), Regexp("[a-z_1-9]+.*(?<!_ynh)$")], render_kw={ "placeholder": "my_super_app", }, ) app_name = StringField( _("App name"), description=_("It's the application name, displayed in the user interface"), validators=[DataRequired()], render_kw={ "placeholder": "My super App", }, ) description_en = StringField( _("Short description (en)"), description=_( "Explain in a few words (10-15) why this app is useful or what it does (the goal is to give a broad idea for the user browsing an hundred apps long catalog" ), validators=[DataRequired()], ) description_fr = StringField( _("Short description (fr)"), description=_( "Explain in a few words (10-15) why this app is useful or what it does (the goal is to give a broad idea for the user browsing an hundred apps long catalog" ), validators=[DataRequired()], ) class IntegrationInfos(FlaskForm): # TODO : people shouldnt have to put the ~ynh1 ? This should be added automatically when rendering the app files ? version = StringField( _("Version"), validators=[Regexp("\d{1,4}.\d{1,4}(.\d{1,4})?(.\d{1,4})?~ynh\d+")], render_kw={"placeholder": "1.0~ynh1"}, ) maintainers = StringField( _("Maintainer of the generated app"), description=_("Usually you put your name here... If you're okay with it ;)"), ) yunohost_required_version = StringField( _("Minimal YunoHost version"), description=_("Minimal YunoHost version for the application to work"), render_kw={ "placeholder": "11.1.21", }, ) architectures = SelectMultipleField( _("Supported architectures"), choices=[ ("all", _("All architectures")), ("amd64", "amd64"), ("i386", "i386"), ("armhf", "armhf"), ("arm64", "arm64"), ], default=["all"], validators=[DataRequired()], ) multi_instance = BooleanField( _( "The app can be installed multiple times at the same time on the same server" ), default=True, ) ldap = SelectField( _("The app will be integrating LDAP"), description=_( "Which means it's possible to use Yunohost credentials to log into this app. 'LDAP' corresponds to the technology used by Yunohost to handle a centralised user base. Bridging the app and Yunohost's LDAP often requires to add the proper technical details in the app's configuration file" ), choices=[ ("false", _("No")), ("true", _("Yes")), ("not_relevant", _("Not relevant")), ], default="not_relevant", validators=[DataRequired()], ) sso = SelectField( _("The app will be integrated in Yunohost SSO (Single Sign On)"), description=_( "Which means that people will be logged in the app after logging in YunoHost's portal, without having to sign on specifically into this app." ), choices=[ ("false", _("Yes")), ("true", _("No")), ("not_relevant", _("Not relevant")), ], default="not_relevant", validators=[DataRequired()], ) class UpstreamInfos(FlaskForm): license = StringField( _("Licence"), description=_( "You should check this on the upstream repository. The expected format is a SPDX id listed in https://spdx.org/licenses/" ), validators=[DataRequired()], ) website = StringField( _("Official website"), description=_("Leave empty if there is no official website"), validators=[URL(), Optional()], render_kw={ "placeholder": "https://awesome-app-website.com", }, ) demo = StringField( _("Official app demo"), description=_("Leave empty if there is no official demo"), validators=[URL(), Optional()], render_kw={ "placeholder": "https://awesome-app-website.com/demo", }, ) admindoc = StringField( _("Admin documentation"), description=_("Leave empty if there is no official admin doc"), validators=[URL(), Optional()], render_kw={ "placeholder": "https://awesome-app-website.com/doc/admin", }, ) userdoc = StringField( _("Usage documentation"), description=_("Leave empty if there is no official user doc"), validators=[URL(), Optional()], render_kw={ "placeholder": "https://awesome-app-website.com/doc/user", }, ) code = StringField( _("Code repository"), validators=[URL(), DataRequired()], render_kw={ "placeholder": "https://some.git.forge/org/app", }, ) class InstallQuestions(FlaskForm): domain_and_path = SelectField( _( "Ask the URL where the app will be installed ('domain' and 'path' variables)" ), default="true", choices=[ ("true", _("Ask domain+path")), ( "full_domain", _( "Ask only the domain (the app requires to be installed at the root of a dedicated domain)" ), ), ("false", _("Do not ask (it isn't a webapp)")), ], ) init_main_permission = BooleanField( _("Ask who can access to the app"), description=_( "In the users groups : by default at least 'visitors', 'all_users' et 'admins' exists. (It was previously the private/public app concept)" ), default=True, ) init_admin_permission = BooleanField( _("Ask who can access to the admin interface"), description=_("In the case where the app has an admin interface"), default=False, ) language = SelectMultipleField( _("Supported languages"), choices=[ ("_", _("None / not relevant")), ("en", _("English")), ("fr", _("French")), ("en", _("Spanish")), ("it", _("Italian")), ("de", _("German")), ("zh", _("Chinese")), ("jp", _("Japanese")), ("da", _("Danish")), ("pt", _("Portugese")), ("nl", _("Dutch")), ("ru", _("Russian")), ], default=["_"], validators=[DataRequired()], ) # manifest class Ressources(FlaskForm): # Sources source_url = StringField( _("Application source code or executable"), validators=[DataRequired(), URL()], render_kw={ "placeholder": "https://github.com/foo/bar/archive/refs/tags/v1.2.3.tar.gz", }, ) sha256sum = StringField( _("Sources sha256 checksum"), validators=[DataRequired(), Length(min=64, max=64)], render_kw={ "placeholder": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", }, ) auto_update = SelectField( _("Enable automatic update of sources (using a bot running every night)"), description=_( "If the upstream software is hosted in one of the handled sources and publishes proper releases or tags, the bot will create a pull request to update the sources URL and checksum" ), default="none", choices=[ ("none", "Non"), ("latest_github_tag", "Github (tag)"), ("latest_github_release", "Github (release)"), ("latest_github_commit", "Github (commit)"), ("latest_gitlab_tag", "Gitlab (tag)"), ("latest_gitlab_release", "Gitlab (release)"), ("latest_gitlab_commit", "Gitlab (commit)"), ("latest_gitea_tag", "Gitea (tag)"), ("latest_gitea_release", "Gitea (release)"), ("latest_gitea_commit", "Gitea (commit)"), ("latest_forgejo_tag", "Forgejo (tag)"), ("latest_forgejo_release", "Forgejo (release)"), ("latest_forgejo_commit", "Forgejo (commit)"), ], ) apt_dependencies = StringField( _("Dependencies to be installed via apt (separated by comma and/or spaces)"), render_kw={ "placeholder": "foo, bar2.1-ext, libwat", }, ) database = SelectField( _("Initialize an SQL database"), choices=[ ("false", "Non"), ("mysql", "MySQL/MariaDB"), ("postgresql", "PostgreSQL"), ], default="false", ) system_user = BooleanField( _("Initialize a system user for this app"), default=True, ) install_dir = BooleanField( _("Initialize an installation folder for this app"), description=_("By default it's /var/www/$app"), default=True, ) data_dir = BooleanField( _("Initialize a folder to store the app data"), description=_("By default it's /var/yunohost.app/$app"), default=False, ) class SpecificTechnology(FlaskForm): main_technology = SelectField( _("App main technology"), choices=[ ("none", _("None / Static application")), ("php", "PHP"), ("nodejs", "NodeJS"), ("python", "Python"), ("ruby", "Ruby"), ("other", _("Other")), ], default="none", validators=[DataRequired()], ) install_snippet = TextAreaField( _("Installation specific commands"), description=_( "These commands are executed from the app installation folder (by default, /var/www/$app) after the sources have been deployed. This field uses by default a classic example based on the selected technology. You should probably compare and adapt it according to the app installation documentation" ), validators=[Optional()], render_kw={"spellcheck": "false"}, ) # # PHP # use_composer = BooleanField( _("Use composer"), description=_("Composer is a PHP dependencies manager used by some apps"), default=False, ) # # NodeJS # nodejs_version = StringField( _("NodeJS version"), description=_("For example: 16.4, 18, 18.2, 20, 20.1, ..."), render_kw={ "placeholder": "20", }, ) use_yarn = BooleanField( _("Install and use Yarn"), default=False, ) # NodeJS / Python / Ruby / ... systemd_execstart = StringField( _("Command to start the app daemon (from systemd service)"), description=_( "Corresponds to 'ExecStart' statement in systemd. You can use '__INSTALL_DIR__' to refer to the install directory, or '__APP__' to refer to the app id" ), render_kw={ "placeholder": "__INSTALL_DIR__/bin/app --some-option", }, ) class AppConfig(FlaskForm): use_custom_config_file = BooleanField( _("The app uses a specific configuration file"), description=_("Usually : .env, config.json, conf.ini, params.yml, ..."), default=False, ) custom_config_file = StringField( _("Name or file path to use"), validators=[Optional()], render_kw={ "placeholder": "config.json", }, ) custom_config_file_content = TextAreaField( _("App configuration file pattern"), description=_( "In this pattern, you can use the syntax __FOO_BAR__ which will automatically replaced by the value of the variable $foo_bar" ), validators=[Optional()], render_kw={"spellcheck": "false"}, ) class Documentation(FlaskForm): # TODO : # screenshot description = TextAreaField( _( "doc/DESCRIPTION.md: A comprehensive presentation of the app, possibly listing the main features, possible warnings and specific details on its functioning in Yunohost (e.g. warning about integration issues)." ), validators=[Optional()], render_kw={ "spellcheck": "false", }, ) pre_install = TextAreaField( _( "doc/PRE_INSTALL.md: important info to be shown to the admin before installing the app" ), description=_("Leave empty if not relevant"), validators=[Optional()], render_kw={ "spellcheck": "false", }, ) post_install = TextAreaField( _( "doc/POST_INSTALL.md: important info to be shown to the admin after installing the app" ), description=_("Leave empty if not relevant"), validators=[Optional()], render_kw={ "spellcheck": "false", }, ) pre_upgrade = TextAreaField( _( "doc/PRE_UPGRADE.md: important info to be shown to the admin before upgrading the app" ), description=_("Leave empty if not relevant"), validators=[Optional()], render_kw={ "spellcheck": "false", }, ) post_upgrade = TextAreaField( _( "doc/POST_UPGRADE.md: important info to be shown to the admin after upgrading the app" ), description=_("Leave empty if not relevant"), validators=[Optional()], render_kw={ "spellcheck": "false", }, ) admin = TextAreaField( _("doc/ADMIN.md: general tips on how to administrate this app"), description=_("Leave empty if not relevant"), validators=[Optional()], render_kw={ "spellcheck": "false", }, ) class MoreAdvanced(FlaskForm): enable_change_url = BooleanField( _("Handle app install URL change (change_url script)"), default=True, render_kw={ "title": _("Should changing the app URL be allowed ? (change_url change)") }, ) use_logrotate = BooleanField( _("Use logrotate for the app logs"), default=True, render_kw={ "title": _( "If the app generates logs, this option permit to handle their archival. Recommended." ) }, ) # TODO : specify custom log file # custom_log_file = "/var/log/$app/$app.log" "/var/log/nginx/${domain}-error.log" use_fail2ban = BooleanField( _("Protect the application against brute force attacks (via fail2ban)"), default=False, render_kw={ "title": _( "If the app generates failed connexions logs, this option allows to automatically banish the related IP after a certain number of failed password tries. Recommended." ) }, ) use_cron = BooleanField( _("Add a CRON task for this application"), description=_("Corresponds to some app periodic operations"), default=False, ) cron_config_file = TextAreaField( _("Type the CRON file content"), validators=[Optional()], render_kw={ "class": "form-control", "spellcheck": "false", }, ) fail2ban_regex = StringField( _("Regular expression for fail2ban"), # Regex to match into the log for a failed login validators=[Optional()], render_kw={ "placeholder": _("A regular expression"), "class": "form-control", "title": _( "Regular expression to check in the log file to activate failban (search for a line that indicates a credentials error)." ), }, ) ## Main form class GeneratorForm( GeneralInfos, IntegrationInfos, UpstreamInfos, InstallQuestions, Ressources, SpecificTechnology, AppConfig, Documentation, MoreAdvanced, ): class Meta: csrf = False generator_mode = SelectField( _("Generator mode"), description=_( "In tutorial version, the generated app will contain additionnal comments to ease the understanding. In steamlined version, the generated app will only contain the necessary minimum." ), choices=[ ("simple", _("Streamlined version")), ("tutorial", _("Tutorial version")), ], default="true", validators=[DataRequired()], ) submit_preview = SubmitField(_("Previsualise")) submit_download = SubmitField(_("Download the .zip")) submit_demo = SubmitField( _("Fill with demo values"), render_kw={ "onclick": "fillFormWithDefaultValues()", "title": _( "Generate a complete and functionnal minimalistic app that you can iterate from" ), }, ) #### Web pages @app.route("/", methods=["GET", "POST"]) def main_form_route(): main_form = GeneratorForm() app_files = [] if request.method == "POST": if not main_form.validate_on_submit(): logging.error("Form not validated?") logging.error(main_form.errors) return render_template( "index.html", main_form=main_form, generated_files={}, ) if main_form.submit_preview.data: submit_mode = "preview" elif main_form.submit_demo.data: submit_mode = "demo" # TODO : for now this always trigger a preview. Not sure if that's an issue else: submit_mode = "download" class AppFile: def __init__(self, id_, destination_path=None): self.id = id_ self.destination_path = destination_path self.content = None app_files = [ AppFile("manifest", "manifest.toml"), AppFile("tests", "tests.toml"), # TODO test this AppFile("_common.sh", "scripts/_common.sh"), AppFile("install", "scripts/install"), AppFile("remove", "scripts/remove"), AppFile("backup", "scripts/backup"), AppFile("restore", "scripts/restore"), AppFile("upgrade", "scripts/upgrade"), AppFile("nginx", "conf/nginx.conf"), ] if main_form.enable_change_url.data: app_files.append(AppFile("change_url", "scripts/change_url")) if main_form.main_technology.data not in ["none", "php"]: app_files.append(AppFile("systemd", "conf/systemd.service")) # TODO : buggy, tries to open php.j2 # if main_form.main_technology.data == "php": # app_files.append(AppFile("php", "conf/extra_php-fpm.conf")) if main_form.description.data: app_files.append(AppFile("DESCRIPTION", "doc/DESCRIPTION.md")) if main_form.pre_install.data: app_files.append(AppFile("PRE_INSTALL", "doc/PRE_INSTALL.md")) if main_form.post_install.data: app_files.append(AppFile("POST_INSTALL", "doc/POST_INSTALL.md")) if main_form.pre_upgrade.data: app_files.append(AppFile("PRE_UPGRADE", "doc/PRE_UPGRADE.md")) if main_form.post_upgrade.data: app_files.append(AppFile("POST_UPGRADE", "doc/POST_UPGRADE.md")) if main_form.admin.data: app_files.append(AppFile("ADMIN", "doc/ADMIN.md")) template_dir = os.path.dirname(__file__) + "/templates/" for app_file in app_files: template = open(template_dir + app_file.id + ".j2").read() app_file.content = render_template_string(template, data=dict(request.form)) app_file.content = re.sub(r"\n\s+$", "\n", app_file.content, flags=re.M) app_file.content = re.sub(r"\n{3,}", "\n\n", app_file.content, flags=re.M) if main_form.use_custom_config_file.data: app_files.append( AppFile("appconf", "conf/" + main_form.custom_config_file.data) ) app_files[-1].content = main_form.custom_config_file_content.data if submit_mode == "download": # Generate the zip file f = BytesIO() with zipfile.ZipFile(f, "w") as zf: for app_file in app_files: zf.writestr(app_file.destination_path, app_file.content) f.seek(0) # Send the zip file to the user return send_file( f, as_attachment=True, download_name=request.form["app_id"] + ".zip" ) return render_template( "index.html", main_form=main_form, generated_files=app_files, ) #### Running the web server if __name__ == "__main__": app.run(debug=True)