feat: upload de fichiers dans l'app Flask CDN

Ajoute la route POST /upload (admin uniquement) et la zone de dépôt
dans browse.html — glisser-déposer ou sélection multiple, destination
= dossier courant du navigateur.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alpinux 2026-05-03 20:35:29 +02:00
parent 7c70e904f3
commit 64989e83c8
13 changed files with 1212 additions and 0 deletions

29
app/.env.example Normal file
View file

@ -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=<chemin absolu>
# ── 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=

511
app/app.py Normal file
View file

@ -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/<path:subpath>")
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/<path:subpath>")
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("/<path:subpath>")
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)

5
app/requirements.txt Normal file
View file

@ -0,0 +1,5 @@
flask>=3.0
authlib>=1.3
requests>=2.31
gunicorn>=21.0
python-dotenv>=1.0

167
app/static/app.css Normal file
View file

@ -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; }
}

View file

@ -0,0 +1,24 @@
{# Navigation précédent/suivant pour les aperçus de fichiers #}
<div class="preview-nav">
{% if prev_path %}
<a href="{{ url_for('browse', subpath=prev_path) }}" class="nav-arrow" id="nav-prev" title="Précédent (←)">&#8249;</a>
{% else %}
<span class="nav-arrow nav-arrow--off">&#8249;</span>
{% endif %}
{% if sibling_pos %}
<span class="nav-pos">{{ sibling_pos }}</span>
{% endif %}
{% if next_path %}
<a href="{{ url_for('browse', subpath=next_path) }}" class="nav-arrow" id="nav-next" title="Suivant (→)">&#8250;</a>
{% else %}
<span class="nav-arrow nav-arrow--off">&#8250;</span>
{% endif %}
</div>
<script>
document.addEventListener('keydown', function(e) {
if (e.key === 'ArrowLeft') { var a = document.getElementById('nav-prev'); if (a) location.href = a.href; }
if (e.key === 'ArrowRight') { var a = document.getElementById('nav-next'); if (a) location.href = a.href; }
});
</script>

45
app/templates/base.html Normal file
View file

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}CDN{% endblock %} — Static Alpinux</title>
<link rel="icon" type="image/x-icon" href="https://static.alpinux.org/logo/favicon.ico">
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
</head>
<body>
<header>
<div class="header-inner">
<a href="{{ url_for('dashboard') }}" class="brand">
<img src="https://static.alpinux.org/logo/alpinux-logo.png" alt="Alpinux" width="36" height="36">
<span>A<strong>l</strong>p<strong>inux</strong> <span class="brand-sub">Static</span></span>
</a>
<nav class="header-nav">
<a href="{{ url_for('dashboard') }}"
{% if request.endpoint == 'dashboard' %}class="active"{% endif %}>Tableau de bord</a>
<a href="{{ url_for('browse') }}"
{% if request.endpoint == 'browse' %}class="active"{% endif %}>Parcourir</a>
<a href="{{ url_for('stats') }}"
{% if request.endpoint in ('stats', 'stats_report') %}class="active"{% endif %}>Statistiques</a>
</nav>
<form class="header-search" action="{{ url_for('search') }}" method="get" role="search">
<input type="search" name="q" placeholder="Rechercher…"
value="{{ request.args.get('q', '') }}" aria-label="Recherche">
<button type="submit" aria-label="Lancer la recherche">🔍</button>
</form>
<div class="header-user">
{% if user %}
<span>{{ user.name }}</span>
<a href="{{ url_for('logout') }}" class="btn-logout">Déconnexion</a>
{% endif %}
</div>
</div>
</header>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

132
app/templates/browse.html Normal file
View file

