diff --git a/.gitignore b/.gitignore index 484a0956..1dc38528 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ tools/autopatches/token __pycache__ app_list_auto_update.log +venv diff --git a/store/.gitignore b/store/.gitignore new file mode 100644 index 00000000..54539b10 --- /dev/null +++ b/store/.gitignore @@ -0,0 +1,3 @@ +config.toml +.stars +messages.pot diff --git a/store/.stars/.gitkeep b/store/.stars/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/store/README.md b/store/README.md new file mode 100644 index 00000000..b60700be --- /dev/null +++ b/store/README.md @@ -0,0 +1,49 @@ +# YunoHost app store + +This is a Flask app interfacing with YunoHost's app catalog for a cool browsing of YunoHost's apps catalog, wishlist and being able to vote/star for apps + +## Developement + +``` +python3 -m venv venv +source venv/bin/activate +pip3 install -r requirements.txt +cp config.toml.example config.toml + +# Tweak config.toml with appropriate values... (not everyting is needed for the base features to work) +nano config.toml + +# You'll need to have a built version of the catalog +mkdir -p ../builds/default/v3/ +curl https://app.yunohost.org/default/v3/apps.json > ../builds/default/v3/apps.json + +# You will also want to run list_builder.py to initialize the .apps_cache (at least for a few apps, you can Ctrl+C after a while) +pushd .. + python3 list_builder.py +popd +``` + +And then start the dev server: + +``` +source venv/bin/activate +FLASK_APP=app.py FLASK_ENV=development flask run +``` + +## Translation + +It's based on Flask-Babel : https://python-babel.github.io/ + +``` +source venv/bin/activate +pybabel extract --ignore-dirs venv -F babel.cfg -o messages.pot . + +# If working on a new locale : initialize it (in this example: fr) +pybabel init -i messages.pot -d translations -l fr +# Otherwise, update the existing .po: +pybabel update -i messages.pot -d translations + +# ... translate stuff in translations//LC_MESSAGES/messages.po +# then compile: +pybabel compile -d translations +``` diff --git a/store/__init__.py b/store/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/store/app.py b/store/app.py new file mode 100644 index 00000000..a4e06aee --- /dev/null +++ b/store/app.py @@ -0,0 +1,473 @@ +import time +import re +import toml +import base64 +import hashlib +import hmac +import os +import string +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 flask_babel import Babel +from flask_babel import gettext as _ +from github import Github, InputGitAuthor +from .utils import ( + get_locale, + get_catalog, + get_wishlist, + get_stars, + get_app_md_and_screenshots, +) + +app = Flask(__name__, static_url_path="/assets", static_folder="assets") + +try: + config = toml.loads(open("config.toml").read()) +except Exception as e: + print( + "You should create a config.toml with the appropriate key/values, cf config.toml.example" + ) + sys.exit(1) + +mandatory_config_keys = [ + "DISCOURSE_SSO_SECRET", + "DISCOURSE_SSO_ENDPOINT", + "COOKIE_SECRET", + "CALLBACK_URL_AFTER_LOGIN_ON_DISCOURSE", + "GITHUB_LOGIN", + "GITHUB_TOKEN", + "GITHUB_EMAIL", + "APPS_CACHE", +] + +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 + app.config["TEMPLATES_AUTO_RELOAD"] = True + +# This is the secret key used for session signing +app.secret_key = config["COOKIE_SECRET"] + +babel = Babel(app, locale_selector=get_locale) + + +@app.template_filter("localize") +def localize(d): + if not isinstance(d, dict): + return d + else: + locale = get_locale() + if locale in d: + return d[locale] + else: + return d["en"] + + +############################################################################### + + +@app.route("/favicon.ico") +def favicon(): + return send_from_directory("assets", "favicon.png") + + +@app.route("/") +def index(): + return render_template( + "index.html", + locale=get_locale(), + user=session.get("user", {}), + catalog=get_catalog(), + ) + + +@app.route("/catalog") +def browse_catalog(): + return render_template( + "catalog.html", + locale=get_locale(), + init_sort=request.args.get("sort"), + init_search=request.args.get("search"), + init_category=request.args.get("category"), + init_starsonly=request.args.get("starsonly"), + user=session.get("user", {}), + catalog=get_catalog(), + timestamp_now=int(time.time()), + stars=get_stars(), + ) + + +@app.route("/popularity.json") +def popularity_json(): + return {app: len(stars) for app, stars in get_stars().items()} + + +@app.route("/app/") +def app_info(app_id): + infos = get_catalog()["apps"].get(app_id) + app_folder = os.path.join(config["APPS_CACHE"], app_id) + if not infos or not os.path.exists(app_folder): + return f"App {app_id} not found", 404 + + get_app_md_and_screenshots(app_folder, infos) + + return render_template( + "app.html", + locale=get_locale(), + user=session.get("user", {}), + app_id=app_id, + infos=infos, + catalog=get_catalog(), + stars=get_stars(), + ) + + +@app.route("/app//") +def star_app(app_id, action): + assert action in ["star", "unstar"] + if app_id not in get_catalog()["apps"] and app_id not in get_wishlist(): + return _("App %(app_id) not found", app_id=app_id), 404 + if not session.get("user", {}): + return _("You must be logged in to be able to star an app"), 401 + + app_star_folder = os.path.join(".stars", app_id) + app_star_for_this_user = os.path.join( + ".stars", app_id, session.get("user", {})["id"] + ) + + if not os.path.exists(app_star_folder): + os.mkdir(app_star_folder) + + if action == "star": + open(app_star_for_this_user, "w").write("") + elif action == "unstar": + try: + os.remove(app_star_for_this_user) + except FileNotFoundError: + pass + + if app_id in get_catalog()["apps"]: + return redirect(f"/app/{app_id}") + else: + return redirect("/wishlist") + + +@app.route("/wishlist") +def browse_wishlist(): + return render_template( + "wishlist.html", + init_sort=request.args.get("sort"), + init_search=request.args.get("search"), + init_starsonly=request.args.get("starsonly"), + locale=get_locale(), + user=session.get("user", {}), + wishlist=get_wishlist(), + stars=get_stars(), + ) + + +@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", + locale=get_locale(), + user=session.get("user", {}), + csrf_token=None, + successmsg=None, + errormsg=errormsg, + ) + + csrf_token = request.form["csrf_token"] + + if csrf_token != session.get("csrf_token"): + errormsg = _("Invalid CSRF token, please refresh the form and try again") + return render_template( + "wishlist_add.html", + locale=get_locale(), + user=session.get("user", {}), + csrf_token=csrf_token, + successmsg=None, + 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 description should be at least 5 characters"), + ), + ( + len(description) <= 100, + _("App description 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", + locale=get_locale(), + user=session.get("user", {}), + csrf_token=csrf_token, + successmsg=None, + errormsg=errormsg, + ) + + slug = slugify(name) + github = Github(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", + locale=get_locale(), + user=session.get("user", {}), + csrf_token=csrf_token, + successmsg=None, + errormsg=_( + "An entry with the name %(slug) already exists in the wishlist", + slug=slug, + ), + ) + + 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 exception as e: + print("... Failed to create branch ?") + print(e) + errormsg = _( + "Failed to create the pull request to add the app to the wishlist ... please report the issue to the yunohost team" + ) + return render_template( + "wishlist_add.html", + locale=get_locale(), + user=session.get("user", {}), + csrf_token=csrf_token, + successmsg=None, + errormsg=errormsg, + ) + + message = f"Add {name} to wishlist" + repo.update_file( + "wishlist.toml", + message=message, + content=new_wishlist_rawtoml, + 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 + +Proposed by **{session['user']['username']}** + +- [ ] 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 = repo.create_pull( + title=message, + body=body, + head=new_branch, + base="app-store", # FIXME app-store -> repo.default_branch + ) + + url = f"https://github.com/YunoHost/apps/pull/{pr.number}" + + successmsg = _( + "Your proposed app has succesfully been submitted. It must now be validated by the YunoHost team. You can track progress here: %(url)s", + url=url, + ) + return render_template( + "wishlist_add.html", + locale=get_locale(), + user=session.get("user", {}), + successmsg=successmsg, + ) + else: + letters = string.ascii_lowercase + string.digits + csrf_token = ''.join(random.choice(letters) for i in range(16)) + session["csrf_token"] = csrf_token + return render_template( + "wishlist_add.html", + locale=get_locale(), + user=session.get("user", {}), + csrf_token=csrf_token, + successmsg=None, + errormsg=None, + ) + + +############################################################################### +# Session / SSO using Discourse # +############################################################################### + + +@app.route("/login_using_discourse") +def login_using_discourse(): + """ + Send auth request to Discourse: + """ + + ( + nonce, + url, + uri_to_redirect_to_after_login, + ) = create_nonce_and_build_url_to_login_on_discourse_sso() + + session.clear() + session["nonce"] = nonce + if uri_to_redirect_to_after_login: + session["uri_to_redirect_to_after_login"] = uri_to_redirect_to_after_login + + return redirect(url) + + +@app.route("/sso_login_callback") +def sso_login_callback(): + response = base64.b64decode(request.args["sso"].encode()).decode() + user_data = urllib.parse.parse_qs(response) + if user_data["nonce"][0] != session.get("nonce"): + return "Invalid nonce", 401 + + uri_to_redirect_to_after_login = session.get("uri_to_redirect_to_after_login") + + session.clear() + session["user"] = { + "id": user_data["external_id"][0], + "username": user_data["username"][0], + "avatar_url": user_data["avatar_url"][0], + } + + if uri_to_redirect_to_after_login: + return redirect("/" + uri_to_redirect_to_after_login) + else: + return redirect("/") + + +@app.route("/logout") +def logout(): + session.clear() + + # Only use the current referer URI if it's on the same domain as the current route + # to avoid XSS or whatever... + referer = request.environ.get("HTTP_REFERER") + if referer: + if referer.startswith("http://"): + referer = referer[len("http://") :] + if referer.startswith("https://"): + referer = referer[len("https://") :] + if "/" not in referer: + referer = referer + "/" + + domain, uri = referer.split("/", 1) + if domain == request.environ.get("HTTP_HOST"): + return redirect("/" + uri) + + return redirect("/") + + +def create_nonce_and_build_url_to_login_on_discourse_sso(): + """ + Redirect the user to DISCOURSE_ROOT_URL/session/sso_provider?sso=URL_ENCODED_PAYLOAD&sig=HEX_SIGNATURE + """ + + nonce = "".join([str(random.randint(0, 9)) for i in range(99)]) + + # Only use the current referer URI if it's on the same domain as the current route + # to avoid XSS or whatever... + referer = request.environ.get("HTTP_REFERER") + uri_to_redirect_to_after_login = None + if referer: + if referer.startswith("http://"): + referer = referer[len("http://") :] + if referer.startswith("https://"): + referer = referer[len("https://") :] + if "/" not in referer: + referer = referer + "/" + + domain, uri = referer.split("/", 1) + if domain == request.environ.get("HTTP_HOST"): + uri_to_redirect_to_after_login = uri + + 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( + config["DISCOURSE_SSO_SECRET"].encode(), + msg=payload.encode(), + digestmod=hashlib.sha256, + ).hexdigest() + data = {"sig": sig, "sso": payload} + url = f"{config['DISCOURSE_SSO_ENDPOINT']}?{urllib.parse.urlencode(data)}" + + return nonce, url, uri_to_redirect_to_after_login diff --git a/store/assets/app_logo_placeholder.png b/store/assets/app_logo_placeholder.png new file mode 100644 index 00000000..97c19b9b Binary files /dev/null and b/store/assets/app_logo_placeholder.png differ diff --git a/store/assets/favicon.png b/store/assets/favicon.png new file mode 100644 index 00000000..8d60d823 Binary files /dev/null and b/store/assets/favicon.png differ diff --git a/store/assets/fetch_assets b/store/assets/fetch_assets new file mode 100644 index 00000000..99c250da --- /dev/null +++ b/store/assets/fetch_assets @@ -0,0 +1,13 @@ +# Download standalone tailwind to compile what we need +wget https://github.com/tailwindlabs/tailwindcss/releases/download/v3.3.3/tailwindcss-linux-x64 +chmod +x tailwindcss-linux-x64 +./tailwindcss-linux-x64 --input tailwind-local.css --output tailwind.css --minify + +curl https://cdn.jsdelivr.net/npm/fork-awesome@1.2.0/css/fork-awesome.min.css > fork-awesome.min.css +sed -i 's@../fonts/@@g' ./fork-awesome.min.css +curl https://cdn.jsdelivr.net/npm/fork-awesome@1.2.0/fonts/forkawesome-webfont.woff2?v=1.2.0 > forkawesome-webfont.woff2 +curl https://cdn.jsdelivr.net/npm/fork-awesome@1.2.0/fonts/forkawesome-webfont.woff?v=1.2.0 > forkawesome-webfont.woff +curl https://cdn.jsdelivr.net/npm/fork-awesome@1.2.0/fonts/forkawesome-webfont.ttf?v=1.2.0 > forkawesome-webfont.ttf + +curl https://raw.githubusercontent.com/YunoHost/doc/master/images/logo_roundcorner.png > ynh_logo_roundcorner.png +curl https://raw.githubusercontent.com/YunoHost/doc/master/images/ynh_logo_black.svg > ynh_logo_black.svg diff --git a/store/assets/horizontal-yunohost.svg b/store/assets/horizontal-yunohost.svg new file mode 100644 index 00000000..9b9b4ec5 --- /dev/null +++ b/store/assets/horizontal-yunohost.svg @@ -0,0 +1,38 @@ + +image/svg+xml diff --git a/store/assets/tailwind-local.css b/store/assets/tailwind-local.css new file mode 100644 index 00000000..787ba1d2 --- /dev/null +++ b/store/assets/tailwind-local.css @@ -0,0 +1,37 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer utilities { + .btn { + @apply text-sm font-medium rounded-md px-4 py-2 transition; + } + .btn-sm { + @apply text-xs font-medium rounded-md px-2 py-2 transition; + } + .btn-success { + @apply text-white bg-green-500 hover:bg-green-700; + } + .btn-primary { + @apply text-white bg-blue-500 hover:bg-blue-700; + } + .btn-link { + @apply bg-gray-100 hover:bg-gray-200; + } + .btn-primary-outline { + @apply border text-blue-600 border-blue-500 hover:text-blue-400; + } + .from-markdown p { + @apply mb-2; + } + .from-markdown h3 { + @apply text-xl mb-1 font-semibold; + } + .from-markdown ul { + padding: revert; + list-style: disc; + } + .from-markdown a { + @apply text-blue-600; + } +} diff --git a/store/assets/tailwind.config.js b/store/assets/tailwind.config.js new file mode 100644 index 00000000..c5b0b2fc --- /dev/null +++ b/store/assets/tailwind.config.js @@ -0,0 +1,17 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['../templates/*.html'], + theme: { + extend: {}, + }, + plugins: [ + require('@tailwindcss/forms'), + ], + safelist: [ + 'safelisted', + { + pattern: /^(text-[a-z]+-600|border-[a-z]+-400)$/, + }, + ] +} + diff --git a/store/babel.cfg b/store/babel.cfg new file mode 100644 index 00000000..759e805a --- /dev/null +++ b/store/babel.cfg @@ -0,0 +1,2 @@ +[python: **.py] +[jinja2: **/templates/**.html] diff --git a/store/config.toml.example b/store/config.toml.example new file mode 100644 index 00000000..e85497d6 --- /dev/null +++ b/store/config.toml.example @@ -0,0 +1,9 @@ +COOKIE_SECRET = "abcdefghijklmnopqrstuvwxyz1234567890" +# This secret is configured in Discourse +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" +GITHUB_LOGIN = "yunohost-bot" +GITHUB_EMAIL = "yunohost [at] yunohost.org" # Replace the [at] by actual @ +GITHUB_TOKEN = "superSecretToken" +APPS_CACHE = "../.apps_cache/" diff --git a/store/gunicorn.py b/store/gunicorn.py new file mode 100644 index 00000000..5346bc7d --- /dev/null +++ b/store/gunicorn.py @@ -0,0 +1,13 @@ +import os +install_dir = os.path.dirname(__file__) +command = f'{install_dir}/venv/bin/gunicorn' +pythonpath = install_dir +workers = 4 +user = 'appstore' +bind = f'unix:{install_dir}/sock' +pid = '/run/gunicorn/appstore-pid' +errorlog = '/var/log/appstore/error.log' +accesslog = '/var/log/appstore/access.log' +access_log_format = '%({X-Real-IP}i)s %({X-Forwarded-For}i)s %(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' +loglevel = 'warning' +capture_output = True diff --git a/store/nginx.conf.example b/store/nginx.conf.example new file mode 100644 index 00000000..590224c3 --- /dev/null +++ b/store/nginx.conf.example @@ -0,0 +1,15 @@ +location / { + try_files $uri @appstore; +} + +location /assets { + alias __INSTALL_DIR__/assets/; +} + +location @appstore { + proxy_pass http://unix:__INSTALL_DIR__/sock; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} diff --git a/store/requirements.txt b/store/requirements.txt new file mode 100644 index 00000000..d572e041 --- /dev/null +++ b/store/requirements.txt @@ -0,0 +1,9 @@ +Flask==2.3.2 +python-slugify +PyGithub +toml +pycmarkgfm +gunicorn +emoji +Babel +Flask-Babel diff --git a/store/systemd.conf.example b/store/systemd.conf.example new file mode 100644 index 00000000..766247fa --- /dev/null +++ b/store/systemd.conf.example @@ -0,0 +1,53 @@ +[Unit] +Description=appstore gunicorn daemon +After=network.target + +[Service] +PIDFile=/run/gunicorn/appstore-pid +User=appstore +Group=appstore +WorkingDirectory=__INSTALL_DIR__ +ExecStart=__INSTALL_DIR__/venv/bin/gunicorn -c __INSTALL_DIR__/gunicorn.py app:app +ExecReload=/bin/kill -s HUP $MAINPID +ExecStop=/bin/kill -s TERM $MAINPID +StandardOutput=append:/var/log/appstore/appstore.log +StandardError=inherit + +# Sandboxing options to harden security +# Depending on specificities of your service/app, you may need to tweak these +# .. but this should be a good baseline +# Details for these options: https://www.freedesktop.org/software/systemd/man/systemd.exec.html +NoNewPrivileges=yes +PrivateTmp=yes +PrivateDevices=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +RestrictNamespaces=yes +RestrictRealtime=yes +DevicePolicy=closed +ProtectClock=yes +ProtectHostname=yes +ProtectProc=invisible +ProtectSystem=full +ProtectControlGroups=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +LockPersonality=yes +SystemCallArchitectures=native +SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap @cpu-emulation +# @privileged # (not sure why this need to be removed...) + +# Denying access to capabilities that should not be relevant for webapps +# Doc: https://man7.org/linux/man-pages/man7/capabilities.7.html +CapabilityBoundingSet=~CAP_RAWIO CAP_MKNOD +CapabilityBoundingSet=~CAP_AUDIT_CONTROL CAP_AUDIT_READ CAP_AUDIT_WRITE +CapabilityBoundingSet=~CAP_SYS_BOOT CAP_SYS_TIME CAP_SYS_MODULE CAP_SYS_PACCT +CapabilityBoundingSet=~CAP_LEASE CAP_LINUX_IMMUTABLE CAP_IPC_LOCK +CapabilityBoundingSet=~CAP_BLOCK_SUSPEND CAP_WAKE_ALARM +CapabilityBoundingSet=~CAP_SYS_TTY_CONFIG +CapabilityBoundingSet=~CAP_MAC_ADMIN CAP_MAC_OVERRIDE +CapabilityBoundingSet=~CAP_NET_ADMIN CAP_NET_BROADCAST CAP_NET_RAW +CapabilityBoundingSet=~CAP_SYS_ADMIN CAP_SYS_PTRACE CAP_SYSLOG + + +[Install] +WantedBy=multi-user.target diff --git a/store/templates/app.html b/store/templates/app.html new file mode 100644 index 00000000..c738d2f0 --- /dev/null +++ b/store/templates/app.html @@ -0,0 +1,147 @@ +{% extends "base.html" %} +{% block title %} +{{ infos['manifest']['name'] }} +{% endblock %} +{% block main %} +
+ + + + {{ _('Logo for %(app)s',app=infos['manifest']['name']) }} +

