feat(password-generator): envoi de mails avec templates HTML, images et pièces jointes
- Page de génération : sélection de template, variables {{ }} éditables
(pré-remplies via AD), aperçu du rendu HTML
- Serveur : routes /mail/send (smtplib), upload d'images et de fichiers ;
images inlinées en data URI, fichiers ajoutés en pièces jointes à l'envoi
- Rendu email-safe partagé (banner, listes, code, liens)
- gitignore des contenus utilisateur (images/, files/)
- Lien du générateur ajouté à la navigation des autres apps + README
- Bump du sous-module cmdb (éditeur de templates)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
05df4c0dd8
commit
90f3a2ac3d
6 changed files with 1558 additions and 45 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -9,6 +9,10 @@ cmdb/snapshots/
|
||||||
# Certificats générés (contiennent des clés privées)
|
# Certificats générés (contiennent des clés privées)
|
||||||
cert-generator/LISTS_ALL_CERT/
|
cert-generator/LISTS_ALL_CERT/
|
||||||
|
|
||||||
|
# Images et fichiers importés dans les templates email (contenu utilisateur)
|
||||||
|
password-generator/images/
|
||||||
|
password-generator/files/
|
||||||
|
|
||||||
# === Environnement / build ===
|
# === Environnement / build ===
|
||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ Portail d'outils pour administrateurs systèmes et réseau, servi par un serveur
|
||||||
- **cmdb** — inventaire (machines, réseaux, IP/MAC, salles & baies), recherche Active Directory, import/export CSV, snapshots de la base
|
- **cmdb** — inventaire (machines, réseaux, IP/MAC, salles & baies), recherche Active Directory, import/export CSV, snapshots de la base
|
||||||
- **cert-generator** — génération de certificats SSL (clés RSA, CSR)
|
- **cert-generator** — génération de certificats SSL (clés RSA, CSR)
|
||||||
- **switch-config** — mapper de VLAN et export de configuration switch
|
- **switch-config** — mapper de VLAN et export de configuration switch
|
||||||
|
- **password-generator** — génération de mots de passe aléatoires, passphrases et mots de passe temporaires (préfixe configurable, envoi par mail via l'AD + templates)
|
||||||
|
|
||||||
Le tout accessible depuis un seul serveur, avec navigation entre les 3 apps.
|
Le tout accessible depuis un seul serveur, avec navigation entre les apps.
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,7 @@
|
||||||
<a href="../cmdb/cmdb.html"><div class="psw-ico">◧</div><div class="psw-txt"><b>CMDB</b><small>Machines, réseaux, salles</small></div></a>
|
<a href="../cmdb/cmdb.html"><div class="psw-ico">◧</div><div class="psw-txt"><b>CMDB</b><small>Machines, réseaux, salles</small></div></a>
|
||||||
<a class="active" href="dashboard.html"><div class="psw-ico">🔐</div><div class="psw-txt"><b>Générateur certificats</b><small>Clés RSA, CSR, SSL</small></div></a>
|
<a class="active" href="dashboard.html"><div class="psw-ico">🔐</div><div class="psw-txt"><b>Générateur certificats</b><small>Clés RSA, CSR, SSL</small></div></a>
|
||||||
<a href="../switch-config/switch_vlan_mapper.html"><div class="psw-ico">🔌</div><div class="psw-txt"><b>Configuration switch</b><small>VLANs, export config</small></div></a>
|
<a href="../switch-config/switch_vlan_mapper.html"><div class="psw-ico">🔌</div><div class="psw-txt"><b>Configuration switch</b><small>VLANs, export config</small></div></a>
|
||||||
|
<a href="../password-generator/password_generator.html"><div class="psw-ico">🔑</div><div class="psw-txt"><b>Mots de passe</b><small>Générateur & envoi</small></div></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="psw" style="margin-top:auto;padding-top:14px;border-top:1px solid var(--line)">
|
<div class="psw" style="margin-top:auto;padding-top:14px;border-top:1px solid var(--line)">
|
||||||
<a href="../cmdb/preferences.html"><div class="psw-ico">⚙</div><div class="psw-txt"><b>Préférences</b><small>Types, tickets, réglages</small></div></a>
|
<a href="../cmdb/preferences.html"><div class="psw-ico">⚙</div><div class="psw-txt"><b>Préférences</b><small>Types, tickets, réglages</small></div></a>
|
||||||
|
|
|
||||||
2
cmdb
2
cmdb
|
|
@ -1 +1 @@
|
||||||
Subproject commit 18751d75c88a32790a50c1f0a458a2657db6a2ad
|
Subproject commit 805f0fe00b1314970a95c717c2945102130a0fd4
|
||||||
1238
password-generator/password_generator.html
Normal file
1238
password-generator/password_generator.html
Normal file
File diff suppressed because it is too large
Load diff
355
server.py
355
server.py
|
|
@ -1,6 +1,6 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Serveur unifié du portail Infra (CMDB + Certificats + Switch).
|
Serveur unifié du portail Infra (CMDB + Certificats + Switch + Mots de passe).
|
||||||
|
|
||||||
Un seul serveur, lancé à la racine du projet :
|
Un seul serveur, lancé à la racine du projet :
|
||||||
|
|
||||||
|
|
@ -11,11 +11,15 @@ Un seul serveur, lancé à la racine du projet :
|
||||||
- CMDB : base partagée cmdb.db sourcée automatiquement (/db, versionnée)
|
- CMDB : base partagée cmdb.db sourcée automatiquement (/db, versionnée)
|
||||||
- Générateur de certificats : écrit sur le disque dans
|
- Générateur de certificats : écrit sur le disque dans
|
||||||
cert-generator/LISTS_ALL_CERT/ (créé au besoin), aucun prompt
|
cert-generator/LISTS_ALL_CERT/ (créé au besoin), aucun prompt
|
||||||
- Sert les 3 applications + le switcher (liens relatifs OK)
|
- Sert les 4 applications + le switcher (liens relatifs OK)
|
||||||
- Recherche AD/LDAP de la CMDB conservée (nécessite `pip install ldap3`)
|
- Recherche AD/LDAP de la CMDB conservée (nécessite `pip install ldap3`)
|
||||||
|
- Générateur de mots de passe : envoi de mails via POST /mail/send (SMTP
|
||||||
|
configuré dans la page, module standard smtplib)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys, os, json, base64, shutil, re, threading
|
import sys, os, json, base64, shutil, re, smtplib, threading, mimetypes, sqlite3
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formataddr
|
||||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
|
@ -27,6 +31,13 @@ CMDB_DIR = os.path.join(HERE, "cmdb")
|
||||||
CERT_DIR = os.path.join(HERE, "cert-generator")
|
CERT_DIR = os.path.join(HERE, "cert-generator")
|
||||||
WORK = os.path.join(CERT_DIR, "LISTS_ALL_CERT") # dossier de travail certs (disque)
|
WORK = os.path.join(CERT_DIR, "LISTS_ALL_CERT") # dossier de travail certs (disque)
|
||||||
ARCH = os.path.join(WORK, "archives")
|
ARCH = os.path.join(WORK, "archives")
|
||||||
|
PWGEN_DIR = os.path.join(HERE, "password-generator")
|
||||||
|
PWGEN_IMG = os.path.join(PWGEN_DIR, "images") # images des templates email (disque)
|
||||||
|
PWGEN_IMG_URL = "/password-generator/images/" # préfixe d'URL servi (chdir HERE)
|
||||||
|
PWGEN_FILES = os.path.join(PWGEN_DIR, "files") # fichiers joignables (rdp, pdf…)
|
||||||
|
PWGEN_FILES_URL = "/password-generator/files/"
|
||||||
|
MAX_IMG_BYTES = 2 * 1024 * 1024 # 2 Mo par image
|
||||||
|
MAX_FILE_BYTES = 10 * 1024 * 1024 # 10 Mo par fichier joint
|
||||||
|
|
||||||
# réutilise la logique CMDB existante (base partagée cmdb.db + LDAP)
|
# réutilise la logique CMDB existante (base partagée cmdb.db + LDAP)
|
||||||
sys.path.insert(0, CMDB_DIR)
|
sys.path.insert(0, CMDB_DIR)
|
||||||
|
|
@ -46,6 +57,175 @@ def ensure_work():
|
||||||
os.makedirs(WORK, exist_ok=True)
|
os.makedirs(WORK, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def read_vlans():
|
||||||
|
"""Lit les VLANs depuis la table `networks` de la base CMDB partagée
|
||||||
|
(cmdb.db). Un réseau devient un VLAN dès qu'il porte un numéro de VLAN.
|
||||||
|
Renvoie une liste triée : [{"vlan": int, "name": str, "color": str}, ...].
|
||||||
|
Source de vérité unique pour le mapper de switch (remplace vlan_database.js)."""
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect("file:%s?mode=ro" % C.DB_PATH, uri=True)
|
||||||
|
except sqlite3.Error:
|
||||||
|
return out
|
||||||
|
try:
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
for r in con.execute(
|
||||||
|
"SELECT vlan, name, color FROM networks "
|
||||||
|
"WHERE vlan IS NOT NULL ORDER BY vlan"
|
||||||
|
):
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"vlan": r["vlan"],
|
||||||
|
"name": r["name"] or ("VLAN_%s" % r["vlan"]),
|
||||||
|
"color": r["color"] or "#8B5CF6",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except sqlite3.Error:
|
||||||
|
pass # table absente / base illisible → liste vide, repli côté client
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cache du listing des certificats.
|
||||||
|
# Les certs sont des fichiers sur disque (LISTS_ALL_CERT/<dir>/<fichiers>).
|
||||||
|
# Sans cache, chaque GET /api/list re-scanne tout le disque (N×M syscalls) →
|
||||||
|
# lent quand il y a beaucoup de certs. On mémorise le résultat et on ne le
|
||||||
|
# reconstruit que si le contenu a changé :
|
||||||
|
# - empreinte légère (mtime des sous-dossiers) → détecte ajout/suppression
|
||||||
|
# et toute écriture passant par le serveur (le mtime du dossier bouge) ;
|
||||||
|
# - invalidation explicite après chaque write/delete → couvre l'écrasement
|
||||||
|
# d'un fichier en place (même nom), que le mtime du dossier ne voit pas.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_CERT_CACHE = {"sig": None, "payload": None}
|
||||||
|
_CERT_CACHE_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_cert_cache():
|
||||||
|
with _CERT_CACHE_LOCK:
|
||||||
|
_CERT_CACHE["sig"] = None
|
||||||
|
_CERT_CACHE["payload"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _cert_signature():
|
||||||
|
"""Empreinte du dossier certs : (nom, mtime_ns) de chaque sous-dossier.
|
||||||
|
Ne descend pas dans les fichiers → peu de syscalls. Renvoie None si le
|
||||||
|
dossier est illisible (force alors une reconstruction)."""
|
||||||
|
sig = []
|
||||||
|
try:
|
||||||
|
with os.scandir(WORK) as it:
|
||||||
|
for e in it:
|
||||||
|
try:
|
||||||
|
sig.append((e.name, e.stat().st_mtime_ns))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
sig.sort()
|
||||||
|
return tuple(sig)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cert_list():
|
||||||
|
"""Reconstruit le listing complet via os.scandir (le stat est replié dans la
|
||||||
|
lecture du dossier → bien moins de syscalls que listdir + stat séparés)."""
|
||||||
|
certs, archives = [], []
|
||||||
|
try:
|
||||||
|
with os.scandir(WORK) as it:
|
||||||
|
entries = sorted(it, key=lambda e: e.name)
|
||||||
|
except OSError:
|
||||||
|
entries = []
|
||||||
|
for entry in entries:
|
||||||
|
if entry.name == "archives":
|
||||||
|
if entry.is_dir():
|
||||||
|
try:
|
||||||
|
with os.scandir(entry.path) as ait:
|
||||||
|
for a in sorted(ait, key=lambda e: e.name):
|
||||||
|
if a.is_file():
|
||||||
|
st = a.stat()
|
||||||
|
archives.append(
|
||||||
|
{
|
||||||
|
"name": a.name,
|
||||||
|
"size": st.st_size,
|
||||||
|
"mtime": int(st.st_mtime * 1000),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
if not entry.is_dir():
|
||||||
|
continue
|
||||||
|
files, date = [], 0
|
||||||
|
try:
|
||||||
|
with os.scandir(entry.path) as sit:
|
||||||
|
subs = sorted(sit, key=lambda e: e.name)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for f in subs:
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
st = f.stat()
|
||||||
|
m = int(st.st_mtime * 1000)
|
||||||
|
date = max(date, m)
|
||||||
|
files.append(
|
||||||
|
{
|
||||||
|
"name": f.name,
|
||||||
|
"ext": (f.name.rsplit(".", 1)[-1] if "." in f.name else "").lower(),
|
||||||
|
"size": st.st_size,
|
||||||
|
"mtime": m,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if files:
|
||||||
|
certs.append({"dir": entry.name, "files": files, "date": date})
|
||||||
|
archives.sort(key=lambda a: -a["mtime"])
|
||||||
|
certs.sort(key=lambda c: -c["date"])
|
||||||
|
return {"ok": True, "certs": certs, "archives": archives}
|
||||||
|
|
||||||
|
|
||||||
|
# src d'images pointant vers le dossier local des templates (chemin absolu ou relatif)
|
||||||
|
_LOCAL_IMG_SRC = re.compile(r'src="([^"]*?/password-generator/images/([^"?#]+))"', re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def inline_local_images(html):
|
||||||
|
"""Remplace les <img src="…/password-generator/images/x.png"> par des data
|
||||||
|
URI (lecture du fichier sur disque). Les images data:/http(s): sont laissées
|
||||||
|
telles quelles. Rend le mail HTML autonome au moment de l'envoi."""
|
||||||
|
|
||||||
|
def repl(m):
|
||||||
|
fname = os.path.basename(m.group(2)) # anti-traversal
|
||||||
|
path = os.path.join(PWGEN_IMG, fname)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return m.group(0)
|
||||||
|
mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = base64.b64encode(f.read()).decode("ascii")
|
||||||
|
return 'src="data:%s;base64,%s"' % (mime, data)
|
||||||
|
|
||||||
|
return _LOCAL_IMG_SRC.sub(repl, html)
|
||||||
|
|
||||||
|
|
||||||
|
def attach_files(msg, urls):
|
||||||
|
"""Joint au mail les fichiers du dossier password-generator/files/ dont l'URL
|
||||||
|
figure dans la liste `urls` (pièces jointes du template). Le nom présenté au
|
||||||
|
destinataire est le nom d'origine (suffixe aléatoire de stockage retiré)."""
|
||||||
|
seen = set()
|
||||||
|
for url in urls or []:
|
||||||
|
fname = os.path.basename(str(url)) # anti-traversal
|
||||||
|
if not fname or fname in seen:
|
||||||
|
continue
|
||||||
|
seen.add(fname)
|
||||||
|
path = os.path.join(PWGEN_FILES, fname)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
|
ctype = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
||||||
|
maintype, _, subtype = ctype.partition("/")
|
||||||
|
disp = re.sub(r"-[0-9a-f]{8}(\.[^.]+)$", r"\1", fname) # nom d'origine lisible
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
msg.add_attachment(
|
||||||
|
f.read(), maintype=maintype, subtype=subtype or "octet-stream", filename=disp
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Handler(SimpleHTTPRequestHandler):
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
def log_message(self, *a):
|
def log_message(self, *a):
|
||||||
pass
|
pass
|
||||||
|
|
@ -106,6 +286,10 @@ class Handler(SimpleHTTPRequestHandler):
|
||||||
return self._send_db(200, C.db_bytes(), C.db_version())
|
return self._send_db(200, C.db_bytes(), C.db_version())
|
||||||
if self.path == "/db/version":
|
if self.path == "/db/version":
|
||||||
return self._json({"version": C.db_version()})
|
return self._json({"version": C.db_version()})
|
||||||
|
# VLANs = réseaux CMDB (table networks) → consommés par le mapper switch
|
||||||
|
if self.path == "/api/vlans":
|
||||||
|
with C.DB_LOCK:
|
||||||
|
return self._json(read_vlans())
|
||||||
if self.path == "/db/snapshots":
|
if self.path == "/db/snapshots":
|
||||||
return self._json({"snapshots": C.list_snapshots()})
|
return self._json({"snapshots": C.list_snapshots()})
|
||||||
if self.path.startswith("/db/snapshot?"):
|
if self.path.startswith("/db/snapshot?"):
|
||||||
|
|
@ -177,6 +361,23 @@ class Handler(SimpleHTTPRequestHandler):
|
||||||
)
|
)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
return self._json({"error": str(ex)}, 500)
|
return self._json({"error": str(ex)}, 500)
|
||||||
|
# envoi de mail (générateur de mots de passe)
|
||||||
|
if self.path == "/mail/send":
|
||||||
|
try:
|
||||||
|
return self.mail_send()
|
||||||
|
except Exception as e:
|
||||||
|
return self._err(e, 500)
|
||||||
|
# upload d'image / de fichier joint (générateur de mots de passe)
|
||||||
|
if self.path == "/pwgen/upload-image":
|
||||||
|
try:
|
||||||
|
return self.pwgen_upload_image()
|
||||||
|
except Exception as e:
|
||||||
|
return self._err(e, 500)
|
||||||
|
if self.path == "/pwgen/upload-file":
|
||||||
|
try:
|
||||||
|
return self.pwgen_upload_file()
|
||||||
|
except Exception as e:
|
||||||
|
return self._err(e, 500)
|
||||||
# API certs (écriture)
|
# API certs (écriture)
|
||||||
api = self._cert_api()
|
api = self._cert_api()
|
||||||
try:
|
try:
|
||||||
|
|
@ -192,49 +393,113 @@ class Handler(SimpleHTTPRequestHandler):
|
||||||
return self._err(e, 500)
|
return self._err(e, 500)
|
||||||
self._err("route inconnue: %s" % self.path, 404)
|
self._err("route inconnue: %s" % self.path, 404)
|
||||||
|
|
||||||
|
# ---------- mail (générateur de mots de passe) ----------
|
||||||
|
def mail_send(self):
|
||||||
|
"""Envoie un mail texte via le serveur SMTP fourni par la page.
|
||||||
|
|
||||||
|
Body JSON : { smtp: {host, port, security, user, pass, from, from_name},
|
||||||
|
to, subject, body }
|
||||||
|
security : none | starttls | ssl
|
||||||
|
"""
|
||||||
|
d = self._body()
|
||||||
|
smtp = d.get("smtp") or {}
|
||||||
|
to = (d.get("to") or "").strip()
|
||||||
|
host = (smtp.get("host") or "").strip()
|
||||||
|
if not to or "@" not in to:
|
||||||
|
raise ValueError("destinataire invalide: %r" % to)
|
||||||
|
if not host:
|
||||||
|
raise ValueError("serveur SMTP non configuré")
|
||||||
|
security = (smtp.get("security") or "starttls").lower()
|
||||||
|
try:
|
||||||
|
port = int(smtp.get("port") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
port = 0
|
||||||
|
if not port:
|
||||||
|
port = {"ssl": 465, "starttls": 587}.get(security, 25)
|
||||||
|
sender = (smtp.get("from") or smtp.get("user") or "").strip()
|
||||||
|
if not sender:
|
||||||
|
raise ValueError("adresse expéditeur non configurée")
|
||||||
|
|
||||||
|
msg = EmailMessage()
|
||||||
|
from_name = (smtp.get("from_name") or "").strip()
|
||||||
|
msg["From"] = formataddr((from_name, sender)) if from_name else sender
|
||||||
|
msg["To"] = to
|
||||||
|
msg["Subject"] = d.get("subject") or ""
|
||||||
|
msg.set_content(d.get("body") or "")
|
||||||
|
html = (d.get("html") or "").strip()
|
||||||
|
if html:
|
||||||
|
# les images de templates stockées sur disque sont ré-intégrées en
|
||||||
|
# data URI pour que le mail reçu soit autonome (pas de lien vers le
|
||||||
|
# serveur, images visibles hors du réseau).
|
||||||
|
html = inline_local_images(html)
|
||||||
|
msg.add_alternative(html, subtype="html")
|
||||||
|
# pièces jointes du template (ex : .rdp de connexion)
|
||||||
|
attach_files(msg, d.get("attachments"))
|
||||||
|
|
||||||
|
if security == "ssl":
|
||||||
|
server = smtplib.SMTP_SSL(host, port, timeout=15)
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP(host, port, timeout=15)
|
||||||
|
try:
|
||||||
|
if security == "starttls":
|
||||||
|
server.starttls()
|
||||||
|
user = (smtp.get("user") or "").strip()
|
||||||
|
if user:
|
||||||
|
server.login(user, smtp.get("pass") or "")
|
||||||
|
server.send_message(msg)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
server.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._json({"ok": True, "to": to})
|
||||||
|
|
||||||
|
# ---------- uploads de templates (disque réel) ----------
|
||||||
|
def _save_upload(self, dest_dir, url_prefix, max_bytes, default_ext, kind):
|
||||||
|
"""Stocke un fichier posté ({name, b64}) sur le disque avec un nom sûr
|
||||||
|
(radical assaini + suffixe aléatoire) et renvoie son URL servie."""
|
||||||
|
d = self._body()
|
||||||
|
raw = (d.get("name") or kind).strip()
|
||||||
|
try:
|
||||||
|
data = base64.b64decode(d.get("b64") or "", validate=True)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise ValueError("%s invalide (base64)" % kind)
|
||||||
|
if not data:
|
||||||
|
raise ValueError("%s vide" % kind)
|
||||||
|
if len(data) > max_bytes:
|
||||||
|
raise ValueError("%s trop lourd (max %d Mo)" % (kind, max_bytes // (1024 * 1024)))
|
||||||
|
base, _, ext = raw.rpartition(".")
|
||||||
|
ext = re.sub(r"[^A-Za-z0-9]", "", ext).lower()[:8] or default_ext
|
||||||
|
stem = re.sub(r"[^A-Za-z0-9._-]", "_", base or kind)[:40].strip("._-") or kind
|
||||||
|
fname = "%s-%s.%s" % (stem, os.urandom(4).hex(), ext)
|
||||||
|
os.makedirs(dest_dir, exist_ok=True)
|
||||||
|
with open(os.path.join(dest_dir, fname), "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
self._json({"ok": True, "url": url_prefix + fname, "name": fname})
|
||||||
|
|
||||||
|
def pwgen_upload_image(self):
|
||||||
|
"""Image de template → password-generator/images/ (ré-intégrée en data URI à l'envoi)."""
|
||||||
|
self._save_upload(PWGEN_IMG, PWGEN_IMG_URL, MAX_IMG_BYTES, "png", "image")
|
||||||
|
|
||||||
|
def pwgen_upload_file(self):
|
||||||
|
"""Fichier joignable (rdp, pdf…) → password-generator/files/ (joint au mail à l'envoi)."""
|
||||||
|
self._save_upload(PWGEN_FILES, PWGEN_FILES_URL, MAX_FILE_BYTES, "bin", "fichier")
|
||||||
|
|
||||||
# ---------- certificats (disque réel) ----------
|
# ---------- certificats (disque réel) ----------
|
||||||
def cert_list(self):
|
def cert_list(self):
|
||||||
ensure_work()
|
ensure_work()
|
||||||
certs, archives = [], []
|
sig = _cert_signature()
|
||||||
for entry in sorted(os.listdir(WORK)):
|
with _CERT_CACHE_LOCK:
|
||||||
full = os.path.join(WORK, entry)
|
if (
|
||||||
if entry == "archives":
|
sig is not None
|
||||||
if os.path.isdir(full):
|
and sig == _CERT_CACHE["sig"]
|
||||||
for a in sorted(os.listdir(full)):
|
and _CERT_CACHE["payload"] is not None
|
||||||
af = os.path.join(full, a)
|
):
|
||||||
if os.path.isfile(af):
|
return _CERT_CACHE["payload"] # inchangé → réponse mémorisée
|
||||||
st = os.stat(af)
|
payload = _build_cert_list()
|
||||||
archives.append(
|
_CERT_CACHE["sig"] = sig
|
||||||
{
|
_CERT_CACHE["payload"] = payload
|
||||||
"name": a,
|
return payload
|
||||||
"size": st.st_size,
|
|
||||||
"mtime": int(st.st_mtime * 1000),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if not os.path.isdir(full):
|
|
||||||
continue
|
|
||||||
files, date = [], 0
|
|
||||||
for fn in sorted(os.listdir(full)):
|
|
||||||
ff = os.path.join(full, fn)
|
|
||||||
if not os.path.isfile(ff):
|
|
||||||
continue
|
|
||||||
st = os.stat(ff)
|
|
||||||
m = int(st.st_mtime * 1000)
|
|
||||||
date = max(date, m)
|
|
||||||
files.append(
|
|
||||||
{
|
|
||||||
"name": fn,
|
|
||||||
"ext": (fn.rsplit(".", 1)[-1] if "." in fn else "").lower(),
|
|
||||||
"size": st.st_size,
|
|
||||||
"mtime": m,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if files:
|
|
||||||
certs.append({"dir": entry, "files": files, "date": date})
|
|
||||||
archives.sort(key=lambda a: -a["mtime"])
|
|
||||||
certs.sort(key=lambda c: -c["date"])
|
|
||||||
return {"ok": True, "certs": certs, "archives": archives}
|
|
||||||
|
|
||||||
def cert_write_text(self):
|
def cert_write_text(self):
|
||||||
d = self._body()
|
d = self._body()
|
||||||
|
|
@ -249,6 +514,7 @@ class Handler(SimpleHTTPRequestHandler):
|
||||||
newline="",
|
newline="",
|
||||||
) as f:
|
) as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
invalidate_cert_cache()
|
||||||
self._json({"ok": True, "path": "LISTS_ALL_CERT/%s/" % dname})
|
self._json({"ok": True, "path": "LISTS_ALL_CERT/%s/" % dname})
|
||||||
|
|
||||||
def cert_write_bin(self):
|
def cert_write_bin(self):
|
||||||
|
|
@ -260,6 +526,7 @@ class Handler(SimpleHTTPRequestHandler):
|
||||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||||
with open(target, "wb") as f:
|
with open(target, "wb") as f:
|
||||||
f.write(base64.b64decode(d["b64"]))
|
f.write(base64.b64decode(d["b64"]))
|
||||||
|
invalidate_cert_cache()
|
||||||
self._json({"ok": True, "path": "LISTS_ALL_CERT/%s" % "/".join(parts)})
|
self._json({"ok": True, "path": "LISTS_ALL_CERT/%s" % "/".join(parts)})
|
||||||
|
|
||||||
def cert_delete_dir(self):
|
def cert_delete_dir(self):
|
||||||
|
|
@ -270,10 +537,12 @@ class Handler(SimpleHTTPRequestHandler):
|
||||||
shutil.rmtree(target)
|
shutil.rmtree(target)
|
||||||
if d.get("archives"):
|
if d.get("archives"):
|
||||||
self._rm_archives(dname)
|
self._rm_archives(dname)
|
||||||
|
invalidate_cert_cache()
|
||||||
self._json({"ok": True})
|
self._json({"ok": True})
|
||||||
|
|
||||||
def cert_delete_archives(self):
|
def cert_delete_archives(self):
|
||||||
self._rm_archives(safe_name(self._body()["dir"]))
|
self._rm_archives(safe_name(self._body()["dir"]))
|
||||||
|
invalidate_cert_cache()
|
||||||
self._json({"ok": True})
|
self._json({"ok": True})
|
||||||
|
|
||||||
def _rm_archives(self, dname):
|
def _rm_archives(self, dname):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue