Code générique de la page d'accueil personnalisée YunoHost (Makefile, scripts, éditeur visuel)

This commit is contained in:
mathieu
2026-09-19 17:47:32 +02:00
parent 44d01ea7cd
commit 455988e49c
16 changed files with 2025 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
#!/bin/sh
# Deploie le contenu de output/ (voir "make export") sur l'app YunoHost.
# Voir README.md pour le detail des parametres et l'authentification par cle SSH.
#
# Le compte SSH d'une app "Custom Webapp" YunoHost est en general limite a
# SFTP (ForceCommand internal-sftp) et chroote dans son propre dossier home
# (/var/www/<REMOTE_USER>) : rsync ne fonctionne donc pas (il a besoin
# d'executer un shell distant), et le chemin distant doit etre relatif a la
# racine du chroot, pas un chemin absolu du systeme de fichiers du serveur.
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
PROJECT_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
OUTPUT_DIR="$PROJECT_ROOT/output"
if [ ! -f "${PROJECT_ROOT}/.env" ]; then
echo "Aucun .env trouve : lance 'make config' puis renseigne-le (voir .env.example)." >&2
exit 1
fi
# shellcheck disable=SC1090,SC1091
. "${PROJECT_ROOT}/.env"
if [ ! -f "${OUTPUT_DIR}/index.html" ]; then
echo "output/ est vide ou incomplet : lance d'abord 'make export' (ou le bouton Export de l'editeur)." >&2
exit 1
fi
if [ -z "${REMOTE_HOST:-}" ] || [ -z "${REMOTE_USER:-}" ]; then
echo "REMOTE_HOST et REMOTE_USER doivent etre renseignes dans .env (voir .env.example)." >&2
exit 1
fi
REMOTE_PORT="${REMOTE_PORT:-22}"
# Chemin relatif a la racine du chroot SFTP (pas le chemin absolu serveur).
REMOTE_PATH="${REMOTE_PATH:-/www/}"
SCP="scp"
if [ -n "${REMOTE_PASSWORD:-}" ]; then
if ! command -v sshpass > /dev/null 2>&1; then
echo "REMOTE_PASSWORD est defini dans .env mais sshpass n'est pas installe (apt install sshpass)." >&2
exit 1
fi
SCP="sshpass -p ${REMOTE_PASSWORD} scp"
fi
${SCP} -P "${REMOTE_PORT}" \
"${OUTPUT_DIR}/index.html" \
"${OUTPUT_DIR}/style.css" \
"${OUTPUT_DIR}/app.js" \
"${OUTPUT_DIR}/config.json" \
"${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Copie les fichiers deployables de src/ vers output/ (equivalent en ligne
# de commande du bouton "Exporter" de l'editeur).
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
PROJECT_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
SRC_DIR="$PROJECT_ROOT/src"
OUTPUT_DIR="$PROJECT_ROOT/output"
mkdir -p "$OUTPUT_DIR"
cp "$SRC_DIR/index.html" "$SRC_DIR/style.css" "$SRC_DIR/app.js" "$SRC_DIR/config.json" "$OUTPUT_DIR/"
echo "Exporte vers $OUTPUT_DIR :"
ls "$OUTPUT_DIR"
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Serveur de previsualisation locale (sert src/), avec rechargement
automatique, sauvegarde et export depuis l'editeur visuel (editor.html).
Usage: python3 scripts/preview.py [port]
"""
import http.server
import json
import shutil
import sys
from pathlib import Path
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = SCRIPT_DIR.parent
SRC_DIR = PROJECT_ROOT / "src"
OUTPUT_DIR = PROJECT_ROOT / "output"
WATCHED_FILES = ["index.html", "style.css", "app.js", "config.json"]
EXPORTED_FILES = ["index.html", "style.css", "app.js", "config.json"]
RELOAD_SNIPPET = b"""
<script>
(function () {
var lastSeen = null;
var polling = false;
function poll() {
if (polling) { return; }
polling = true;
fetch("/__mtime", { cache: "no-store" })
.then(function (r) { return r.text(); })
.then(function (text) {
if (lastSeen === null) {
lastSeen = text;
} else if (text !== lastSeen) {
location.reload();
}
})
.catch(function (err) { console.error("live-reload:", err); })
.finally(function () { polling = false; });
}
setInterval(poll, 1000);
})();
</script>
</body>
"""
def watched_mtime():
mtimes = []
for name in WATCHED_FILES:
try:
mtimes.append((SRC_DIR / name).stat().st_mtime)
except OSError:
pass
return str(max(mtimes)) if mtimes else "0"
class ReloadingHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(SRC_DIR), **kwargs)
def do_GET(self):
if self.path == "/__mtime":
body = watched_mtime().encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if self.path in ("/", "/index.html"):
try:
html = (SRC_DIR / "index.html").read_bytes()
except OSError:
return super().do_GET()
html = html.replace(b"</body>", RELOAD_SNIPPET)
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(html)))
self.end_headers()
self.wfile.write(html)
return
super().do_GET()
def do_POST(self):
if self.path == "/__save":
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length)
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
self.send_error(400, f"JSON invalide: {exc}")
return
with open(SRC_DIR / "config.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
self._send_json({"ok": True})
return
if self.path == "/__export":
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
copied = []
for name in EXPORTED_FILES:
src = SRC_DIR / name
if src.exists():
shutil.copy(src, OUTPUT_DIR / name)
copied.append(name)
self._send_json({"ok": True, "files": copied, "dir": str(OUTPUT_DIR)})
return
self.send_error(404)
def _send_json(self, payload):
body = json.dumps(payload).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def end_headers(self):
# Empeche le navigateur de mettre en cache src/*, sinon un simple
# rechargement (ou le reload automatique) peut afficher une version
# perimee et forcer a faire Ctrl+F5.
self.send_header("Cache-Control", "no-store, must-revalidate")
super().end_headers()
def log_message(self, format, *args):
if args and "__mtime" in str(args[0]):
return
super().log_message(format, *args)
if __name__ == "__main__":
with http.server.ThreadingHTTPServer(("127.0.0.1", PORT), ReloadingHandler) as httpd:
print(f"Apercu : http://localhost:{PORT}/")
print(f"Editeur : http://localhost:{PORT}/editor.html")
print("Ctrl+C pour arreter.")
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# Lance le serveur de previsualisation locale et ouvre l'editeur + l'apercu
# dans le navigateur. Ctrl+C arrete tout.
set -eu
PORT="${1:-8000}"
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
cd "$SCRIPT_DIR"
python3 preview.py "$PORT" &
SERVER_PID=$!
trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT INT TERM
# Laisse le temps au serveur de demarrer avant d'ouvrir le navigateur.
sleep 1
EDITOR_URL="http://localhost:${PORT}/editor.html"
PREVIEW_URL="http://localhost:${PORT}/"
open_url() {
if command -v xdg-open > /dev/null 2>&1; then
xdg-open "$1" > /dev/null 2>&1 &
elif command -v open > /dev/null 2>&1; then
open "$1" > /dev/null 2>&1 &
else
echo "Ouvre manuellement : $1"
fi
}
open_url "$EDITOR_URL"
open_url "$PREVIEW_URL"
wait "$SERVER_PID"
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Recupere la liste des apps installees sur le serveur YunoHost et ajoute
celles qui manquent dans src/config.json (dans "ungrouped"), sans toucher a
celles deja placees dans un groupe, dans "pinned" ou deja presentes.
Usage: python3 scripts/sync-apps.py [utilisateur@hote] [domaine]
Toute la configuration vient de .env (SYNC_SSH_USER, SYNC_SSH_HOST,
PORTAL_DOMAIN, REMOTE_USER) ; les arguments en ligne de commande, s'ils sont
fournis, ont priorite sur .env.
"""
import json
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = SCRIPT_DIR.parent
CONFIG_PATH = PROJECT_ROOT / "src" / "config.json"
ENV_PATH = PROJECT_ROOT / ".env"
def load_env():
env = {}
if not ENV_PATH.exists():
return env
for line in ENV_PATH.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip()
return env
ENV = load_env()
SYNC_SSH_USER = ENV.get("SYNC_SSH_USER", "root")
SYNC_SSH_HOST = ENV.get("SYNC_SSH_HOST") or ENV.get("REMOTE_HOST")
PORTAL_DOMAIN = ENV.get("PORTAL_DOMAIN") or ENV.get("REMOTE_HOST")
SELF_APP_ID = ENV.get("REMOTE_USER", "") # cette page elle-meme, jamais ajoutee
if len(sys.argv) > 1:
SSH_TARGET = sys.argv[1]
elif SYNC_SSH_HOST:
SSH_TARGET = f"{SYNC_SSH_USER}@{SYNC_SSH_HOST}"
else:
SSH_TARGET = None
DOMAIN = sys.argv[2] if len(sys.argv) > 2 else PORTAL_DOMAIN
if not SSH_TARGET or not DOMAIN:
print(
"Configuration manquante : renseigne SYNC_SSH_HOST (ou REMOTE_HOST) "
"et PORTAL_DOMAIN dans .env (lance 'make config' si .env n'existe pas "
"encore), ou passe-les en argument :\n"
" python3 scripts/sync-apps.py utilisateur@hote domaine",
file=sys.stderr,
)
sys.exit(1)
PALETTE = [
"#1db9c3", "#ff5a1f", "#2094f3", "#ffc230", "#00a651", "#e6721a",
"#b71c1c", "#f39c12", "#0d3b66", "#3b8a8c", "#568d34", "#609926",
"#d24939", "#0066cc", "#8e44ad", "#16a085", "#c0392b", "#2c3e50",
]
def color_of(name):
h = 0
for ch in name:
h = (h * 31 + ord(ch)) & 0xFFFFFFFF
return PALETTE[h % len(PALETTE)]
def initials_of(name):
words = name.strip().split()
if len(words) == 1:
return words[0][:2].upper()
return (words[0][0] + words[1][0]).upper()
def fetch_remote_apps():
cmd = ["ssh", "-o", "BatchMode=yes", SSH_TARGET, f"cat /etc/yunohost/portal/{DOMAIN}.json"]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"Erreur SSH: {result.stderr}", file=sys.stderr)
sys.exit(1)
return json.loads(result.stdout)
def normalize_url(url):
return url.replace("https://", "").replace("http://", "").rstrip("/").lower()
def known_urls(config):
urls = set()
for app in config.get("pinned", []):
urls.add(normalize_url(app["url"]))
for group in config.get("groups", []):
for app in group["apps"]:
urls.add(normalize_url(app["url"]))
for app in config.get("ungrouped", []):
urls.add(normalize_url(app["url"]))
return urls
def main():
with open(CONFIG_PATH, encoding="utf-8") as f:
config = json.load(f)
remote = fetch_remote_apps()
remote_apps = remote.get("apps", {})
existing_urls = known_urls(config)
added = []
for perm_key, info in sorted(remote_apps.items()):
if not perm_key.endswith(".main"):
continue
app_id = perm_key[: -len(".main")]
if app_id == SELF_APP_ID:
continue
url = info["url"]
if not url.startswith("http"):
url = "https://" + url
if not url.endswith("/"):
url += "/"
if normalize_url(url) in existing_urls:
continue
name = info.get("label", app_id)
app = {
"id": app_id,
"name": name,
"url": url,
"color": color_of(name),
"initials": initials_of(name),
}
logo = info.get("logo")
if logo:
app["icon"] = f"https://{DOMAIN}{logo}"
config.setdefault("ungrouped", []).append(app)
added.append(name)
if not added:
print("Rien a ajouter, config.json est deja a jour.")
return
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
f.write("\n")
print(f"{len(added)} application(s) ajoutee(s) dans \"ungrouped\" :")
for name in added:
print(f" - {name}")
if __name__ == "__main__":
main()