12 %}text-2xl{% else %}text-3xl{% endif %} font-bold text-gray-900">{{ infos["manifest"]["name"] }}

+ + {% if infos['category'] %} + + {{ catalog['categories'][infos['category']]['title']|localize|lower }} + + {% endif %} + + {% if infos['level'] == "?" or infos["level"]|int <= 4 %} + + + + {% elif infos['level'] == 8 %} + + + + {% endif %} + +
+ + + +
+ + + + {% set this_app_stars = stars.get(app_id, {})|length %} + {% if user %} + {% set user_starred_this_app = user['id'] in stars.get(app_id, {}) %} + {% else %} + {% set user_starred_this_app = False %} + {% endif %} + + + {% if not user_starred_this_app %} + {{ this_app_stars }} + + + + {% else %} + {{ this_app_stars }} + + + + {% endif %} + + {% if infos["manifest"]["upstream"]["demo"] %} + + + + {{ _("Demo") }} + + {% endif %} + + Install
with
+ YunoHost +
+
+ +
+ +

{{ _("Current version: %(version)s", version=infos["manifest"]["version"]) }}

+ {% if infos["potential_alternative_to"] %} +

{{ _("Potential alternative to: %(alternatives)s", alternatives=infos["potential_alternative_to"]|join(', ')) }}

