Code générique de la page d'accueil personnalisée YunoHost (Makefile, scripts, éditeur visuel)
This commit is contained in:
Executable
+53
@@ -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}"
|
||||
Executable
+16
@@ -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"
|
||||
Executable
+146
@@ -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
|
||||
Executable
+34
@@ -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"
|
||||
Executable
+162
@@ -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()
|
||||
Reference in New Issue
Block a user