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:
parent
7c70e904f3
commit
64989e83c8
13 changed files with 1212 additions and 0 deletions
29
app/.env.example
Normal file
29
app/.env.example
Normal 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
511
app/app.py
Normal 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
5
app/requirements.txt
Normal 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
167
app/static/app.css
Normal 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; }
|
||||||
|
}
|
||||||
24
app/templates/_preview_nav.html
Normal file
24
app/templates/_preview_nav.html
Normal 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 (←)">‹</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="nav-arrow nav-arrow--off">‹</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 (→)">›</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="nav-arrow nav-arrow--off">›</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
45
app/templates/base.html
Normal 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
132
app/templates/browse.html
Normal 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 %}
|
||||||
39
app/templates/dashboard.html
Normal file
39
app/templates/dashboard.html
Normal 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 %}
|
||||||
|
· {{ humansize(info.size) }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty">Aucun dossier trouvé dans <code>ASSETS_ROOT</code>.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
26
app/templates/preview_image.html
Normal file
26
app/templates/preview_image.html
Normal 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 %}
|
||||||
27
app/templates/preview_other.html
Normal file
27
app/templates/preview_other.html
Normal 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 %}
|
||||||
30
app/templates/preview_text.html
Normal file
30
app/templates/preview_text.html
Normal 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
91
app/templates/search.html
Normal 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
86
app/templates/stats.html
Normal 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 %}
|
||||||
Loading…
Reference in a new issue