@ -0,0 +1,132 @@
{% extends "base.html" %}
{% block title %}{{ breadcrumb[-1].name if breadcrumb else 'Parcourir' }}{% endblock %}
{% block content %}
<section class="card">
<nav class="breadcrumb">
<a href="{{ url_for('browse') }}">CDN</a>
{% for crumb in breadcrumb %}
<span class="sep">/</span>
{% if loop.last %}
<span class="current">{{ crumb.name }}</span>
{% else %}
<a href="{{ url_for('browse', subpath=crumb.path) }}">{{ crumb.name }}</a>
{% endif %}
{% endfor %}
</nav>
{% if entries or subpath %}
<table class="file-table">
<thead>
<tr>
<th class="col-icon"></th>
<th>Nom</th>
<th class="col-size">Taille</th>
<th class="col-date">Modifié le</th>
{% if has_hits %}<th class="col-hits">Vues</th>{% endif %}
<th class="col-del"></th>
</tr>
</thead>
<tbody>
{% if subpath %}
<tr>
<td class="col-icon"></td>
<td>
{% if breadcrumb | length > 1 %}
<a href="{{ url_for('browse', subpath=breadcrumb[-2].path) }}">.. (dossier parent)</a>
{% else %}
<a href="{{ url_for('browse') }}">.. (racine)</a>
{% endif %}
</td>
<td></td><td></td>
{% if has_hits %}<td></td>{% endif %}
<td></td>
</tr>
{% endif %}
{% for e in entries %}
<tr>
<td class="col-icon">
{%- if e.is_dir -%}📁
{%- elif e.is_image -%}🖼
{%- elif e.is_pdf -%}📕
{%- elif e.is_text -%}📄
{%- else -%}📎
{%- endif -%}
</td>
<td>
<div class="col-name">
{% if e.is_image %}
<img class="thumb-sm"
src="{{ url_for('raw_file', subpath=e.path) }}"
alt="{{ e.name }}"
loading="lazy">
{% endif %}
<a href="{{ url_for('browse', subpath=e.path) }}">
{{ e.name }}{% if e.is_dir %}/{% endif %}
</a>
{% if e.ext and not e.is_dir %}
<span class="type-badge">{{ e.ext }}</span>
{% endif %}
</div>
</td>
<td class="col-size">{{ humansize(e.size) if e.size is not none else '—' }}</td>
<td class="col-date">{{ e.mtime.strftime('%d/%m/%Y %H:%M') }}</td>
{% if has_hits %}
<td class="col-hits">
{% if not e.is_dir %}
{% if e.hits %}
<span class="hits-badge hits-active" title="{{ e.hits }} requête(s) dans les stats">{{ e.hits }}</span>
{% else %}
<span class="hits-badge hits-zero" title="Aucune vue dans les stats">0</span>
{% endif %}
{% endif %}
</td>
{% endif %}
<td class="col-del">
{% if not e.is_dir %}
<form method="post" action="{{ url_for('delete_file') }}">
<input type="hidden" name="path" value="{{ e.path }}">
<button type="submit" class="btn-del" title="Supprimer du CDN">🗑</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">Dossier vide.</p>
{% endif %}
</section>
<section class="card upload-card">
<h2>Déposer des fichiers dans {{ ('/' + subpath) if subpath else '/' }}</h2>
<form method="post" action="{{ url_for('upload_file') }}" enctype="multipart/form-data" id="upload-form">
<input type="hidden" name="path" value="{{ subpath }}">
<label class="drop-zone" for="upload-input">
<span class="drop-icon">📤</span>
<span class="drop-text">Glisser-déposer des fichiers ici<br>ou cliquer pour sélectionner</span>
<span class="drop-names" id="drop-names"></span>
<input type="file" name="files" id="upload-input" multiple>
</label>
<button type="submit" class="btn btn-primary" id="upload-btn" disabled>Envoyer</button>
</form>
<script>
const inp = document.getElementById('upload-input');
const btn = document.getElementById('upload-btn');
const lbl = document.getElementById('drop-names');
inp.addEventListener('change', () => {
const n = inp.files.length;
lbl.textContent = n ? Array.from(inp.files).map(f => f.name).join(', ') : '';
btn.disabled = n === 0;
});
</script>
</section>
{% endblock %}

View file

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Tableau de bord{% endblock %}
{% block content %}
<div class="stat-row">
<div class="stat-box">
<div class="label">Fichiers CDN</div>
<div class="value">{{ total_files }}</div>
</div>
<div class="stat-box">
<div class="label">Taille totale</div>
<div class="value">{{ humansize(total_size) }}</div>
</div>
<div class="stat-box">
<div class="label">Dossiers</div>
<div class="value">{{ folders | length }}</div>
</div>
</div>
<section class="card">
<h2>Contenu du CDN</h2>
<div class="folder-grid">
{% for name, info in folders.items() %}
<a href="{{ url_for('browse', subpath=name) }}" class="folder-card">
<div class="icon">📁</div>
<div class="name">{{ name }}/</div>
<div class="meta">
{{ info.files }} fichier{% if info.files != 1 %}s{% endif %}
&nbsp;·&nbsp; {{ humansize(info.size) }}
</div>
</a>
{% else %}
<p class="empty">Aucun dossier trouvé dans <code>ASSETS_ROOT</code>.</p>
{% endfor %}
</div>
</section>
{% endblock %}