+ {% endif %} + +
{{ infos["full_description_html"]|safe }}
+ + {% if infos["screenshot"] %} + {{ _( + {% endif %} + + {% if infos["manifest"]["integration"]["architectures"] != "all" %} +
+ {{ _("This app is only compatible with these specific architectures: %(archs)s", archs=infos["manifest"]["integration"]["architectures"]|join(', ')) }} +
+ {% endif %} + + {% if infos["manifest"]["integration"]["ram"]["build_binary"] >= 500 * 1024 * 1024 %} +
+ {{ _("This app requires an unusual amount of RAM to install: %(ram)s", ram=infos["manifest"]["integration"]["ram"]["build"]) }} +
+ {% endif %} + + {% if infos["pre_install_html"] %} +
+

{{ _("Important infos before installing") }}

+
{{ infos["pre_install_html"] | safe }}
+
+ {% endif %} + + {% if infos["antifeatures"] %} +

{{ _("Anti-features") }}

+

{{ _("(This app has features you may not like)") }}

+
+
    + {% for antifeature in infos["antifeatures"] %} +
  • {{ catalog['antifeatures'][antifeature]['description']|localize }}
  • + {% endfor %} +
+
+ {% endif %} + + +

{{ _("Useful links") }}

+
+ {% set upstream = infos["manifest"]["upstream"] %} + {{ _("License: %(license)s", license=upstream.license) }} + {% if upstream.website %} {{ _(" Official website") }}{% endif %} + {% if upstream.admindoc %} {{ _("Official admin documentation") }}{% endif %} + {% if upstream.userdoc %} {{ _("Official user documentation") }}{% endif %} + {% if upstream.code %} {{ _("Official code repository") }}{% endif %} + {{ _("YunoHost package repository") }} +
+
+{% endblock %} diff --git a/store/templates/base.html b/store/templates/base.html new file mode 100644 index 00000000..ecc5afd8 --- /dev/null +++ b/store/templates/base.html @@ -0,0 +1,209 @@ + + + + + {{ _("YunoHost app store") }} | {% block title %}{% endblock %} + + + + + + + +
+
+ + {{ _("Home") }} + YunoHost Logo + + +
+ + +
+ + +
+ + +
+
+
+
+
+ +
+ {% block main %} + {% endblock %} +
+ + + + + + + diff --git a/store/templates/catalog.html b/store/templates/catalog.html new file mode 100644 index 00000000..67a591ee --- /dev/null +++ b/store/templates/catalog.html @@ -0,0 +1,316 @@ +{% macro appCard(app, infos, timestamp_now, catalog) -%} + +{% set this_app_stars = stars.get(app, {})|length %} +{% if user %} + {% set user_starred_this_app = user['id'] in stars.get(app, {}) %} +{% else %} + {% set user_starred_this_app = False %} +{% endif %} + +
+ +
+
+ {{ _('Logo for %(app)s',app=infos['manifest']['name']) }} +
+
+ +

+ {{ infos['manifest']['name'] }} +

+ + {% if infos['level'] == "?" or infos["level"]|int <= 4 %} + + {% elif infos['level'] == 8 %} + + {% endif %} + + {{ this_app_stars }} + + + +
+

+ {{ infos['manifest']['description']|localize }} +

+ + {% if infos['category'] %} + + {{ catalog['categories'][infos['category']]['title']|localize|lower }} + + {% endif %} +
+
+
+
+{%- endmacro %} + +{% extends "base.html" %} +{% block title %} +{{ _("Application Catalog") }} +{% endblock %} +{% block main %} +
+

+ {{ _("Application Catalog") }} +

+
+
+
+
+ + + + + + + +
+
+ + +
+
+ +
+
+ {{ _("Sort by") }} + +
+
+ + {{ _("Show only apps you starred") }} +
+
+
+ +
+ {% for app, infos in catalog['apps'].items() %} + {% if infos['level'] and infos['level'] != "?" and infos['level'] > 4 %} + {{ appCard(app, infos, timestamp_now, catalog) }} + {% endif %} + {% endfor %} +
+ + + + +
+

+ {{ _("Applications currently flagged as broken") }} +

+

+ {{ _("These are apps which failed our automatic tests.") }}
+ {{ _("This is usually a temporary situation which requires packagers to fix something in the app.") }} +

+
+ +
+ {% for app, infos in catalog['apps'].items() %} + {% if not infos['level'] or infos["level"] == "?" or infos['level'] <= 4 %} + {{ appCard(app, infos, timestamp_now, catalog) }} + {% endif %} + {% endfor %} +
+ + + + +{% endblock %} diff --git a/store/templates/index.html b/store/templates/index.html new file mode 100644 index 00000000..ae03a647 --- /dev/null +++ b/store/templates/index.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block title %} +{{ _("Home") }} +{% endblock %} +{% block main %} + +
+ YunoHost logo +

+ {{ _("Application Store") }} +

+
+ + +{% endblock %} diff --git a/store/templates/wishlist.html b/store/templates/wishlist.html new file mode 100644 index 00000000..3aca66a8 --- /dev/null +++ b/store/templates/wishlist.html @@ -0,0 +1,280 @@ +{% extends "base.html" %} +{% block title %} +{{ _("Application Wishlist") }} +{% endblock %} +{% block main %} +
+

+ {{ _("Application Wishlist") }} +

+

{{ _("The wishlist is the place where people can collectively suggest and vote for apps that they would like to see packaged and made available in YunoHost's official apps catalog. Nevertheless, the fact that apps are listed here should by no mean be interpreted as a fact that the YunoHost project plans to integrate it, and is merely a source of inspiration for packaging volunteers.") }}

+
+ +
+
+
+ + + + + + + +
+ + +
+
+
+ {{ _("Sort by") }} + +
+
+ + {{ _("Show only apps you starred") }} +
+
+ +
+ +
+ + + + + + + + + + + + + {% for app, infos in wishlist.items() %} + {% set this_app_stars = stars.get(app, {})|length %} + {% if user %} + {% set user_starred_this_app = user['id'] in stars.get(app, {}) %} + {% else %} + {% set user_starred_this_app = False %} + {% endif %} + + + + + + + + {% endfor %} + +
+ {{ infos['name'] }} + {{ infos['description'] }} + {% if infos['website'] %} + + + + {% endif %} + + {% if infos['upstream'] %} + + + + {% endif %} + + + + {% if not user_starred_this_app %} + {{ this_app_stars }} + + + + {% else %} + {{ this_app_stars }} + + + + {% endif %} + +
+ + +
+ + + +{% endblock %} diff --git a/store/templates/wishlist_add.html b/store/templates/wishlist_add.html new file mode 100644 index 00000000..5c66e9cc --- /dev/null +++ b/store/templates/wishlist_add.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} +{% block title %} +{{ _("Suggest an app") }} +{% endblock %} +{% block main %} +
+

+ {{ _("Suggest an application to be added to YunoHost's catalog") }} +

+
+ + +
+ + {% if successmsg %} + + {% else %} + + + {% 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.") }} + + + +
+ {% endif %} +
+ +{% endblock %} diff --git a/store/translations/fr/LC_MESSAGES/messages.mo b/store/translations/fr/LC_MESSAGES/messages.mo new file mode 100644 index 00000000..1cac11a2 Binary files /dev/null and b/store/translations/fr/LC_MESSAGES/messages.mo differ diff --git a/store/translations/fr/LC_MESSAGES/messages.po b/store/translations/fr/LC_MESSAGES/messages.po new file mode 100644 index 00000000..718e7770 --- /dev/null +++ b/store/translations/fr/LC_MESSAGES/messages.po @@ -0,0 +1,409 @@ +# French translations for PROJECT. +# Copyright (C) 2023 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2023. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2023-09-19 17:04+0200\n" +"PO-Revision-Date: 2023-09-05 19:50+0200\n" +"Last-Translator: FULL NAME \n" +"Language: fr\n" +"Language-Team: fr \n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.12.1\n" + +#: app.py:140 +msgid "App %(app_id) not found" +msgstr "L'app %(app_id) n'a pas été trouvée" + +#: app.py:142 +msgid "You must be logged in to be able to star an app" +msgstr "Vous devez être connecté·e pour mettre une app en favoris" + +#: app.py:185 +msgid "You must be logged in to submit an app to the wishlist" +msgstr "Vous devez être connecté·e pour proposer une app pour la liste de souhaits" + +#: app.py:200 +msgid "Invalid CSRF token, please refresh the form and try again" +msgstr "Jeton CSRF invalide, prière de rafraîchir la page et retenter" + +#: app.py:216 +msgid "App name should be at least 3 characters" +msgstr "Le nom d'app devrait contenir au moins 3 caractères" + +#: app.py:217 +msgid "App name should be less than 30 characters" +msgstr "Le nom d'app devrait contenir moins de 30 caractères" + +#: app.py:220 +msgid "App description should be at least 5 characters" +msgstr "La description de l'app devrait contenir au moins 5 caractères" + +#: app.py:224 +msgid "App description should be less than 100 characters" +msgstr "La description de l'app devrait contenir moins de 100 caractères" + +#: app.py:228 +msgid "Upstream code repo URL should be at least 10 characters" +msgstr "L'URL du dépôt de code devrait contenir au moins 10 caractères" + +#: app.py:232 +msgid "Upstream code repo URL should be less than 150 characters" +msgstr "L'URL du dépôt de code devrait contenir moins de 150 caractères" + +#: app.py:234 +msgid "Website URL should be less than 150 characters" +msgstr "L'URL du site web devrait contenir moins de 150 caractères" + +#: app.py:237 +msgid "App name contains special characters" +msgstr "Le nom de l'app contiens des caractères spéciaux" + +#: app.py:270 +msgid "An entry with the name %(slug) already exists in the wishlist" +msgstr "Une entrée nommée $(slug) existe déjà dans la liste de souhaits" + +#: app.py:295 +msgid "" +"Failed to create the pull request to add the app to the wishlist ... " +"please report the issue to the yunohost team" +msgstr "" +"Échec de la création de la demande d'intégration de l'app dans la liste " +"de souhaits ... merci de rapport le problème à l'équipe YunoHost" + +#: app.py:340 +#, python-format +msgid "" +"Your proposed app has succesfully been submitted. It must now be " +"validated by the YunoHost team. You can track progress here: %(url)s" +msgstr "" +"Un demande d'intégration à la liste de souhaits a bien été créée pour " +"cette app. Elle doit maintenant être validée par l'équipe YunoHost. Vous " +"pouvez suivre cette demande ici: %(url)s" + +#: templates/app.html:10 templates/catalog.html:23 +#, python-format +msgid "Logo for %(app)s" +msgstr "Logo pour %(app)s" + +#: templates/app.html:30 templates/app.html:31 templates/catalog.html:41 +#: templates/catalog.html:42 +msgid "" +"This app is currently flagged as broken because it failed our automatic " +"tests." +msgstr "" +"Cette app est actuellement marquée comme cassée ou de mauvaise qualité " +"car elle ne passe pas nos tests automatisés." + +#: templates/app.html:30 templates/app.html:31 templates/catalog.html:41 +#: templates/catalog.html:42 templates/catalog.html:169 +msgid "" +"This is usually a temporary situation which requires packagers to fix " +"something in the app." +msgstr "" +"Il s'agit généralement d'une situation temporaire qui requiert que des " +"packageur·euse·s corrigent un problème dans l'app." + +#: templates/app.html:37 templates/app.html:38 templates/catalog.html:46 +#: templates/catalog.html:47 +msgid "" +"This app has been good quality according to our automatic tests over at " +"least one year." +msgstr "Cette app est de bonne qualité d'après nos tests automatisés depuis au moins un an." + +#: templates/app.html:81 +msgid "Try the demo" +msgstr "Essayer la démo" + +#: templates/app.html:82 +msgid "Demo" +msgstr "Démo" + +#: templates/app.html:85 +msgid "Install with YunoHost" +msgstr "Installer avec YunoHost" + +#: templates/app.html:93 +#, python-format +msgid "Current version: %(version)s" +msgstr "Version actuelle: %(version)s" + +#: templates/app.html:95 +#, python-format +msgid "Potential alternative to: %(alternatives)s" +msgstr "Alternative potentielle à : %(alternatives)s" + +#: templates/app.html:101 +#, python-format +msgid "Screenshot for %(app)s" +msgstr "Capture d'écran pour %(app)s" + +#: templates/app.html:106 +#, python-format +msgid "This app is only compatible with these specific architectures: %(archs)s" +msgstr "" +"Cette app est uniquement compatible avec les architectures suivantes : " +"%(archs)s" + +#: templates/app.html:112 +#, python-format +msgid "This app requires an unusual amount of RAM to install: %(ram)s" +msgstr "" +"Cette app requiert une quantité inhabituelle de RAM pour être installée :" +" %(ram)s" + +#: templates/app.html:118 +msgid "Important infos before installing" +msgstr "Informations importantes avant l'installation" + +#: templates/app.html:124 +msgid "Anti-features" +msgstr "Anti-fonctionnalités" + +#: templates/app.html:125 +msgid "(This app has features you may not like)" +msgstr "(Cette app a des spécificités que vous pourriez ne pas aimer)" + +#: templates/app.html:136 +msgid "Useful links" +msgstr "Liens utiles" + +#: templates/app.html:139 +#, python-format +msgid "License: %(license)s" +msgstr "Licence: %(license)s" + +#: templates/app.html:140 +msgid " Official website" +msgstr "Site officiel" + +#: templates/app.html:141 +msgid "Official admin documentation" +msgstr "Documentation officielle pour les admins" + +#: templates/app.html:142 +msgid "Official user documentation" +msgstr "Documentation officielle pour les utilisateur·ice·s" + +#: templates/app.html:143 +msgid "Official code repository" +msgstr "Dépôt de code officiel" + +#: templates/app.html:144 +msgid "YunoHost package repository" +msgstr "Dépôt de code du paquet YunoHost" + +#: templates/base.html:5 +msgid "YunoHost app store" +msgstr "Store d'apps de YunoHost" + +#: templates/base.html:56 templates/base.html:149 templates/index.html:3 +msgid "Home" +msgstr "Accueil" + +#: templates/base.html:65 templates/base.html:158 +msgid "Catalog" +msgstr "Catalogue" + +#: templates/base.html:71 templates/base.html:167 +msgid "Wishlist" +msgstr "Liste de souhaits" + +#: templates/base.html:84 templates/base.html:177 +msgid "YunoHost documentation" +msgstr "Documentation YunoHost" + +#: templates/base.html:92 templates/base.html:187 +msgid "Login using YunoHost's forum" +msgstr "Se connecter via le forum YunoHost" + +#: templates/base.html:122 templates/base.html:213 +msgid "Logout" +msgstr "Se déconnecter" + +#: templates/base.html:135 +msgid "Toggle menu" +msgstr "Activer le menu" + +#: templates/catalog.html:75 templates/catalog.html:80 +msgid "Application Catalog" +msgstr "Catalogue d'applications" + +#: templates/catalog.html:86 templates/wishlist.html:16 +msgid "Search" +msgstr "Recherche" + +#: templates/catalog.html:91 templates/wishlist.html:21 +msgid "Search for..." +msgstr "Rechercher..." + +#: templates/catalog.html:107 +msgid "All apps" +msgstr "Toutes les apps" + +#: templates/catalog.html:117 templates/wishlist.html:39 +msgid "Sort by" +msgstr "Trier par" + +#: templates/catalog.html:123 templates/wishlist.html:45 +msgid "Alphabetical" +msgstr "Alphabétique" + +#: templates/catalog.html:124 +msgid "Newest" +msgstr "Nouveauté" + +#: templates/catalog.html:125 templates/wishlist.html:46 +#: templates/wishlist.html:78 +msgid "Popularity" +msgstr "Popularité" + +#: templates/catalog.html:128 templates/wishlist.html:49 +msgid "Requires to be logged-in" +msgstr "Nécessite d'être connecté·e" + +#: templates/catalog.html:130 templates/catalog.html:139 +#: templates/wishlist.html:51 templates/wishlist.html:60 +msgid "Show only apps you starred" +msgstr "Montrer uniquement mes favoris" + +#: templates/catalog.html:155 templates/wishlist.html:152 +msgid "No results found." +msgstr "Aucun résultat trouvé." + +#: templates/catalog.html:158 +msgid "Not finding what you are looking for?" +msgstr "Vous ne trouvez pas ce que vous cherchez ?" + +#: templates/catalog.html:159 +msgid "Checkout the wishlist!" +msgstr "Jetez un oeil à la liste de souhaits !" + +#: templates/catalog.html:165 +msgid "Applications currently flagged as broken" +msgstr "Applications actuellement marquées comme cassées" + +#: templates/catalog.html:168 +msgid "These are apps which failed our automatic tests." +msgstr "Il s'agit d'apps qui n'ont pas validé nos tests automatisés." + +#: templates/index.html:10 +msgid "Application Store" +msgstr "Store d'application" + +#: templates/index.html:21 +msgid "Browse all applications" +msgstr "Toutes les applications" + +#: templates/wishlist.html:3 templates/wishlist.html:8 +msgid "Application Wishlist" +msgstr "Liste de souhaits d'applications" + +#: templates/wishlist.html:10 +msgid "" +"The wishlist is the place where people can collectively suggest and vote " +"for apps that they would like to see packaged and made available in " +"YunoHost's official apps catalog. Nevertheless, the fact that apps are " +"listed here should by no mean be interpreted as a fact that the YunoHost " +"project plans to integrate it, and is merely a source of inspiration for " +"packaging volunteers." +msgstr "" +"La liste de souhaits est l'endroit où il est possible de collectivement " +"suggérer et voter pour des applications que vous aimeriez voir packagée " +"et intégrée dans le catalogue officiel de YunoHost. Néanmoins, le fait " +"que des apps soient listées ici ne devrait en aucun cas être interprété " +"comme le fait que le projet YunoHost prévoit leur intégration, et est " +"uniquement une source d'inspiration pour les packageur·euse·s bénévoles." + +#: templates/wishlist.html:33 templates/wishlist_add.html:3 +msgid "Suggest an app" +msgstr "Suggérer une app" + +#: templates/wishlist.html:71 templates/wishlist_add.html:57 +msgid "Name" +msgstr "Nom" + +#: templates/wishlist.html:74 +msgid "Description" +msgstr "Description" + +#: templates/wishlist.html:102 templates/wishlist.html:103 +msgid "Official website" +msgstr "Site officiel" + +#: templates/wishlist.html:114 templates/wishlist.html:115 +msgid "Code repository" +msgstr "Dépôt de code officiel" + +#: templates/wishlist.html:127 templates/wishlist.html:128 +msgid "Star this app" +msgstr "Étoiler cette app" + +#: templates/wishlist_add.html:8 +msgid "Suggest an application to be added to YunoHost's catalog" +msgstr "Suggérer une application à ajouter dans le catalogue de YunoHost" + +#: templates/wishlist_add.html:29 +msgid "You must first login to be allowed to submit an app to the wishlist" +msgstr "Vous devez être connecté·e pour proposer une app pour la liste de souhaits" + +#: templates/wishlist_add.html:37 +msgid "Please check the license of the app your are proposing" +msgstr "Merci de vérifier la licence de l'app que vous proposez" + +#: templates/wishlist_add.html:40 +msgid "" +"The YunoHost project will only package free/open-source software (with " +"possible case-by-case exceptions for apps which are not-totally-free)" +msgstr "" +"Le projet YunoHost intègrera uniquement des logiciels libre/open-source " +"(avec quelques possibles exceptions au cas-par-cas pour des apps qui ne " +"sont pas entièrement libres)" + +#: templates/wishlist_add.html:60 +msgid "App's description" +msgstr "Description de l'app" + +#: templates/wishlist_add.html:62 +msgid "Please be concise and focus on what the app does." +msgstr "Prière de rester concis et de se concentrer sur ce que l'app fait." + +#: templates/wishlist_add.html:62 +msgid "" +"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'." +msgstr "" +"Il n'est pas nécessaire de répéter '[App] est ...', ni que l'app est " +"libre/open-source (sinon, elle ne serait pas intégrable au catalogue). " +"Évitez les formulations marketing type 'le meilleur', ou les propriétés " +"vagues telles que 'facile', 'simple', 'léger'." + +#: templates/wishlist_add.html:64 +msgid "Project code repository" +msgstr "Dépôt de code officiel" + +#: templates/wishlist_add.html:67 +msgid "Project website" +msgstr "Site officiel" + +#: templates/wishlist_add.html:69 +msgid "" +"Please *do not* just copy-paste the code repository URL. If the project " +"has no proper website, then leave the field empty." +msgstr "" +"Prière de *ne pas* juste copier-coller l'URL du dépôt de code. Si le " +"projet n'a pas de vrai site web, laissez le champ vide." + +#: templates/wishlist_add.html:76 +msgid "Submit" +msgstr "Envoyer" + diff --git a/store/utils.py b/store/utils.py new file mode 100644 index 00000000..5dcd302b --- /dev/null +++ b/store/utils.py @@ -0,0 +1,168 @@ +import base64 +import os +import json +import toml +import subprocess +import pycmarkgfm +from emoji import emojize +from flask import request + + +AVAILABLE_LANGUAGES = ["en"] + os.listdir("translations") + + +def get_locale(): + # try to guess the language from the user accept + # The best match wins. + return request.accept_languages.best_match(AVAILABLE_LANGUAGES) or "en" + + +def get_catalog(): + path = "../builds/default/v3/apps.json" + mtime = os.path.getmtime(path) + if get_catalog.mtime_catalog != mtime: + get_catalog.mtime_catalog = mtime + + catalog = json.load(open(path)) + catalog["categories"] = {c["id"]: c for c in catalog["categories"]} + catalog["antifeatures"] = {c["id"]: c for c in catalog["antifeatures"]} + + category_color = { + "synchronization": "sky", + "publishing": "yellow", + "communication": "amber", + "office": "lime", + "productivity_and_management": "purple", + "small_utilities": "black", + "reading": "emerald", + "multimedia": "fuchsia", + "social_media": "rose", + "games": "violet", + "dev": "stone", + "system_tools": "black", + "iot": "orange", + "wat": "teal", + } + + for id_, category in catalog["categories"].items(): + category["color"] = category_color[id_] + + get_catalog.cache_catalog = catalog + + return get_catalog.cache_catalog + + +get_catalog.mtime_catalog = None +get_catalog() + + +def get_wishlist(): + path = "../wishlist.toml" + mtime = os.path.getmtime(path) + if get_wishlist.mtime_wishlist != mtime: + get_wishlist.mtime_wishlist = mtime + get_wishlist.cache_wishlist = toml.load(open(path)) + + return get_wishlist.cache_wishlist + + +get_wishlist.mtime_wishlist = None +get_wishlist() + + +def get_stars(): + checksum = ( + subprocess.check_output("find . -type f -printf '%T@,' | md5sum", shell=True) + .decode() + .split()[0] + ) + if get_stars.cache_checksum != checksum: + stars = {} + for folder, _, files in os.walk(".stars/"): + app_id = folder.split("/")[-1] + if not app_id: + continue + stars[app_id] = set(files) + get_stars.cache_stars = stars + get_stars.cache_checksum = checksum + + return get_stars.cache_stars + + +get_stars.cache_checksum = None +get_stars() + + +def human_to_binary(size: str) -> int: + symbols = ("K", "M", "G", "T", "P", "E", "Z", "Y") + factor = {} + for i, s in enumerate(symbols): + factor[s] = 1 << (i + 1) * 10 + + suffix = size[-1] + size = size[:-1] + + if suffix not in symbols: + raise Exception(f"Invalid size suffix '{suffix}', expected one of {symbols}") + + try: + size_ = float(size) + except Exception: + raise Exception(f"Failed to convert size {size} to float") + + return int(size_ * factor[suffix]) + + +def get_app_md_and_screenshots(app_folder, infos): + locale = get_locale() + + if locale != "en" and os.path.exists( + os.path.join(app_folder, "doc", f"DESCRIPTION_{locale}.md") + ): + description_path = os.path.join(app_folder, "doc", f"DESCRIPTION_{locale}.md") + elif os.path.exists(os.path.join(app_folder, "doc", "DESCRIPTION.md")): + description_path = os.path.join(app_folder, "doc", "DESCRIPTION.md") + else: + description_path = None + if description_path: + with open(description_path) as f: + infos["full_description_html"] = emojize( + pycmarkgfm.gfm_to_html(f.read()), language="alias" + ) + else: + infos["full_description_html"] = infos["manifest"]["description"][locale] + + if locale != "en" and os.path.exists( + os.path.join(app_folder, "doc", f"PRE_INSTALL_{locale}.md") + ): + pre_install_path = os.path.join(app_folder, "doc", f"PRE_INSTALL_{locale}.md") + elif os.path.exists(os.path.join(app_folder, "doc", "PRE_INSTALL.md")): + pre_install_path = os.path.join(app_folder, "doc", "PRE_INSTALL.md") + else: + pre_install_path = None + if pre_install_path: + with open(pre_install_path) as f: + infos["pre_install_html"] = emojize( + pycmarkgfm.gfm_to_html(f.read()), language="alias" + ) + + infos["screenshot"] = None + + screenshots_folder = os.path.join(app_folder, "doc", "screenshots") + + if os.path.exists(screenshots_folder): + with os.scandir(screenshots_folder) as it: + for entry in it: + ext = os.path.splitext(entry.name)[1].replace(".", "").lower() + if entry.is_file() and ext in ("png", "jpg", "jpeg", "webp", "gif"): + with open(entry.path, "rb") as img_file: + data = base64.b64encode(img_file.read()).decode("utf-8") + infos[ + "screenshot" + ] = f"data:image/{ext};charset=utf-8;base64,{data}" + break + + ram_build_requirement = infos["manifest"]["integration"]["ram"]["build"] + infos["manifest"]["integration"]["ram"]["build_binary"] = human_to_binary( + ram_build_requirement + ) diff --git a/tools/catalog_linter.py b/tools/catalog_linter.py index 6925cf81..3c5453e1 100755 --- a/tools/catalog_linter.py +++ b/tools/catalog_linter.py @@ -5,6 +5,7 @@ import sys from functools import cache from pathlib import Path from typing import Any, Dict, Generator, List, Tuple +from difflib import SequenceMatcher import jsonschema import toml @@ -30,6 +31,12 @@ def get_antifeatures() -> Dict[str, Any]: return toml.load(antifeatures_path) +@cache +def get_wishlist() -> Dict[str, Dict[str, str]]: + wishlist_path = APPS_ROOT / "wishlist.toml" + return toml.load(wishlist_path) + + def validate_schema() -> Generator[str, None, None]: with open(APPS_ROOT / "schemas" / "apps.toml.schema.json", encoding="utf-8") as file: apps_catalog_schema = json.load(file) @@ -46,6 +53,19 @@ def check_app(app: str, infos: Dict[str, Any]) -> Generator[Tuple[str, bool], No if infos["state"] != "working": return + # validate that the app is not (anymore?) in the wishlist + # we use fuzzy matching because the id in catalog may not be the same exact id as in the wishlist + # some entries are ignore-hard-coded, because e.g. radarr an readarr are really different apps... + ignored_wishlist_entries = ["readarr"] + wishlist_matches = [ + wish + for wish in get_wishlist() + if wish not in ignored_wishlist_entries + and SequenceMatcher(None, app, wish).ratio() > 0.9 + ] + if wishlist_matches: + yield f"app seems to be listed in wishlist: {wishlist_matches}", True + repo_name = infos.get("url", "").split("/")[-1] if repo_name != f"{app}_ynh": yield f"repo name should be {app}_ynh, not in {repo_name}", True diff --git a/wishlist.toml b/wishlist.toml new file mode 100644 index 00000000..20079705 --- /dev/null +++ b/wishlist.toml @@ -0,0 +1,1367 @@ +[access-to-memory-atom] +name = "Access to Memory (AtoM)" +description = "Standards-based archival description and access in a multilingual, multi-repository environment." +upstream = "https://github.com/artefactual/atom" +website = "https://www.accesstomemory.org/" + +[ajenti] +name = "Ajenti" +description = "A modular server admin panel" +upstream = "https://github.com/ajenti/ajenti/" +website = "https://ajenti.org" + +[akaunting] +name = "Akaunting" +description = "Manage payments/invoices/expenses" +upstream = "https://github.com/akaunting/akaunting" +website = "" + +[amara] +name = "Amara" +description = "Collaborative translation of subtitles for videosCollaborative translation of subtitles for videos" +upstream = "https://gitlab.com/hanklank/amara-archive" +website = "https://amara.org" + +[anki-sync-server] +name = "Anki Sync Server" +description = "a personal Anki server" +upstream = "https://github.com/ankicommunity/anki-sync-server" +website = "" + +[anonaddy] +name = "AnonAddy" +description = "Anonymous email forwarding - Create Unlimited Email Aliases" +upstream = "https://github.com/anonaddy/anonaddy" +website = "https://anonaddy.com/" + +[ansible-matrix-docker-deploy] +name = "Ansible Matrix Docker Deploy" +description = "Full Featured Matrix Server Setup with All Bridges and Integrations" +upstream = "https://github.com/spantaleev/matrix-docker-ansible-deploy" +website = "" + +[apache-superset] +name = "Apache Superset" +description = "A Data Visualization and Data Exploration Platform" +upstream = "https://github.com/apache/superset" +website = "https://superset.apache.org/" + +[appflowy] +name = "Appflowy" +description = "Alternative to Notion" +upstream = "https://github.com/AppFlowy-IO/appflowy" +website = "https://appflowy.io/" + +[archivematica] +name = "Archivematica" +description = "Mature digital preservation system designed to maintain standards-based, long-term access to collections of digital objects." +upstream = "https://github.com/artefactual/archivematica" +website = "https://www.archivematica.org/" + +[archivesspace] +name = "ArchivesSpace" +description = "Archives information management application for managing and providing Web access to archives, manuscripts and digital objects." +upstream = "https://github.com/archivesspace/archivesspace" +website = "https://archivesspace.org/" + +[ass] +name = "ass" +description = "ShareX upload server written in Node.js." +upstream = "https://github.com/tycrek/ass" +website = "" + +[astral] +name = "Astral" +description = "Organize Your GitHub Stars With Ease" +upstream = "https://github.com/astralapp/astral" +website = "https://astralapp.com/" + +[asqatasun] +name = "Asqatasun" +description = "Website analyser for web accessibility and SEO" +upstream = "https://gitlab.com/asqatasun/Asqatasun" +website = "https://asqatasun.org/" + +[azuracast] +name = "Azuracast" +description = "A Web Radio Management Suite" +upstream = "https://github.com/AzuraCast/AzuraCast" +website = "https://azuracast.com/" + +[backstage-io] +name = "Backstage.io" +description = "Enterprise Developer Portal with Eco-System" +upstream = "https://github.com/backstage/backstage" +website = "https://backstage.io/" + +[baserow] +name = "Baserow" +description = "No-code database tool, alternative to Airtable" +upstream = "https://gitlab.com/bramw/baserow" +website = "https://baserow.io/" + +[beatbump] +name = "Beatbump" +description = "An alternative frontend for YouTube Music" +upstream = "https://github.com/snuffyDev/Beatbump" +website = "https://beatbump.ml/home" + +[beeper] +name = "Beeper" +description = "A unified inbox for 15 chat networks." +upstream = "https://gitlab.com/beeper" +website = "https://www.beeper.com/" + +[bigbluebutton] +name = "BigBlueButton" +description = "Web conferencing system" +upstream = "https://github.com/bigbluebutton/bigbluebutton" +website = "https://bigbluebutton.org" + +[bitcartcc] +name = "BitcartCC" +description = "All-in-one cryptocurrency solution" +upstream = "https://github.com/bitcartcc/bitcart" +website = "https://bitcartcc.com" + +[bitmessage] +name = "Bitmessage" +description = "P2P communication protocol used to send encrypted messages" +upstream = "https://github.com/Bitmessage/PyBitmessage" +website = "https://bitmessage.org/" + +[blynk] +name = "Blynk" +description = "Blynk library for embedded hardware. Works with Arduino, ESP8266, Raspberry Pi, Intel Edison/Galileo, LinkIt ONE, Particle Core/Photon, Energia, ARM mbed, etc." +upstream = "https://github.com/blynkkk/blynk-library" +website = "" + +[borgwarehouse] +name = "BorgWarehouse" +description = "A fast and modern WebUI for a BorgBackup's central repository server" +upstream = "https://github.com/ravinou/borgwarehouse" +website = "https://borgwarehouse.com" + +[btcpay-server] +name = "BTCPay Server" +description = "Bitcoin payment processor" +upstream = "https://github.com/btcpayserver/btcpayserver" +website = "https://btcpayserver.org" + +[budibase] +name = "Budibase" +description = "Low code platform for creating internal apps, workflows, and admin panels in minutes." +upstream = "https://github.com/Budibase/budibase" +website = "https://budibase.com/" + +[cactus-comments] +name = "Cactus Comments" +description = "Federated comment system, to embed into your webpages, based on the Matrix protocol." +upstream = "https://gitlab.com/cactus-comments" +website = "https://cactus.chat/" + +[cagette] +name = "Cagette" +description = "A marketplace for local farmers and producers" +upstream = "https://github.com/CagetteNet/cagette" +website = "https://www.cagette.net/" + +[cal-com] +name = "Cal.com" +description = "Formerly Calendso. Volunteer shift management and meeting scheduling. Alternative to Calendly." +upstream = "https://github.com/calcom/cal.com" +website = "https://cal.com/" + +[changedetection-io] +name = "changedetection.io" +description = "Monitor changes in web pages" +upstream = "https://github.com/dgtlmoon/changedetection.io" +website = "" + +[chaskiq] +name = "Chaskiq" +description = "A full featured Live Chat, Support & Marketing platform, alternative to Intercom, Drift, Crisp" +upstream = "https://github.com/chaskiq/chaskiq" +website = "" + +[chatterbox] +name = "Chatterbox" +description = "Embedded live chat for customer service" +upstream = "https://github.com/vector-im/chatterbox" +website = "https://element.io/solutions/chatterbox-embedded-live-chat-for-customer-service" + +[chatwoot] +name = "Chatwoot" +description = "Customer engagement suite, an alternative to Intercom, Zendesk, Salesforce Service Cloud" +upstream = "https://github.com/chatwoot/chatwoot" +website = "https://www.chatwoot.com/docs/self-hosted/" + +[checkmk] +name = "Checkmk" +description = "Monitoring for networks, servers, clouds, containers and applications" +upstream = "https://github.com/tribe29/checkmk" +website = "https://checkmk.com/" + +[checkup] +name = "CheckUp" +description = "Distributed, lock-free, self-hosted health checks and status pages" +upstream = "https://github.com/sourcegraph/checkup" +website = "https://sourcegraph.github.io/checkup" + +[ckan] +name = "CKAN" +description = "A tool for making open data websites" +upstream = "https://github.com/ckan/ckan" +website = "https://ckan.org/" + +[cloudtube] +name = "CloudTube" +description = "CloudTube front-end for YouTube" +upstream = "https://git.sr.ht/~cadence/cloudtube" +website = "https://tube.cadence.moe/" + +[commafeed] +name = "Commafeed" +description = "RSS reader" +upstream = "https://github.com/Athou/commafeed" +website = "https://www.commafeed.com/" + +[coquelicot] +name = "Coquelicot" +description = "A “one-click” file sharing web application" +upstream = "" +website = "https://coquelicot.potager.org/" + +[cusdis] +name = "Cusdis" +description = "A lightweight, privacy-friendly comment system alternative to Disqus." +upstream = "https://github.com/djyde/cusdis" +website = "https://cusdis.com/" + +[dataverse] +name = "Dataverse" +description = "Find, share, cite, and preserve research data " +upstream = "https://github.com/IQSS/dataverse" +website = "https://dataverse.org" + +[davmail] +name = "DavMail" +description = "Gateway from OWA and O365 to IMAP, POP, and CalDav for email and calendars" +upstream = "https://github.com/mguessan/davmail" +website = "http://davmail.sourceforge.net/" + +[docker-registry] +name = "Docker-registry" +description = "The toolkit to pack, ship, store, and deliver container content" +upstream = "https://github.com/docker/distribution/" +website = "" + +[docspell] +name = "Docspell" +description = "Simple document organizer" +upstream = "https://github.com/eikek/docspell" +website = "" + +[docusaurus] +name = "Docusaurus" +description = "Static site generator/SPA to build documentations" +upstream = "https://github.com/facebook/docusaurus" +website = "" + +[drawpile] +name = "Drawpile" +description = "Collaborative drawing program that allows multiple users to sketch on the same canvas simultaneously" +upstream = "https://github.com/drawpile/Drawpile" +website = "https://drawpile.net" + +[earthstar-project] +name = "Earthstar-Project" +description = "Storage for private, distributed, offline-first applications. " +upstream = "https://github.com/earthstar-project/earthstar" +website = "https://earthstar-project.org/" + +[element-call] +name = "Element Call" +description = "Showcase for full mesh video chat powered by Matrix" +upstream = "https://github.com/vector-im/element-call" +website = "https://element.io/blog/element-call-beta-2-encryption-spatial-audio-walkie-talkie-mode-and-more/" + +[elk] +name = "Elk" +description = "A nimble Mastodon web client, also works with other Fediverse servers" +upstream = "https://github.com/elk-zone/elk" +website = "https://elk.zone" + +[endlessh] +name = "Endlessh" +description = "SSH Tarpit" +upstream = "https://github.com/skeeto/endlessh" +website = "" + +[erine-email] +name = "erine.email" +description = "" +upstream = "https://gitlab.com/mdavranche/erine.email" +website = "https://erine.email/" + +[erpnext] +name = "ERPnext" +description = "Enterprise Resource Planning (ERP)" +upstream = "https://github.com/frappe/erpnext" +website = "https://erpnext.com/" + +[etesync] +name = "EteSync" +description = "The Etebase server (so you can run your own)" +upstream = "https://github.com/etesync/server" +website = "https://www.etesync.com/" + +[excalibur] +name = "Excalibur" +description = "A web interface to extract tabular data from PDFs (based on Camelot)" +upstream = "https://github.com/camelot-dev/excalibur" +website = "https://excalibur-py.readthedocs.io/en/master/" + +[farside] +name = "Farside" +description = "A redirecting service for FOSS alternative frontends" +upstream = "https://github.com/benbusby/farside" +website = "https://farside.link/" + +[federated-wiki] +name = "Federated wiki" +description = "Farm for fedwiki sites" +upstream = "https://github.com/fedwiki/wiki-server" +website = "http://fed.wiki.org/view/welcome-visitors/view/federated-wiki" + +[filestash] +name = "Filestash" +description = "A modern web client for SFTP, S3, FTP, WebDAV, Git, Minio, LDAP, CalDAV, CardDAV, Mysql, Backblaze, ..." +upstream = "https://github.com/mickael-kerjean/filestash" +website = "https://www.filestash.app/" + +[fishnet] +name = "fishnet" +description = "Distributed Stockfish analysis for lichess.org" +upstream = "https://github.com/niklasf/fishnet" +website = "https://lichess.org/get-fishnet" + +[flaresolverr] +name = "FlareSolverr" +description = "Proxy server to bypass Cloudflare protection" +upstream = "https://github.com/FlareSolverr/FlareSolverr" +website = "" + +[forem] +name = "Forem" +description = "Software for building communities." +upstream = "https://github.com/forem/selfhost" +website = "https://www.forem.com/" + +[fractale] +name = "Fractale" +description = "Platform for self-organization." +upstream = "https://github.com/fractal6/fractal6.go" +website = "https://fractale.co/" + +[framaestro-hub] +name = "Framaestro_hub" +description = "Online service aggregator hub" +upstream = "https://github.com/mozilla/togetherjs" +website = "" + +[freescout] +name = "Freescout" +description = "Helpdesk & Shared Mailbox" +upstream = "https://github.com/freescout-helpdesk/freescout" +website = "https://freescout.net/" + +[gancio] +name = "Gancio" +description = "" +upstream = "https://framagit.org/les/gancio" +website = "https://gancio.org/" + +[gatsby] +name = "Gatsby" +description = "Build blazing fast, modern apps and websites with React" +upstream = "https://github.com/gatsbyjs/gatsby" +website = "https://www.gatsbyjs.com/" + +[geneweb] +name = "Geneweb" +description = "Genealogy in a web interface" +upstream = "https://github.com/geneweb/geneweb" +website = "https://geneweb.tuxfamily.org" + +[goaccess] +name = "Goaccess" +description = "Web log analyzer" +upstream = "https://github.com/allinurl/goaccess" +website = "https://goaccess.io" + +[goatcounter] +name = "GoatCounter" +description = "privacy-friendly web analytics" +upstream = "https://github.com/arp242/goatcounter" +website = "https://www.goatcounter.com/" + +[gocd] +name = "gocd" +description = "CI/CD server" +upstream = "https://github.com/gocd/gocd" +website = "https://go.cd" + +[gollum] +name = "Gollum" +description = "A simple Git-powered wiki" +upstream = "https://github.com/gollum/gollum" +website = "" + +[granary] +name = "Granary" +description = "💬 The social web translator" +upstream = "https://github.com/snarfed/granary" +website = "" + +[graphhopper] +name = "Graphhopper" +description = "Routing engine for OpenStreetMap. Use it as Java library or standalone web server." +upstream = "https://github.com/graphhopper/graphhopper" +website = "https://www.graphhopper.com/" + +[greenlight] +name = "Greenlight" +description = "A really simple end-user interface for your BigBlueButton server" +upstream = "https://github.com/bigbluebutton/greenlight" +website = "https://blabla.aquilenet.fr/b" + +[grist] +name = "Grist" +description = "The evolution of spreadsheets" +upstream = "https://github.com/gristlabs/grist-core/" +website = "https://www.getgrist.com/" + +[habitica] +name = "Habitica" +description = "A habit tracker app which treats your goals like a Role Playing Game." +upstream = "https://github.com/HabitRPG/habitica" +website = "https://habitica.com/" + +[helpy] +name = "Helpy" +description = "A modern helpdesk customer support app, including knowledgebase, discussions and tickets" +upstream = "https://github.com/helpyio/helpy" +website = "" + +[hexo] +name = "Hexo" +description = "A fast, simple & powerful blog framework, powered by Node.js." +upstream = "https://github.com/hexojs/hexo" +website = "https://hexo.io/" + +[histopad] +name = "HistoPad" +description = "Log pads (etherpad) and archiving them in a git repository" +upstream = "https://github.com/24eme/histopad" +website = "" + +[hometown] +name = "Hometown" +description = "A Mastodon fork with local-only posting, support for more content types, and other features and tweaks." +upstream = "https://github.com/hometown-fork/hometown" +website = "" + +[hyperion] +name = "Hyperion" +description = "Ambient lightning software" +upstream = "https://github.com/hyperion-project/hyperion.ng" +website = "https://docs.hyperion-project.org/" + +[hypothes-is] +name = "Hypothes.is" +description = "Annotation server (and client) to create and share highlights and notes" +upstream = "https://github.com/hypothesis/h" +website = "https://hypothes.is" + +[icecast-2] +name = "Icecast 2" +description = "" +upstream = "https://gitlab.xiph.org/xiph/icecast-server/" +website = "https://www.icecast.org" + +[infcloud] +name = "InfCloud" +description = "A contacts, calendar and tasks web client for CalDAV and CardDAV" +upstream = "https://inf-it.com/open-source/download/InfCloud_0.13.1.zip" +website = "https://inf-it.com/open-source/clients/infcloud/" + +[inginious] +name = "Inginious" +description = "Secure and automated exercises assessment platform using your own tests, an can interface with your existing LMS." +upstream = "https://github.com/UCL-INGI/INGInious" +website = "https://inginious.org" + +[inventaire] +name = "Inventaire" +description = "A collaborative resource mapper powered by open-knowledge, starting with books!" +upstream = "https://github.com/inventaire/inventaire" +website = "https://inventaire.io" + +[invoiceplane] +name = "InvoicePlane" +description = "Manage invoices, clients and payments." +upstream = "https://github.com/InvoicePlane/InvoicePlane" +website = "https://invoiceplane.com" + +[ipfs] +name = "IPFS" +description = "Peer-to-peer hypermedia protocol" +upstream = "https://github.com/ipfs/ipfs" +website = "https://ipfs.io" + +[joplin] +name = "Joplin" +description = "Note taking and to-do application with synchronisation capabilities for Windows, macOS, Linux, Android and iOS." +upstream = "https://github.com/laurent22/joplin" +website = "https://joplin.cozic.net/" + +[js-bin] +name = "JS Bin" +description = "Collaborative JavaScript Debugging App" +upstream = "https://github.com/jsbin/jsbin" +website = "https://jsbin.com/" + +[karaoke-forever] +name = "Karaoke-forever" +description = "Organize karaoke parties" +upstream = "https://github.com/bhj/karaoke-forever" +website = "https://www.karaoke-forever.com/" + +[kill-the-newsletter] +name = "Kill the newsletter" +description = "Convert email newsletters to RSS feeds" +upstream = "https://github.com/leafac/kill-the-newsletter.com" +website = "https://kill-the-newsletter.com/" + +[kitchenowl] +name = "Kitchenowl" +description = "Grocery list and recipe manager" +upstream ="https://github.com/TomBursch/kitchenowl" +website = "https://kitchenowl.org/" + +[klaxon] +name = "Klaxon" +description = "Easily create alerts for changes on the web" +upstream = "https://github.com/themarshallproject/klaxon" +website = "https://newsklaxon.org" + +[known] +name = "Known" +description = "A social publishing platform." +upstream = "https://github.com/idno/known" +website = "https://withknown.com" + +[koha] +name = "Koha" +description = "Library system" +upstream = "https://git.koha-community.org/Koha-community/Koha" +website = "https://koha-community.org/" + +[l-atelier] +name = "L'atelier" +description = "A project management tool" +upstream = "https://github.com/jbl2024/latelier" +website = "" + +[lesspass] +name = "LessPass" +description = "Stateless password manager" +upstream = "https://github.com/lesspass/lesspass" +website = "https://www.lesspass.com/" + +[lichen] +name = "Lichen" +description = "Gemtext to HTML translator" +upstream = "https://git.sensorstation.co/lichen.git" +website = "" + +[lila] +name = "Lila" +description = "Online chess game server" +upstream = "https://github.com/ornicar/lila" +website = "https://lichess.org/" + +[lingva-translate] +name = "Lingva Translate" +description = "Alternative front-end for Google Translate" +upstream = "https://github.com/TheDavidDelta/lingva-translate" +website = "https://lingva.ml/" + +[liquidsoap] +name = "LiquidSoap" +description = "Audio and video streaming language" +upstream = "https://github.com/savonet/liquidsoap" +website = "https://www.liquidsoap.info/" + +[locomotivecms] +name = "LocomotiveCMS" +description = "A platform to create, publish and edit sites" +upstream = "https://github.com/locomotivecms/engine" +website = "" + +[logitech-media-server] +name = "Logitech Media Server" +description = "A streaming audio server (formerly SlimServer, SqueezeCenter and Squeezebox Server)" +upstream = "http://mysqueezebox.com/download" +website = "https://en.wikipedia.org/wiki/Logitech_Media_Server" + +[loomio] +name = "Loomio" +description = "A collaborative decision making tool" +upstream = "https://github.com/loomio/loomio/" +website = "https://www.loomio.org" + +[maidsafe] +name = "MaidSafe" +description = "The Safe Network Core. API message definitions, routing and nodes, client core api." +upstream = "https://github.com/maidsafe/safe_network" +website = "https://maidsafe.net" + +[mailpile] +name = "Mailpile" +description = "A modern, fast email client with user-friendly encryption and privacy features" +upstream = "https://github.com/mailpile/Mailpile" +website = "https://www.mailpile.is" + +[mailtrain] +name = "Mailtrain" +description = "Newsletter app" +upstream = "https://github.com/Mailtrain-org/mailtrain" +website = "https://mailtrain.org/" + +[majola] +name = "Majola" +description = "Music scrobble database, alternative to Last.fm" +upstream = "https://github.com/krateng/maloja" +website = "https://maloja.krateng.ch" + +[mautrix-discord] +name = "Mautrix-Discord" +description = "Matrix bridge for Discord" +upstream = "https://github.com/mautrix/discord" +website = "" + +[mealie] +name = "Mealie" +description = "Recipe manager and meal planner" +upstream = "https://github.com/hay-kot/mealie/" +website = "https://hay-kot.github.io/mealie/" + +[mediagoblin] +name = "Mediagoblin" +description = "Video streaming platform" +upstream = "https://savannah.gnu.org/projects/mediagoblin" +website = "https://mediagoblin.org/" + +[medusa] +name = "Medusa" +description = "Automatic TV shows downloader" +upstream = "" +website = "https://pymedusa.com/" + +[megaglest] +name = "Megaglest" +description = "realtime stategy game" +upstream = "https://megaglest.org/linux-packages.html" +website = "https://megaglest.org/" + +[meshery] +name = "Meshery" +description = "Cloudnative solution to bind multiple Service-Meshes together, not only K8s" +upstream = "https://github.com/meshery/meshery" +website = "https://meshery.io/" + +[microblog-pub] +name = "microblog.pub" +description = "A single-user ActivityPub-powered microblog." +upstream = "https://github.com/tsileo/microblog.pub" +website = "" + +[mindustry] +name = "Mindustry" +description = "A sandbox tower-defense game" +upstream = "https://github.com/Anuken/Mindustry" +website = "https://mindustrygame.github.io/" + +[modoboa] +name = "Modoboa" +description = "Mail hosting made simple" +upstream = "https://github.com/modoboa/modoboa" +website = "https://modoboa.org" + +[motioneye] +name = "MotionEye" +description = "A web frontend for the motion daemon" +upstream = "https://github.com/ccrisan/motioneye" +website = "" + +[nebula] +name = "Nebula" +description = "Scalable overlay networking tool with a focus on performance, simplicity and security." +upstream = "https://github.com/slackhq/nebula" +website = "https://nebula.defined.net/docs/" + +[netbird] +name = "Netbird" +description = "Create an overlay peer-to-peer network connecting machines regardless of their location" +upstream = "https://github.com/netbirdio/netbird" +website = "https://netbird.io/" + +[netlify-cms] +name = "Netlify CMS" +description = "A CMS for any static site generator that connects to a Gitlab/Github repo (requires netlify/gotrue)" +upstream = "https://github.com/netlify/netlify-cms" +website = "https://netlifycms.org/" + +[netrunner] +name = "Netrunner" +description = "A card game in a cyberpunk universe" +upstream = "https://github.com/mtgred/netrunner" +website = "" + +[newsblur] +name = "NewsBlur" +description = "RSS reader" +upstream = "https://github.com/samuelclay/NewsBlur" +website = "https://www.newsblur.com" + +[nostr] +name = "Nostr" +description = "Censorship-resistant alternative to Twitter" +upstream = "https://github.com/nostr-protocol/nostr" +website = "" + +[ohmyform] +name = "OhMyForm" +description = "Alternative to TypeForm, TellForm, or Google Forms" +upstream = "https://github.com/ohmyform/ohmyform" +website = "" + +[ombi] +name = "Ombi" +description = "Want a Movie or TV Show on Plex/Emby/Jellyfin? Use Ombi!" +upstream = "https://github.com/tidusjar/Ombi" +website = "" + +[omnivore] +name = "Omnivore" +description = "A read-it-later solution for people who like reading." +upstream = "https://github.com/omnivore-app/omnivore" +website = "https://omnivore.app" + +[opencart] +name = "OpenCart" +description = "Shopping cart system. An online e-commerce solution." +upstream = "https://github.com/opencart/opencart" +website = "https://www.opencart.com" + +[openhab] +name = "openHAB" +description = "Smart home platform" +upstream = "https://github.com/openhab/openhab-webui" +website = "https://www.openhab.org/" + +[osrm] +name = "OSRM" +description = "Routing Machine - C++ backend" +upstream = "https://github.com/Project-OSRM/osrm-backend" +website = "" + +[ox-open-xchange] +name = "OX Open-Xchange" +description = "Linux groupware solution" +upstream = "https://github.com/open-xchange/appsuite-frontend" +website = "https://www.open-xchange.com" + +[padloc] +name = "Padloc" +description = "Simple, secure password and data management for individuals and teams" +upstream = "https://github.com/padloc/padloc" +website = "https://padloc.app/" + +[paperless-ng] +name = "Paperless-ng" +description = "A supercharged version of paperless: scan, index and archive all your physical documents" +upstream = "https://github.com/jonaswinkler/paperless-ng" +website = "" + +[paperwork] +name = "Paperwork" +description = "Note-taking and archiving, alternative to Evernote, Microsoft OneNote & Google Keep" +upstream = "https://github.com/paperwork/paperwork" +website = "https://paperwork.cloud" + +[passbolt] +name = "Passbolt" +description = "Password manager" +upstream = "https://github.com/passbolt/passbolt_docker" +website = "https://www.passbolt.com" + +[penpot] +name = "Penpot" +description = "Design Freedom for Teams" +upstream = "https://github.com/penpot/penpot" +website = "https://penpot.app/" + +[personal-management-system] +name = "personal-management-system" +description = "Your web application for managing personal data." +upstream = "https://github.com/Volmarg/personal-management-system" +website = "" + +[phplist] +name = "PHPList" +description = "Email marketing manager: create, send, integrate, and analyze email campaigns and newsletters." +upstream = "https://github.com/phpList/phplist3" +website = "https://www.phplist.com" + +[pia] +name = "PIA" +description = "A tool to help carrying out Privacy Impact Assessments" +upstream = "https://github.com/LINCnil/pia" +website = "" + +[picsur] +name = "Picsur" +description = "Image hosting" +upstream = "https://github.com/rubikscraft/Picsur" +website = "https://picsur.org/" + +[pinry] +name = "Pinry" +description = "Tiling image board" +upstream = "https://github.com/pinry/pinry/" +website = "https://docs.getpinry.com/" + +[piped] +name = "Piped" +description = "An alternative frontend for YouTube which is efficient by design." +upstream = "https://github.com/TeamPiped/Piped" +website = "https://github.com/TeamPiped/Piped/wiki/Instances" + +[planka] +name = "Planka" +description = "Kanban board for workgroups." +upstream = "https://github.com/plankanban/planka" +website = "https://planka.app/" + +[plausible-analytics] +name = "Plausible Analytics" +description = "Privacy-friendly web analytics (alternative to Google Analytics)" +upstream = "https://github.com/plausible/analytics" +website = "https://plausible.io" + +[protonmail-bridge] +name = "ProtonMail Bridge" +description = "Use Proton Mail with your email client" +upstream = "https://github.com/ProtonMail/proton-bridge" +website = "https://proton.me/mail/bridge" + +[protonmails-webclient] +name = "ProtonMail’s WebClient" +description = "Monorepo hosting the proton web clients" +upstream = "https://github.com/ProtonMail/WebClient" +website = "" + +[psono] +name = "Psono" +description = "Password Manager for Teams" +upstream = "https://gitlab.com/psono/psono-server" +website = "https://psono.com/" + +[pterodactyl] +name = "Pterodactyl" +description = "" +upstream = "" +website = "https://pterodactyl.io/" + +[qgis-server] +name = "QGis server" +description = "Publish QGis desktop projets and maps as OGC-compliant services, that can be used in openlayers, leaflet etc." +upstream = "https://github.com/qgis/QGIS" +website = "https://qgis.org/fr/site/" + +[qwc2] +name = "QWC2" +description = "A react and openlayers-based web UI to publish and display QGIS desktop projects." +upstream = "https://github.com/qgis/qwc2" +website = "" + +[race-for-the-galaxy] +name = "Race for the galaxy" +description = "Play Race for the Galaxy against AI" +upstream = "https://github.com/bnordli/rftg" +website = "" + +[racktables] +name = "racktables" +description = "A datacenter asset management system" +upstream = "https://github.com/RackTables/racktables" +website = "https://racktables.org" + +[raindrop] +name = "Raindrop" +description = "All-in-one bookmark manager" +upstream = "https://github.com/raindropio/app" +website = "https://raindrop.io" + +[raspap] +name = "Raspap" +description = "Simple wireless AP setup & management for Debian-based devices" +upstream = "https://github.com/RaspAP/raspap-webgui" +website = "https://raspap.com/" + +[readarr] +name = "Readarr" +description = "Book Manager and Automation (Sonarr for Ebooks)" +upstream = "https://github.com/Readarr/Readarr" +website = "" + +[redash] +name = "Redash" +description = "Connect to any data source, easily visualize, dashboard and share your data." +upstream = "https://github.com/getredash/redash" +website = "" + +[renovate] +name = "Renovate" +description = "Bot for automating dependency updates on Gitlab / Gitea / Forgejo" +upstream = "https://github.com/renovatebot/renovate" +website = "https://www.mend.io/renovate/" + +[request-tracker] +name = "Request Tracker" +description = "An enterprise-grade issue tracking system" +upstream = "https://github.com/bestpractical/rt" +website = "https://bestpractical.com" + +[restya] +name = "Restya" +description = "Trello like kanban board. Based on Restya platform." +upstream = "https://github.com/RestyaPlatform/board/" +website = "https://restya.com" + +[retroshare] +name = "Retroshare" +description = "Friend-2-Friend, secure decentralised communication platform." +upstream = "https://github.com/RetroShare/RetroShare" +website = "https://retroshare.cc/" + +[revolt] +name = "Revolt" +description = "Chat software similar to Discord" +upstream = "https://github.com/revoltchat/self-hosted" +website = "https://revolt.chat/" + +[rss-proxy] +name = "RSS-proxy" +description = "Create an RSS or ATOM feed of almost any website, just by analyzing just the static HTML structure." +upstream = "https://github.com/damoeb/rss-proxy" +website = "" + +[rsshub] +name = "RSSHub" +description = "Extensible RSS feed generator, generate RSS feeds from pretty much everything" +upstream = "https://github.com/DIYgod/RSSHub" +website = "" + +[rustdesk] +name = "RustDesk" +description = "TeamViewer alternative" +upstream = "https://github.com/rustdesk/rustdesk-server" +website = "https://rustdesk.com/server" + +[sat] +name = "SAT" +description = "An all-in-one tool to manage all your communications" +upstream = "" +website = "https://salut-a-toi.org" + +[sabnzbd] +name = "SABnzbd" +descrition = "The automated Usenet download tool" +upstream = "https://github.com/sabnzbd/sabnzbd" +website = "https://sabnzbd.org/" + +[screego] +name = "Screego" +description = "Screen sharing webrtc" +upstream = "https://github.com/screego/server" +website = "https://screego.net/" + +[scribe] +name = "Scribe" +description = "An alternative frontend to Medium" +upstream = "https://git.sr.ht/~edwardloveall/scribe" +website = "https://scribe.rip/" + +[semantic-mediawiki] +name = "Semantic MediaWiki" +description = "Store and query data withxadin MediaWiki's pages" +upstream = "https://github.com/SemanticMediaWiki/SemanticMediaWiki" +website = "https://www.semantic-mediawiki.org/wiki/Semantic_MediaWiki" + +[semaphore] +name = "Semaphore" +description = "A fediverse (Mastodon-API compatible) accessible, simple and fast web client" +upstream = "https://github.com/NickColley/semaphore" +website = "" + +[shadowsocks] +name = "shadowsocks" +description = "A SOCKS5 proxy to protect your Internet traffic" +upstream = "" +website = "https://shadowsocks.org" + +[shinken] +name = "shinken" +description = "A flexible and scalable monitoring framework" +upstream = "https://github.com/naparuba/shinken" +website = "" + +[sickrage] +name = "sickrage" +description = "Automatic TV shows downloader" +upstream = "" +website = "https://sickchill.github.io/" + +[signal-proxy] +name = "Signal Proxy" +description = "Fight censorship and bypass traffic securely to the Signal service" +upstream = "https://github.com/signalapp/Signal-TLS-Proxy" +website = "https://signal.org/blog/help-iran-reconnect/" + +[simplelogin] +name = "SimpleLogin" +description = "Privacy-first e-mail forwarding and identity provider service" +upstream = "https://github.com/simple-login/app" +website = "https://simplelogin.io" + +[smokeping] +name = "smokeping" +description = "The Active Monitoring System" +upstream = "https://github.com/oetiker/SmokePing" +website = "https://oss.oetiker.ch/smokeping/" + +[socialhome] +name = "SocialHome" +description = "A federated personal profile" +upstream = "https://github.com/jaywink/socialhome" +website = "https://socialhome.network" + +[sphinx] +name = "sphinx" +description = "The Sphinx documentation generator" +upstream = "https://github.com/sphinx-doc/sphinx" +website = "" + +[spreed] +name = "Spreed" +description = "Standalone signaling server for Nextcloud Talk." +upstream = "https://github.com/strukturag/nextcloud-spreed-signaling" +website = "" + +[stackedit] +name = "Stackedit" +description = "In-browser Markdown editor" +upstream = "https://github.com/benweet/stackedit" +website = "https://stackedit.io" + +[storj] +name = "Storj" +description = "Ongoing Storj v3 development. Decentralized cloud object storage that is affordable, easy to use, private, and secure." +upstream = "https://github.com/storj/storj" +website = "https://www.storj.io/node" + +[strapi] +name = "Strapi" +description = "Node.js Headless CMS to easily build customisable APIs" +upstream = "https://github.com/strapi/strapi" +website = "https://strapi.io" + +[stremio] +name = "Stremio" +description = "A modern media center" +upstream = "https://github.com/Stremio/stremio-web" +website = "https://strem.io" + +[suitecrm] +name = "SuiteCRM" +description = "A CRM software" +upstream = "https://github.com/salesagility/SuiteCRM" +website = "https://suitecrm.com/" + +[superalgos] +name = "Superalgos" +description = "Crypto trading bot, automated bitcoin / cryptocurrency trading software." +upstream = "https://github.com/Superalgos/Superalgos" +website = "" + +[sympa] +name = "Sympa" +description = "Mailing List manager" +upstream = "" +website = "https://www.sympa.org/" + +[syspass] +name = "Syspass" +description = "Systems Password Manager" +upstream = "https://github.com/nuxsmin/sysPass" +website = "https://www.syspass.org/" + +[tahoe-lafs] +name = "Tahoe-LAFS" +description = "Decentralized cloud storage system" +upstream = "https://github.com/tahoe-lafs/tahoe-lafs" +website = "https://tahoe-lafs.org/" + +[taiga] +name = "Taiga" +description = "" +upstream = "https://github.com/kaleidos-ventures/taiga-back" +website = "https://taiga.io" + +[tailscale] +name = "Tailscale" +description = "Wireguard-based Mesh-VPN" +upstream = "https://github.com/tailscale/tailscale" +website = "https://tailscale.com/" + +[takahe] +name = "Takahē" +description = "An efficient ActivityPub Server, for small installs with multiple domains" +upstream = "https://github.com/jointakahe/takahe" +website = "https://jointakahe.org" + +[taskwarrior] +name = "Taskwarrior" +description = "Command line Task Management" +upstream = "https://github.com/GothenburgBitFactory/taskwarrior" +website = "https://taskwarrior.org" + +[teddy-io] +name = "Teddy.io" +description = "Document manager" +upstream = "https://github.com/sismics/docs" +website = "https://teedy.io/" + +[teleport] +name = "Teleport" +description = "Multi-protocol access proxy which understands SSH, HTTPS, RDP, Kubernetes API, MySQL, MongoDB and PostgreSQL wire protocols." +upstream = "https://github.com/gravitational/teleport" +website = "https://goteleport.com/" + +[theia-ide] +name = "Theia-IDE" +description = "VS Code-like cloud IDE" +upstream = "https://hub.docker.com/r/theiaide/theia-full" +website = "https://theia-ide.org/" + +[tileserver-gl] +name = "Tileserver-GL" +description = "Tile server light SVG for map service" +upstream = "https://github.com/maptiler/tileserver-gl" +website = "https://maps.earth/" + +[timetagger] +name = "Timetagger" +description = "An open source time-tracker with an interactive user experience and powerful reporting." +upstream = "https://github.com/almarklein/timetagger/" +website = "https://timetagger.app/" + +[tmate] +name = "TMate" +description = "Instant Terminal Sharing" +upstream = "https://github.com/tmate-io/tmate" +website = "https://tmate.io/" + +[traccar] +name = "Traccar" +description = "Modern GPS Tracking Platform" +upstream = "https://github.com/traccar/traccar" +website = "" + +[trivy] +name = "trivy" +description = "OSS Vulnerability and Misconfiguration Scanning." +upstream = "https://github.com/aquasecurity/trivy" +website = "https://www.aquasec.com/products/trivy/" + +[tubesync] +name = "tubesync" +description = "Syncs YouTube channels and playlists to a locally hosted media server" +upstream = "https://github.com/meeb/tubesyn" +website = "" + +[tutao] +name = "tutao" +description = "End-to-end encrypted e-mail client" +upstream = "https://github.com/tutao/tutanota/" +website = "" + +[ultrasonics] +name = "ultrasonics" +description = "Sync music playlists between all your music services: Spotify, Deezer, Apple Music, Plex, etc." +upstream = "https://github.com/XDGFX/ultrasonics" +website = "" + +[umap] +name = "umap" +description = "Cartography software" +upstream = "" +website = "https://umap.openstreetmap.fr/" + +[upmpdcli] +name = "upmpdcli" +description = "" +upstream = "https://framagit.org/medoc92/upmpdcli" +website = "https://www.lesbonscomptes.com/upmpdcli/" + +[uwazi] +name = "Uwazi" +description = "Build and share document collections" +upstream = "https://github.com/huridocs/uwazi" +website = "https://www.uwazi.io/" + +[vpn-server] +name = "VPN server" +description = "Create/provide VPNs from your server" +upstream = "" +website = "https://openvpn.net" + +[webhook-site] +name = "Webhook.site" +description = "Easily test HTTP webhooks with this handy tool that displays requests instantly." +upstream = "https://github.com/fredsted/webhook.site" +website = "https://docs.webhook.site/" + +[webogram] +name = "webogram" +description = "A new era of messaging" +upstream = "https://github.com/zhukov/webogram" +website = "" + +[webterminal] +name = "Webterminal" +description = "A web-based Jump Host / Bastion, supports VNC, SSH, RDP, Telnet, SFTP..." +upstream = "https://github.com/jimmy201602/webterminal/" +website = "" + +[webthings-gateway] +name = "WebThings Gateway" +description = "WebThings Gateway" +upstream = "https://github.com/WebThingsIO/gateway" +website = "https://iot.mozilla.org/gateway/" + +[wg-access-server] +name = "wg-access-server" +description = "VPN Server OIDC ipv4 ipv6" +upstream = "https://github.com/freifunkMUC/wg-access-server" +website = "" + +[whisparr] +name = "Whisparr" +description = "Adult movie collection manager for Usenet and BitTorrent users" +upstream = "https://github.com/Whisparr/Whisparr" +website = "" + +[whoogle] +name = "Whoogle" +description = "A metasearch engine" +upstream = "https://github.com/benbusby/whoogle-search" +website = "" + +[wikiless] +name = "Wikiless" +description = "An alternative Wikipedia front-end focused on privacy." +upstream = "https://codeberg.org/orenom/wikiless" +website = "https://wikiless.org/" + +[wikisuite] +name = "WikiSuite" +description = "An integrated enterprise solution" +upstream = "https://gitlab.com/wikisuite" +website = "https://wikisuite.org/Software" + +[wildduck] +name = "WildDuck" +description = "Opinionated email server" +upstream = "https://github.com/nodemailer/wildduck" +website = "" + +[wisemapping] +name = "Wisemapping" +description = "An online mind mapping editor" +upstream = "https://bitbucket.org/wisemapping/wisemapping-open-source" +website = "" + +[workadventure] +name = "WorkAdventure" +description = "A web-based collaborative workspace for small to medium teams" +upstream = "https://github.com/thecodingmachine/workadventure" +website = "" + +[xbrowsersync] +name = "xBrowserSync" +description = "A bookmark sync tool, with browser plugins and mobile clients available" +upstream = "https://github.com/xbrowsersync/api" +website = "https://www.xbrowsersync.org/" + +[xibo] +name = "Xibo" +description = "A FLOSS digital signage solution" +upstream = "https://github.com/xibosignage/xibo-cms" +website = "" + +[xonotic] +name = "Xonotic" +description = "" +upstream = "https://gitlab.com/xonotic" +website = "https://xonotic.org" + +[yggdrasil] +name = "Yggdrasil" +description = "An experiment in scalable routing as an encrypted IPv6 overlay network" +upstream = "https://github.com/yggdrasil-network/yggdrasil-go" +website = "https://yggdrasil-network.github.io/" + +[your-spotify] +name = "your_spotify" +description = "Spotify tracking dashboard" +upstream = "https://github.com/Yooooomi/your_spotify" +website = "" + +[zammad] +name = "Zammad" +description = "Helpdesk/customer support system" +upstream = "https://github.com/zammad/zammad" +website = "" + +[zigbee2mqtt-io] +name = "zigbee2mqtt.io" +description = "Zigbee-to-MQTT software-bridge supporting more than 1000 Zigbee devices" +upstream = "https://github.com/koenkk/zigbee2mqtt" +website = "https://www.zigbee2mqtt.io/" + +[zoneminder] +name = "Zoneminder" +description = "Closed-circuit television software app supporting IP, USB and Analog cameras. " +upstream = "https://github.com/ZoneMinder/zoneminder" +website = "" + +[zulip] +name = "Zulip" +description = "Team chat that helps teams stay productive and focused." +upstream = "https://github.com/zulip/zulip" +website = "https://zulipchat.com/"