diff --git a/app/.env.example b/app/.env.example new file mode 100644 index 0000000..2611056 --- /dev/null +++ b/app/.env.example @@ -0,0 +1,29 @@ +SECRET_KEY=changez-moi-avec-une-valeur-aleatoire-longue + +# ── AlpID / Keycloak ────────────────────────────────────────────────── +# Realm : master (seul realm actif sur cette instance AlpID) +ALPID_CLIENT_ID=static-cdn +ALPID_CLIENT_SECRET= +ALPID_DISCOVERY_URL=https://alpid.alpinux.org/realms/master/.well-known/openid-configuration + +# Groupes Keycloak autorisés (séparés par virgule) +ADMIN_GROUPS=admins + +# Fallback si le claim "groups" est absent du token Keycloak +ADMIN_EMAILS=cedric.alpinux@acemail.fr + +# ── Répertoire racine du CDN à parcourir ────────────────────────────── +# Production : /var/www/clients/client1/web17/web +# Dev local : chemin absolu vers org.alpinux.owni/static +ASSETS_ROOT= + +# ── Statistiques GoAccess ───────────────────────────────────────────── +# Rapport HTML affiché dans l'onglet Statistiques +STATS_FILE=/opt/static-cdn/goaccess.html +# Rapport JSON pour les badges "Vues" dans le navigateur de fichiers +STATS_JSON=/opt/static-cdn/goaccess.json +# Fichier de log Apache à analyser (nécessaire pour la génération à la demande) +STATS_LOG_FILE=/var/log/ispconfig/httpd/static.alpinux.org/access.log +# Commande complète de génération (optionnel — remplace la commande par défaut) +# Exemple : goaccess /var/log/... --log-format=COMBINED -o /opt/static-cdn/goaccess.html +STATS_GENERATE_CMD= diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..586a2fc --- /dev/null +++ b/app/app.py @@ -0,0 +1,511 @@ +import json +import os +import subprocess +import threading +from pathlib import Path +from datetime import datetime + +from flask import (Flask, redirect, url_for, session, request, + render_template, abort, send_from_directory, jsonify) +from authlib.integrations.flask_client import OAuth +from werkzeug.middleware.proxy_fix import ProxyFix +from werkzeug.utils import secure_filename + +app = Flask(__name__) +app.secret_key = os.environ["SECRET_KEY"] +app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1) + +oauth = OAuth(app) +oauth.register( + name="alpid", + server_metadata_url=os.environ["ALPID_DISCOVERY_URL"], + client_id=os.environ["ALPID_CLIENT_ID"], + client_secret=os.environ["ALPID_CLIENT_SECRET"], + client_kwargs={"scope": "openid profile email"}, +) + +ADMIN_GROUPS = set(os.environ.get("ADMIN_GROUPS", "admins").split(",")) +ADMIN_EMAILS = set(e.strip() for e in os.environ.get("ADMIN_EMAILS", "").split(",") if e.strip()) +ASSETS_ROOT = Path(os.environ.get("ASSETS_ROOT", ".")).resolve() +STATS_FILE = Path(os.environ.get("STATS_FILE", "/opt/static-cdn/goaccess.html")) +STATS_JSON = Path(os.environ.get("STATS_JSON", "/opt/static-cdn/goaccess.json")) +STATS_LOG_FILE = os.environ.get("STATS_LOG_FILE", "") +STATS_GENERATE_CMD = os.environ.get("STATS_GENERATE_CMD", "") + +_gen_lock = threading.Lock() +_gen_state = {"generating": False} + + +_HIDDEN = frozenset({ + ".git", "scripts", "app", + ".env", ".env.example", ".gitignore", + ".htaccess", ".htpasswd_stats", + "standard_index.html", +}) + +IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".webp"}) +TEXT_EXT = frozenset({".txt", ".html", ".htm", ".md", ".css", ".js", ".json", + ".xml", ".conf", ".sh", ".robots", ".php"}) +PDF_EXT = frozenset({".pdf"}) + + +# ── Helpers ─────────────────────────────────────────────────────────── + +def _user(): + return session.get("user") + + +def _require_admin(): + u = _user() + if not u: + session["next_url"] = request.url + return redirect(url_for("login")) + if not u.get("is_admin"): + abort(403) + return None + + +def _safe_path(subpath: str) -> Path: + target = (ASSETS_ROOT / subpath).resolve() + if not target.is_relative_to(ASSETS_ROOT): + abort(400) + return target + + +def _humansize(n: int) -> str: + for unit in ("o", "Ko", "Mo", "Go"): + if n < 1024: + return f"{n:.0f} {unit}" + n /= 1024 + return f"{n:.1f} To" + + +def _folder_stats(path: Path) -> dict: + files, size = 0, 0 + for f in path.rglob("*"): + if not f.is_file(): + continue + if any(p in _HIDDEN or p.startswith(".") for p in f.relative_to(path).parts): + continue + files += 1 + size += f.stat().st_size + return {"files": files, "size": size} + + +def _parse_date(s: str): + if not s: + return None + try: + return datetime.strptime(s, "%Y-%m-%d") + except ValueError: + return None + + +def _load_hits() -> dict: + """Parse GoAccess JSON report → {relative_path: hit_count}.""" + if not STATS_JSON.is_file(): + return {} + try: + data = json.loads(STATS_JSON.read_text()) + hits = {} + for item in data.get("requests", {}).get("data", []): + url = item.get("data", "").lstrip("/") + count = item.get("hits", {}).get("count", 0) + if url and count: + hits[url] = count + return hits + except Exception: + return {} + + +def _entry(item: Path) -> dict: + stat = item.stat() + ext = item.suffix.lower() + return { + "name": item.name, + "path": str(item.relative_to(ASSETS_ROOT)), + "is_dir": item.is_dir(), + "size": stat.st_size if item.is_file() else None, + "mtime": datetime.fromtimestamp(stat.st_mtime), + "ext": ext, + "is_image": ext in IMAGE_EXT, + "is_text": ext in TEXT_EXT, + "is_pdf": ext in PDF_EXT, + } + + +# ── Auth ────────────────────────────────────────────────────────────── + +@app.route("/auth/login") +def login(): + return oauth.alpid.authorize_redirect(url_for("callback", _external=True)) + + +@app.route("/auth/callback") +def callback(): + token = oauth.alpid.authorize_access_token() + info = token.get("userinfo") or oauth.alpid.userinfo(token=token) + email = info.get("email", "") + groups = set(info.get("groups", [])) + if groups: + is_admin = bool(groups & ADMIN_GROUPS) + elif ADMIN_EMAILS: + is_admin = email in ADMIN_EMAILS + else: + is_admin = True + session["user"] = { + "sub": info["sub"], + "name": info.get("name") or info.get("preferred_username", ""), + "email": email, + "is_admin": is_admin, + } + return redirect(session.pop("next_url", url_for("dashboard"))) + + +@app.route("/auth/logout") +def logout(): + session.clear() + return redirect(url_for("dashboard")) + + +# ── Dashboard ───────────────────────────────────────────────────────── + +@app.route("/") +def dashboard(): + redir = _require_admin() + if redir: + return redir + + folders = {} + for item in sorted(ASSETS_ROOT.iterdir()): + if item.name in _HIDDEN or item.name.startswith("."): + continue + if item.is_dir(): + folders[item.name] = _folder_stats(item) + + return render_template("dashboard.html", + user=_user(), + folders=folders, + total_files=sum(v["files"] for v in folders.values()), + total_size=sum(v["size"] for v in folders.values()), + humansize=_humansize, + ) + + +# ── Navigateur de fichiers ──────────────────────────────────────────── + +@app.route("/browse/") +@app.route("/browse/") +def browse(subpath=""): + redir = _require_admin() + if redir: + return redir + + target = _safe_path(subpath) + if not target.exists(): + abort(404) + + if subpath: + top = Path(subpath).parts[0] + if top in _HIDDEN or top.startswith("."): + abort(403) + + if target.is_file(): + return _file_preview(target, subpath) + + hits = _load_hits() + + entries = [] + for item in sorted(target.iterdir()): + if item.name in _HIDDEN or item.name.startswith("."): + continue + e = _entry(item) + e["hits"] = hits.get(e["path"], 0) if not e["is_dir"] else None + entries.append(e) + entries.sort(key=lambda e: (0 if e["is_dir"] else 1, e["name"].lower())) + + parts = Path(subpath).parts if subpath else () + breadcrumb = [ + {"name": p, "path": str(Path(*parts[:i + 1]))} + for i, p in enumerate(parts) + ] + + return render_template("browse.html", + user=_user(), + entries=entries, + subpath=subpath, + breadcrumb=breadcrumb, + humansize=_humansize, + has_hits=STATS_JSON.is_file(), + ) + + +def _file_preview(path: Path, subpath: str): + ext = path.suffix.lower() + stat = path.stat() + parent = str(Path(subpath).parent) if Path(subpath).parent != Path(".") else "" + + siblings = sorted( + [f for f in path.parent.iterdir() + if f.is_file() and f.name not in _HIDDEN and not f.name.startswith(".")], + key=lambda f: f.name.lower(), + ) + idx = next((i for i, f in enumerate(siblings) if f == path), None) + prev_path = str(siblings[idx - 1].relative_to(ASSETS_ROOT)) if idx else None + next_path = str(siblings[idx + 1].relative_to(ASSETS_ROOT)) if idx is not None and idx < len(siblings) - 1 else None + + ctx = dict( + user=_user(), + subpath=subpath, + filename=path.name, + filesize=_humansize(stat.st_size), + mtime=datetime.fromtimestamp(stat.st_mtime), + raw_url=url_for("raw_file", subpath=subpath), + parent_path=parent, + prev_path=prev_path, + next_path=next_path, + sibling_pos=f"{idx + 1}/{len(siblings)}" if idx is not None else "", + ) + if ext in IMAGE_EXT: + return render_template("preview_image.html", **ctx) + if ext in TEXT_EXT: + content = path.read_text(errors="replace") + return render_template("preview_text.html", content=content, lang=ext.lstrip("."), **ctx) + return render_template("preview_other.html", **ctx) + + +# ── Recherche ───────────────────────────────────────────────────────── + +@app.route("/search") +def search(): + redir = _require_admin() + if redir: + return redir + + q = request.args.get("q", "").strip() + after_s = request.args.get("after", "").strip() + before_s = request.args.get("before", "").strip() + in_content = request.args.get("content") == "1" + + dt_after = _parse_date(after_s) + dt_before = _parse_date(before_s) + + results = [] + searched = bool(q or dt_after or dt_before) + + if searched: + q_low = q.lower() + for path in sorted(ASSETS_ROOT.rglob("*")): + if not path.is_file(): + continue + parts = path.relative_to(ASSETS_ROOT).parts + if any(p in _HIDDEN or p.startswith(".") for p in parts): + continue + + stat = path.stat() + mtime = datetime.fromtimestamp(stat.st_mtime) + + if dt_after and mtime.date() < dt_after.date(): + continue + if dt_before and mtime.date() > dt_before.date(): + continue + + name_match = (not q) or q_low in path.name.lower() + content_match = None + + if in_content and q and not name_match and path.suffix.lower() in TEXT_EXT: + try: + text = path.read_text(errors="replace") + idx = text.lower().find(q_low) + if idx != -1: + start = max(0, idx - 60) + end = min(len(text), idx + len(q) + 60) + snippet = text[start:end].replace("\n", " ") + content_match = ("…" if start else "") + snippet + ("…" if end < len(text) else "") + name_match = True + except Exception: + pass + + if not name_match: + continue + + e = _entry(path) + e["content_match"] = content_match + results.append(e) + + return render_template("search.html", + user=_user(), + q=q, + after=after_s, + before=before_s, + in_content=in_content, + results=results, + searched=searched, + humansize=_humansize, + ) + + +# ── Statistiques GoAccess ───────────────────────────────────────────── + +@app.route("/stats/") +def stats(): + redir = _require_admin() + if redir: + return redir + return render_template("stats.html", + user=_user(), + has_report=STATS_FILE.is_file(), + ) + + +@app.route("/stats/report") +def stats_report(): + redir = _require_admin() + if redir: + return redir + if not STATS_FILE.is_file(): + abort(404) + return send_from_directory(STATS_FILE.parent, STATS_FILE.name) + + +def _do_generate(): + try: + if STATS_GENERATE_CMD: + subprocess.run(STATS_GENERATE_CMD, shell=True, timeout=600) + else: + cmd = ["goaccess", STATS_LOG_FILE, "--log-format=COMBINED", + f"--output={STATS_FILE}"] + if STATS_JSON: + cmd.append(f"--output={STATS_JSON}") + subprocess.run(cmd, timeout=600, check=False) + finally: + with _gen_lock: + _gen_state["generating"] = False + + +@app.route("/stats/generate", methods=["POST"]) +def stats_generate(): + redir = _require_admin() + if redir: + return redir + if STATS_FILE.is_file(): + return jsonify({"status": "exists"}) + with _gen_lock: + if _gen_state["generating"]: + return jsonify({"status": "generating"}) + if not STATS_LOG_FILE and not STATS_GENERATE_CMD: + return jsonify({"status": "error", + "message": "STATS_LOG_FILE non configuré"}), 500 + _gen_state["generating"] = True + threading.Thread(target=_do_generate, daemon=True).start() + return jsonify({"status": "started"}) + + +@app.route("/stats/status") +def stats_status(): + redir = _require_admin() + if redir: + return redir + return jsonify({"ready": STATS_FILE.is_file(), + "generating": _gen_state["generating"]}) + + +# ── Fichiers bruts (aperçu / téléchargement) ────────────────────────── + +@app.route("/raw/") +def raw_file(subpath): + redir = _require_admin() + if redir: + return redir + target = _safe_path(subpath) + if not target.is_file(): + abort(404) + top = Path(subpath).parts[0] + if top in _HIDDEN or top.startswith("."): + abort(403) + return send_from_directory(ASSETS_ROOT, subpath) + + +# ── Suppression de fichiers ─────────────────────────────────────────── + +@app.route("/delete", methods=["POST"]) +def delete_file(): + redir = _require_admin() + if redir: + return redir + subpath = request.form.get("path", "").strip() + if not subpath: + abort(400) + target = _safe_path(subpath) + if not target.exists(): + abort(404) + if target.is_dir(): + abort(400) + top = Path(subpath).parts[0] + if top in _HIDDEN or top.startswith("."): + abort(403) + parent = str(Path(subpath).parent) + target.unlink() + return redirect(url_for("browse", subpath=parent) if parent != "." else url_for("browse")) + + +# ── Upload de fichiers ──────────────────────────────────────────────── + +@app.route("/upload", methods=["POST"]) +def upload_file(): + redir = _require_admin() + if redir: + return redir + + subpath = request.form.get("path", "").strip() + files = request.files.getlist("files") + + if not files or all(f.filename == "" for f in files): + abort(400) + + if subpath: + parts = Path(subpath).parts + if parts and (parts[0] in _HIDDEN or parts[0].startswith(".")): + abort(403) + + dest = _safe_path(subpath) if subpath else ASSETS_ROOT + if not dest.is_dir(): + abort(400) + + for f in files: + name = secure_filename(f.filename or "") + if not name: + continue + f.save(dest / name) + + return redirect(url_for("browse", subpath=subpath) if subpath else url_for("browse")) + + +# ── Fichiers CDN publics (dev local uniquement) ─────────────────────── + +_PUBLIC_TOP = frozenset({"logo", "wiki", "error"}) + + +@app.route("/favicon.ico") +def favicon(): + return send_from_directory(ASSETS_ROOT, "favicon.ico") + + +@app.route("/robots.txt") +def robots(): + return send_from_directory(ASSETS_ROOT, "robots.txt") + + +@app.route("/") +def cdn_file(subpath): + top = Path(subpath).parts[0] + if top not in _PUBLIC_TOP: + abort(404) + target = _safe_path(subpath) + if not target.is_file(): + abort(404) + return send_from_directory(ASSETS_ROOT, subpath) + + +if __name__ == "__main__": + app.run(debug=True, port=5003) diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..ed4447a --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,5 @@ +flask>=3.0 +authlib>=1.3 +requests>=2.31 +gunicorn>=21.0 +python-dotenv>=1.0 diff --git a/app/static/app.css b/app/static/app.css new file mode 100644 index 0000000..a73af85 --- /dev/null +++ b/app/static/app.css @@ -0,0 +1,167 @@ +:root { + --blue: #1a6bbf; + --blue-dark: #0f4e8f; + --blue-light: #e8f1fb; + --bg: #f3f6fb; + --text: #1a1a2e; + --muted: #666; + --border: #dce6f3; + --radius: 10px; + --shadow: 0 2px 12px rgba(26,107,191,.1); +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; } +a { color: var(--blue); text-decoration: none; } +a:hover { text-decoration: underline; } +code { background: #e8eef7; padding: .1rem .4rem; border-radius: 4px; font-size: .88em; } + +/* ── Header ─────────────────────────────────────────────────────── */ +header { background: var(--blue-dark); color: #fff; } +.header-inner { max-width: 1100px; margin: 0 auto; padding: .8rem 1.5rem; display: flex; align-items: center; gap: 1.5rem; } + +.brand { display: flex; align-items: center; gap: .6rem; color: #fff; font-size: 1.1rem; text-decoration: none; white-space: nowrap; } +.brand strong { font-weight: 800; } +.brand-sub { opacity: .7; font-weight: 300; } +.brand img { border-radius: 5px; } + +.header-nav { display: flex; gap: .2rem; flex: 1; } +.header-nav a { color: rgba(255,255,255,.8); padding: .4rem .8rem; border-radius: 6px; font-size: .9rem; transition: background .15s; } +.header-nav a:hover { background: rgba(255,255,255,.12); color: #fff; text-decoration: none; } +.header-nav a.active { background: rgba(255,255,255,.18); color: #fff; font-weight: 600; } + +.header-user { margin-left: auto; display: flex; align-items: center; gap: 1rem; color: rgba(255,255,255,.85); font-size: .9rem; white-space: nowrap; } +.btn-logout { color: rgba(255,255,255,.75); font-size: .85rem; border: 1px solid rgba(255,255,255,.4); border-radius: 4px; padding: .3rem .7rem; } +.btn-logout:hover { background: rgba(255,255,255,.1); color: #fff; text-decoration: none; } + +/* ── Mise en page ─────────────────────────────────────────────── */ +main { max-width: 1100px; margin: 2rem auto; padding: 0 1.5rem 3rem; display: flex; flex-direction: column; gap: 1.5rem; } + +/* ── Carte générique ──────────────────────────────────────────── */ +.card { background: #fff; border-radius: var(--radius); box-shadow: var(--shadow); padding: 1.5rem 1.8rem; } +.card h2 { font-size: 1.05rem; color: var(--blue-dark); margin-bottom: 1rem; } + +/* ── Statistiques globales ────────────────────────────────────── */ +.stat-row { display: flex; gap: 1rem; flex-wrap: wrap; } +.stat-box { flex: 1; min-width: 160px; background: var(--blue-light); border-radius: 8px; padding: 1.2rem 1.5rem; } +.stat-box .label { font-size: .78rem; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: .4rem; } +.stat-box .value { font-size: 1.9rem; font-weight: 700; color: var(--blue-dark); line-height: 1; } + +/* ── Grille de dossiers ───────────────────────────────────────── */ +.folder-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 1rem; } +.folder-card { background: #fff; border-radius: var(--radius); box-shadow: var(--shadow); padding: 1.2rem 1.4rem; display: flex; flex-direction: column; gap: .5rem; text-decoration: none; color: var(--text); transition: box-shadow .15s, transform .1s; border: 2px solid transparent; } +.folder-card:hover { box-shadow: 0 4px 20px rgba(26,107,191,.2); transform: translateY(-2px); text-decoration: none; border-color: var(--blue-light); } +.folder-card .icon { font-size: 1.8rem; } +.folder-card .name { font-weight: 700; color: var(--blue-dark); } +.folder-card .meta { font-size: .82rem; color: var(--muted); } + +/* ── Fil d'Ariane ─────────────────────────────────────────────── */ +.breadcrumb { display: flex; align-items: center; gap: .35rem; font-size: .88rem; flex-wrap: wrap; margin-bottom: .8rem; } +.breadcrumb a { color: var(--blue); } +.breadcrumb .sep { color: var(--muted); } +.breadcrumb .current { color: var(--text); font-weight: 600; } + +/* ── Tableau de fichiers ──────────────────────────────────────── */ +.file-table { width: 100%; border-collapse: collapse; font-size: .9rem; } +.file-table th { text-align: left; padding: .55rem .8rem; background: var(--blue-light); color: var(--blue-dark); font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; } +.file-table td { padding: .5rem .8rem; border-bottom: 1px solid var(--border); vertical-align: middle; } +.file-table tr:last-child td { border-bottom: none; } +.file-table tr:hover td { background: #f8faff; } + +.col-icon { width: 2.2rem; text-align: center; font-size: 1.05rem; } +.col-name { display: flex; align-items: center; gap: .7rem; } +.col-name a { color: var(--text); font-weight: 500; } +.col-name a:hover { color: var(--blue); text-decoration: underline; } +.thumb-sm { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); flex-shrink: 0; } +.type-badge { display: inline-block; font-size: .7rem; background: var(--blue-light); color: var(--blue-dark); border-radius: 3px; padding: .1rem .35rem; font-family: monospace; } +.col-size { color: var(--muted); font-size: .85rem; white-space: nowrap; width: 7rem; } +.col-date { color: var(--muted); font-size: .82rem; white-space: nowrap; width: 11rem; } + +/* ── Aperçu de fichier ────────────────────────────────────────── */ +.preview-header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: .8rem; } + +/* ── Navigation précédent/suivant ────────────────────────────────────── */ +.preview-nav { display: flex; align-items: center; gap: .3rem; margin-left: auto; } +.nav-arrow { display: inline-flex; align-items: center; justify-content: center; width: 2.2rem; height: 2.2rem; border-radius: 50%; font-size: 1.5rem; line-height: 1; background: var(--blue-light); color: var(--blue-dark); text-decoration: none; transition: background .15s; } +.nav-arrow:hover { background: var(--blue); color: #fff; text-decoration: none; } +.nav-arrow--off { background: #f0f0f0; color: #ccc; cursor: default; } +.nav-pos { font-size: .78rem; color: var(--muted); padding: 0 .3rem; white-space: nowrap; } +.preview-header h1 { font-size: 1.1rem; font-weight: 700; } +.back-link { color: var(--muted); font-size: .9rem; flex-shrink: 0; } +.back-link:hover { color: var(--blue); text-decoration: none; } +.preview-meta { display: flex; gap: 1.5rem; align-items: center; font-size: .85rem; color: var(--muted); flex-wrap: wrap; } +.preview-meta strong { color: var(--text); } + +.preview-image-wrap { text-align: center; background: #fff; border-radius: var(--radius); box-shadow: var(--shadow); padding: 1.5rem; } +.preview-image-wrap img { max-width: 100%; max-height: 72vh; border-radius: 6px; border: 1px solid var(--border); } + +.preview-text-wrap { background: #111827; border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); } +.text-bar { background: #1f2937; color: #d1d5db; padding: .6rem 1.2rem; font-size: .85rem; display: flex; align-items: center; gap: .8rem; } +.preview-text-wrap pre { color: #a3e635; font-family: 'Courier New', monospace; font-size: .82rem; line-height: 1.6; padding: 1.2rem; overflow: auto; max-height: 72vh; white-space: pre-wrap; word-break: break-word; margin: 0; } + +/* ── Recherche (header) ───────────────────────────────────────────── */ +.header-search { display: flex; align-items: center; gap: .3rem; } +.header-search input { background: rgba(255,255,255,.12); border: 1px solid rgba(255,255,255,.25); border-radius: 6px; color: #fff; padding: .35rem .7rem; font-size: .88rem; width: 180px; outline: none; transition: background .15s, width .2s; } +.header-search input::placeholder { color: rgba(255,255,255,.5); } +.header-search input:focus { background: rgba(255,255,255,.2); width: 240px; } +.header-search button { background: none; border: none; color: rgba(255,255,255,.8); cursor: pointer; font-size: 1rem; padding: .3rem .4rem; line-height: 1; } +.header-search button:hover { color: #fff; } + +/* ── Formulaire de recherche ──────────────────────────────────────── */ +.search-form { display: flex; flex-direction: column; gap: .8rem; } +.search-row { display: flex; gap: .8rem; flex-wrap: wrap; } +.search-field { display: flex; flex-direction: column; gap: .3rem; } +.search-field label { font-size: .78rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; } +.search-field input { border: 1px solid var(--border); border-radius: 6px; padding: .5rem .8rem; font-size: .9rem; color: var(--text); background: #fff; outline: none; } +.search-field input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(26,107,191,.12); } +.search-field--grow { flex: 1; min-width: 200px; } +.search-options { display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap; } +.checkbox-label { display: flex; align-items: center; gap: .5rem; font-size: .88rem; color: var(--muted); cursor: pointer; } +.checkbox-label input { accent-color: var(--blue); } +.search-count { font-size: .88rem; color: var(--muted); margin-bottom: .8rem; } +.file-path { display: block; font-size: .78rem; color: var(--muted); font-family: monospace; } +.content-match { display: block; font-size: .8rem; color: #555; font-style: italic; margin-top: .2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 500px; } + +/* ── Statistiques (iframe) ────────────────────────────────────────── */ +.stats-frame-wrap { border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); background: #fff; } +.stats-frame { width: 100%; height: calc(100vh - 120px); min-height: 600px; border: none; display: block; } +.inline-pre { background: #111827; color: #a3e635; font-family: 'Courier New', monospace; font-size: .82rem; padding: 1rem 1.2rem; border-radius: 8px; white-space: pre-wrap; word-break: break-all; } + +/* ── Boutons ──────────────────────────────────────────────────────── */ +.btn { display: inline-flex; align-items: center; gap: .5rem; padding: .55rem 1.4rem; border-radius: 6px; font-size: .9rem; font-weight: 600; cursor: pointer; border: none; text-decoration: none; transition: all .15s; } +.btn-primary { background: var(--blue); color: #fff; } +.btn-primary:hover { background: var(--blue-dark); text-decoration: none; color: #fff; } + +.empty { text-align: center; color: var(--muted); padding: 2rem; font-style: italic; } + + +/* ── Vues / suppression (browse) ─────────────────────────────────────── */ +.col-hits { width: 4.5rem; text-align: right; white-space: nowrap; } +.col-del { width: 2.4rem; text-align: center; } + +.hits-badge { display: inline-block; font-size: .75rem; border-radius: 10px; padding: .1rem .45rem; font-weight: 600; } +.hits-active { background: #dcfce7; color: #15803d; } +.hits-zero { background: #f3f4f6; color: #9ca3af; } + +.btn-del { background: none; border: none; cursor: pointer; font-size: .95rem; opacity: .4; padding: .2rem; line-height: 1; transition: opacity .15s; } +.btn-del:hover { opacity: 1; } + +/* ── Upload ───────────────────────────────────────────────────────── */ +.upload-card h2 { margin-bottom: .8rem; } +.drop-zone { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .5rem; border: 2px dashed var(--border); border-radius: var(--radius); padding: 2rem 1.5rem; cursor: pointer; background: var(--bg); transition: border-color .15s, background .15s; text-align: center; position: relative; margin-bottom: 1rem; } +.drop-zone:hover, .drop-zone:focus-within { border-color: var(--blue); background: var(--blue-light); } +.drop-zone input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; } +.drop-icon { font-size: 2rem; pointer-events: none; } +.drop-text { font-size: .9rem; color: var(--muted); line-height: 1.5; pointer-events: none; } +.drop-names { font-size: .82rem; color: var(--blue-dark); font-style: italic; word-break: break-all; pointer-events: none; } + +/* ── Responsive ───────────────────────────────────────────────────── */ +@media (max-width: 700px) { + .header-inner { flex-wrap: wrap; } + .header-nav { order: 3; width: 100%; } + .header-search { order: 4; width: 100%; } + .header-search input { width: 100%; } + .header-user { order: 2; } + .col-date { display: none; } + .stat-box .value { font-size: 1.4rem; } +} diff --git a/app/templates/_preview_nav.html b/app/templates/_preview_nav.html new file mode 100644 index 0000000..f5787d0 --- /dev/null +++ b/app/templates/_preview_nav.html @@ -0,0 +1,24 @@ +{# Navigation précédent/suivant pour les aperçus de fichiers #} +
+ {% if prev_path %} + + {% else %} + + {% endif %} + + {% if sibling_pos %} + {{ sibling_pos }} + {% endif %} + + {% if next_path %} + + {% else %} + + {% endif %} +
+ diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..0a37b19 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,45 @@ + + + + + + {% block title %}CDN{% endblock %} — Static Alpinux + + + + + +
+
+ + Alpinux + Alpinux Static + + + +
+ {% if user %} + {{ user.name }} + Déconnexion + {% endif %} +
+
+
+ +
+ {% block content %}{% endblock %} +
+ + + diff --git a/app/templates/browse.html b/app/templates/browse.html new file mode 100644 index 0000000..48ad1b9 --- /dev/null +++ b/app/templates/browse.html @@ -0,0 +1,132 @@ +{% extends "base.html" %} +{% block title %}{{ breadcrumb[-1].name if breadcrumb else 'Parcourir' }}{% endblock %} + +{% block content %} + +
+ + + + {% if entries or subpath %} + + + + + + + + {% if has_hits %}{% endif %} + + + + + + {% if subpath %} + + + + + {% if has_hits %}{% endif %} + + + {% endif %} + + {% for e in entries %} + + + + + + {% if has_hits %} + + {% endif %} + + + {% endfor %} + + +
NomTailleModifié leVues
+ {% if breadcrumb | length > 1 %} + .. (dossier parent) + {% else %} + .. (racine) + {% endif %} +
+ {%- if e.is_dir -%}📁 + {%- elif e.is_image -%}🖼 + {%- elif e.is_pdf -%}📕 + {%- elif e.is_text -%}📄 + {%- else -%}📎 + {%- endif -%} + +
+ {% if e.is_image %} + {{ e.name }} + {% endif %} + + {{ e.name }}{% if e.is_dir %}/{% endif %} + + {% if e.ext and not e.is_dir %} + {{ e.ext }} + {% endif %} +
+
{{ humansize(e.size) if e.size is not none else '—' }}{{ e.mtime.strftime('%d/%m/%Y %H:%M') }} + {% if not e.is_dir %} + {% if e.hits %} + {{ e.hits }} + {% else %} + 0 + {% endif %} + {% endif %} + + {% if not e.is_dir %} +
+ + +
+ {% endif %} +
+ {% else %} +