View file

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}{{ filename }}{% endblock %}
{% block content %}
<section class="card">
<div class="preview-header">
<a href="{{ url_for('browse', subpath=parent_path) if parent_path else url_for('browse') }}"
class="back-link">← Retour</a>
<h1>{{ filename }}</h1>
{% include '_preview_nav.html' %}
</div>
<div class="preview-meta">
<span>Taille : <strong>{{ filesize }}</strong></span>
<span>Modifié : <strong>{{ mtime.strftime('%d/%m/%Y %H:%M') }}</strong></span>
<a href="{{ raw_url }}" download="{{ filename }}" class="btn btn-primary" style="margin-left:auto">
Télécharger
</a>
</div>
</section>
<div class="preview-image-wrap">
<img src="{{ raw_url }}" alt="{{ filename }}">
</div>
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}{{ filename }}{% endblock %}
{% block content %}
<section class="card">
<div class="preview-header">
<a href="{{ url_for('browse', subpath=parent_path) if parent_path else url_for('browse') }}"
class="back-link">← Retour</a>
<h1>{{ filename }}</h1>
{% include '_preview_nav.html' %}
</div>
<div class="preview-meta">
<span>Taille : <strong>{{ filesize }}</strong></span>
<span>Modifié : <strong>{{ mtime.strftime('%d/%m/%Y %H:%M') }}</strong></span>
</div>
<div style="margin-top:1.5rem">
<p style="color:var(--muted); font-size:.9rem; margin-bottom:1rem">
Aperçu non disponible pour ce type de fichier.
</p>
<a href="{{ raw_url }}" class="btn btn-primary" download="{{ filename }}">
Télécharger {{ filename }}
</a>
</div>
</section>
{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}{{ filename }}{% endblock %}
{% block content %}
<section class="card">
<div class="preview-header">
<a href="{{ url_for('browse', subpath=parent_path) if parent_path else url_for('browse') }}"
class="back-link">← Retour</a>
<h1>{{ filename }}</h1>
{% include '_preview_nav.html' %}
</div>
<div class="preview-meta">
<span>Taille : <strong>{{ filesize }}</strong></span>
<span>Modifié : <strong>{{ mtime.strftime('%d/%m/%Y %H:%M') }}</strong></span>
<a href="{{ raw_url }}" download="{{ filename }}" class="btn btn-primary" style="margin-left:auto">
Télécharger
</a>
</div>
</section>
<div class="preview-text-wrap">
<div class="text-bar">
<span>{{ filename }}</span>
<code>{{ lang }}</code>
</div>
<pre>{{ content | e }}</pre>
</div>
{% endblock %}

91
app/templates/search.html Normal file
View file

