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