Dossier vide.

+ {% endif %} + +
+ +
+

Déposer des fichiers dans {{ ('/' + subpath) if subpath else '/' }}

+
+ + + +
+ +
+ +{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..2a8eba3 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Tableau de bord{% endblock %} + +{% block content %} + +
+
+
Fichiers CDN
+
{{ total_files }}
+
+
+
Taille totale
+
{{ humansize(total_size) }}
+
+
+
Dossiers
+
{{ folders | length }}
+
+
+ +
+

Contenu du CDN

+
+ {% for name, info in folders.items() %} + +
📁
+
{{ name }}/
+
+ {{ info.files }} fichier{% if info.files != 1 %}s{% endif %} +  ·  {{ humansize(info.size) }} +
+
+ {% else %} +

Aucun dossier trouvé dans ASSETS_ROOT.

+ {% endfor %} +
+
+ +{% endblock %} diff --git a/app/templates/preview_image.html b/app/templates/preview_image.html new file mode 100644 index 0000000..bacd511 --- /dev/null +++ b/app/templates/preview_image.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}{{ filename }}{% endblock %} + +{% block content %} + +
+
+ ← Retour +

{{ filename }}

+ {% include '_preview_nav.html' %} +
+
+ Taille : {{ filesize }} + Modifié : {{ mtime.strftime('%d/%m/%Y %H:%M') }} + + Télécharger + +
+
+ +
+ {{ filename }} +
+ +{% endblock %} diff --git a/app/templates/preview_other.html b/app/templates/preview_other.html new file mode 100644 index 0000000..df08334 --- /dev/null +++ b/app/templates/preview_other.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}{{ filename }}{% endblock %} + +{% block content %} + +
+
+ ← Retour +