@ -0,0 +1,91 @@
{% extends "base.html" %}
{% block title %}Recherche{% endblock %}
{% block content %}
<section class="card">
<h2>Recherche</h2>
<form class="search-form" action="{{ url_for('search') }}" method="get">
<div class="search-row">
<div class="search-field search-field--grow">
<label for="q">Nom / contenu</label>
<input id="q" type="search" name="q" value="{{ q }}" placeholder="ex. logo, favicon…" autofocus>
</div>
<div class="search-field">
<label for="after">Modifié après</label>
<input id="after" type="date" name="after" value="{{ after }}">
</div>
<div class="search-field">
<label for="before">Modifié avant</label>
<input id="before" type="date" name="before" value="{{ before }}">
</div>
</div>
<div class="search-options">
<label class="checkbox-label">
<input type="checkbox" name="content" value="1" {% if in_content %}checked{% endif %}>
Rechercher aussi dans le contenu des fichiers texte
</label>
<button type="submit" class="btn btn-primary">Rechercher</button>
</div>
</form>
</section>
{% if searched %}
<section class="card">
{% if results %}
<p class="search-count">
<strong>{{ results | length }}</strong> résultat{% if results | length != 1 %}s{% endif %}
{% if q %}pour <em>« {{ q }} »</em>{% endif %}
{% if after %}· après {{ after }}{% endif %}
{% if before %}· avant {{ before }}{% endif %}
</p>
<table class="file-table">
<thead>
<tr>
<th class="col-icon"></th>
<th>Fichier</th>
<th class="col-size">Taille</th>
<th class="col-date">Modifié le</th>
</tr>
</thead>
<tbody>
{% for e in results %}
<tr>
<td class="col-icon">
{%- if e.is_image -%}🖼
{%- elif e.is_pdf -%}📕
{%- elif e.is_text -%}📄
{%- else -%}📎
{%- endif -%}
</td>
<td>
<div class="col-name">
{% if e.is_image %}
<img class="thumb-sm" src="{{ url_for('raw_file', subpath=e.path) }}"
alt="{{ e.name }}" loading="lazy">
{% endif %}
<div>
<a href="{{ url_for('browse', subpath=e.path) }}">{{ e.name }}</a>
<span class="file-path">{{ e.path }}</span>
{% if e.content_match %}
<span class="content-match">…{{ e.content_match }}…</span>
{% endif %}
</div>
</div>
</td>
<td class="col-size">{{ humansize(e.size) if e.size is not none else '—' }}</td>
<td class="col-date">{{ e.mtime.strftime('%d/%m/%Y %H:%M') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">Aucun résultat
{% if q %}pour <em>« {{ q }} »</em>{% endif %}
{% if after or before %}dans la plage de dates sélectionnée{% endif %}.
</p>
{% endif %}
</section>
{% endif %}
{% endblock %}

86
app/templates/stats.html Normal file
View file

@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}Statistiques{% endblock %}
{% block content %}
{% if has_report %}
<div style="display:flex; justify-content:flex-end; margin-bottom:.6rem">
<a href="{{ url_for('stats_report') }}" target="_blank" class="btn btn-primary">
↗ Ouvrir dans un nouvel onglet
</a>
</div>
<div class="stats-frame-wrap">
<iframe src="{{ url_for('stats_report') }}" class="stats-frame" title="Rapport GoAccess"></iframe>
</div>
{% else %}
<section class="card">
<h2>Statistiques de trafic</h2>
<p id="gen-msg" style="color:var(--muted);margin-bottom:1rem">Aucun rapport disponible.</p>
<button id="gen-btn" class="btn btn-primary" onclick="startGeneration()">Générer et ouvrir</button>
</section>
<script>
(function () {
const GENERATE_URL = {{ url_for('stats_generate') | tojson }};
const STATUS_URL = {{ url_for('stats_status') | tojson }};
const REPORT_URL = {{ url_for('stats_report') | tojson }};
function setMsg(text) {
document.getElementById('gen-msg').textContent = text;
}
function setBtn(text, disabled) {
const b = document.getElementById('gen-btn');
b.textContent = text;
b.disabled = disabled;
}
function pollReady() {
const id = setInterval(async () => {
try {
const d = await fetch(STATUS_URL).then(r => r.json());
if (d.ready) {
clearInterval(id);
setMsg('Rapport prêt. Ouverture…');
window.open(REPORT_URL, '_blank');
location.reload();
} else if (!d.generating) {
clearInterval(id);
setMsg('La génération a échoué. Vérifier la configuration serveur (STATS_LOG_FILE).');
setBtn('Réessayer', false);
}
} catch (_) {}
}, 2000);
}
window.startGeneration = async function () {
setBtn('Génération en cours…', true);
setMsg('Lancement de la génération en arrière-plan…');
try {
const d = await fetch(GENERATE_URL, { method: 'POST' }).then(r => r.json());
if (d.status === 'error') {
setMsg('Erreur : ' + (d.message || 'configuration manquante'));
setBtn('Réessayer', false);
return;
}
setMsg('Génération en cours, veuillez patienter…');
pollReady();
} catch (_) {
setMsg('Erreur réseau.');
setBtn('Réessayer', false);
}
};
// Reprendre le polling si une génération est déjà en cours
fetch(STATUS_URL).then(r => r.json()).then(d => {
if (d.ready) { location.reload(); }
else if (d.generating) {
setBtn('Génération en cours…', true);
setMsg('Génération en cours, veuillez patienter…');
pollReady();
}
}).catch(() => {});
})();
</script>
{% endif %}
{% endblock %}