{{ filename }}

+ {% include '_preview_nav.html' %} +
+
+ Taille : {{ filesize }} + Modifié : {{ mtime.strftime('%d/%m/%Y %H:%M') }} +
+
+

+ Aperçu non disponible pour ce type de fichier. +

+ + Télécharger {{ filename }} + +
+
+ +{% endblock %} diff --git a/app/templates/preview_text.html b/app/templates/preview_text.html new file mode 100644 index 0000000..4b098c9 --- /dev/null +++ b/app/templates/preview_text.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}{{ filename }}{% endblock %} + +{% block content %} + +
+
+ ← Retour +

{{ filename }}

+ {% include '_preview_nav.html' %} +
+
+ Taille : {{ filesize }} + Modifié : {{ mtime.strftime('%d/%m/%Y %H:%M') }} + + Télécharger + +
+
+ +
+
+ {{ filename }} + {{ lang }} +
+
{{ content | e }}
+
+ +{% endblock %} diff --git a/app/templates/search.html b/app/templates/search.html new file mode 100644 index 0000000..fd476ee --- /dev/null +++ b/app/templates/search.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} +{% block title %}Recherche{% endblock %} + +{% block content %} + +
+

Recherche

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +{% if searched %} +
+ {% if results %} +

+ {{ results | length }} résultat{% if results | length != 1 %}s{% endif %} + {% if q %}pour « {{ q }} »{% endif %} + {% if after %}· après {{ after }}{% endif %} + {% if before %}· avant {{ before }}{% endif %} +

+ + + + + + + + + + + {% for e in results %} + + + + + + + {% endfor %} + +
FichierTailleModifié le
+ {%- if e.is_image -%}🖼 + {%- elif e.is_pdf -%}📕 + {%- elif e.is_text -%}📄 + {%- else -%}📎 + {%- endif -%} + +
+ {% if e.is_image %} + {{ e.name }} + {% endif %} +
+ {{ e.name }} + {{ e.path }} + {% if e.content_match %} + …{{ e.content_match }}… + {% endif %} +
+
+
{{ humansize(e.size) if e.size is not none else '—' }}{{ e.mtime.strftime('%d/%m/%Y %H:%M') }}
+ {% else %} +

Aucun résultat + {% if q %}pour « {{ q }} »{% endif %} + {% if after or before %}dans la plage de dates sélectionnée{% endif %}. +

+ {% endif %} +
+{% endif %} + +{% endblock %} diff --git a/app/templates/stats.html b/app/templates/stats.html new file mode 100644 index 0000000..8f0095b --- /dev/null +++ b/app/templates/stats.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} +{% block title %}Statistiques{% endblock %} + +{% block content %} + +{% if has_report %} +
+ + ↗ Ouvrir dans un nouvel onglet + +
+
+ +
+{% else %} +
+

Statistiques de trafic

+

Aucun rapport disponible.

+ +
+ + +{% endif %} + +{% endblock %}