Files
Bjorn/web_utils/action_utils.py

1866 lines
81 KiB
Python

# action_utils.py
"""
Unified web utilities: Actions (scripts+images+comments), Images, Characters,
Comments, and Attacks — consolidated into a single module.
Key image rules:
- Status icon: always 28x28 BMP (<status_images>/<Action>/<Action>.bmp)
- Character image: always 78x78 BMP (<status_images>/<Action>/<Action>N.bmp)
- Missing status icon auto-generates a placeholder (similar intent to makePlaceholderIconBlob).
This file merges previous modules:
- Action/Image/Character utils (now in ActionUtils)
- Comment utils (CommentUtils)
- Attack utils (AttackUtils)
"""
from __future__ import annotations
import ast
import cgi
import io
import json
import os
import re
import shutil
import time
import traceback
import logging
from io import BytesIO
from pathlib import Path
from typing import Any, Dict, Optional
from urllib.parse import parse_qs, unquote, urlparse
from PIL import Image, ImageDraw, ImageFont
from logger import Logger
# Single shared logger for the whole file
logger = Logger(name="action_utils.py", level=logging.DEBUG)
ALLOWED_IMAGE_EXTS = {'.bmp', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp'}
# =============================================================================
# Core unified Actions + Images + Characters
# =============================================================================
class ActionUtils:
"""
One-stop utility for:
- Action bundle management (create/delete actions: .py + images + comments)
- Image management (status/static/character; serve/list/rename/replace/resize)
- Character management (list/switch/create/delete; image IO)
"""
# Fixed sizes required by spec
STATUS_W, STATUS_H = 28, 28
CHAR_W, CHAR_H = 78, 78
def __init__(self, shared_data):
"""
shared_data is expected to expose:
- images_dir, default_images_dir
- status_images_dir, static_images_dir, actions_icons_dir
- actions_dir, default_actions_dir
- default_comments_file
- db (with needed methods)
- config (dict-like) + save_config() + load_config()
- load_images(), load_fonts()
- bjorn_status_image, bjorn_character
- web_dir
"""
self.shared_data = shared_data
self.logger = logger
# Optional manual batch resize flags (for /resize_images only)
self.should_resize_images = False
self.resize_width = 100
self.resize_height = 100
# dirs
self.status_images_dir = getattr(shared_data, "status_images_dir")
self.static_images_dir = getattr(shared_data, "static_images_dir")
self.web_dir = getattr(shared_data, "web_dir")
self.images_dir = getattr(shared_data, "images_dir", None)
self.web_images_dir = getattr(shared_data, "web_images_dir", os.path.join(self.web_dir, "images"))
self.actions_icons_dir= getattr(shared_data, "actions_icons_dir", os.path.join(self.images_dir or self.web_dir, "actions_icons"))
for d in (self.status_images_dir, self.static_images_dir, self.web_images_dir, self.actions_icons_dir):
try: os.makedirs(d, exist_ok=True)
except Exception: pass
# ---------- generic helpers ----------
def _send_json(self, handler, data, status: int = 200):
handler.send_response(status)
handler.send_header("Content-Type", "application/json")
handler.end_headers()
handler.wfile.write(json.dumps(data).encode("utf-8"))
def _send_error(self, handler, message: str, status: int = 500):
self._send_json(handler, {"status": "error", "message": message}, status)
def _ensure_dir(self, path: str | Path) -> str:
p = Path(path)
p.mkdir(parents=True, exist_ok=True)
return str(p)
def _ensure_action_dir(self, action_name: str) -> str:
return self._ensure_dir(Path(self.shared_data.status_images_dir) / action_name)
def _err(self, h, msg: str, code: int=500): self._send_json(h, {'status':'error','message':msg}, code)
def _get_mime(self, path: str) -> str:
p = path.lower()
if p.endswith(".bmp"):
return "image/bmp"
if p.endswith(".png"):
return "image/png"
if p.endswith(".jpg") or p.endswith(".jpeg"):
return "image/jpeg"
return "application/octet-stream"
def _safe(self, name: str) -> str:
return os.path.basename((name or '').strip().replace('\x00', ''))
def _to_bmp_resized(self, raw: bytes, width: int, height: int) -> bytes:
"""Convert arbitrary image bytes to BMP, always resized to (width x height)."""
with Image.open(BytesIO(raw)) as im:
if im.mode != "RGB":
im = im.convert("RGB")
try:
resample = Image.Resampling.LANCZOS
except AttributeError:
resample = Image.LANCZOS
im = im.resize((width, height), resample)
out = BytesIO()
im.save(out, format="BMP")
return out.getvalue()
def _resize_bmp(self, bmp_data: bytes, width: int, height: int) -> bytes:
"""Resize an existing BMP payload (used by batch resizing)."""
return self._to_bmp_resized(bmp_data, width, height)
def _initials(self, name: str) -> str:
parts = re.split(r"[^A-Za-z0-9]+", name.strip())
parts = [p for p in parts if p]
if not parts:
return "A"
return "".join(s[0] for s in parts[:2]).upper()
def _placeholder_icon(self, text: str, size: int) -> bytes:
"""
Build a simple placeholder with initials (similar intent to makePlaceholderIconBlob),
then return as BMP bytes.
"""
img = Image.new("RGB", (size, size), "#0b0e13")
draw = ImageDraw.Draw(img)
# Ring
ring_color = "#59b6ff"
ring_w = max(2, size // 16)
r = size // 2 - ring_w
draw.ellipse(
(ring_w, ring_w, size - ring_w, size - ring_w),
outline=ring_color,
width=ring_w,
)
# Font
try:
font = ImageFont.truetype("DejaVuSans-Bold.ttf", max(10, size // 2))
except Exception:
font = ImageFont.load_default()
tw, th = draw.textsize(text, font=font)
draw.text(((size - tw) / 2, (size - th) / 2), text, fill=ring_color, font=font)
out = BytesIO()
img.save(out, format="BMP")
return out.getvalue()
# ---------- action bundle (scripts + comments + images) ----------
def _extract_action_meta(self, content: str) -> Optional[Dict[str, Any]]:
"""Parse b_* metadata from a Python attack file."""
try:
tree = ast.parse(content)
meta: Dict[str, Any] = {}
for node in tree.body:
if isinstance(node, ast.Assign) and len(node.targets) == 1:
target = node.targets[0]
if isinstance(target, ast.Name) and target.id.startswith("b_"):
try:
meta[target.id] = ast.literal_eval(node.value)
except Exception:
meta[target.id] = None
return meta or None
except Exception:
return None
def serve_bjorn_character(self, handler):
try:
# Convertir l'image PIL en bytes
img_byte_arr = io.BytesIO()
self.shared_data.bjorn_character.save(img_byte_arr, format='PNG')
img_byte_arr = img_byte_arr.getvalue()
handler.send_response(200)
handler.send_header('Content-Type', 'image/png')
handler.send_header('Cache-Control', 'no-cache')
handler.end_headers()
handler.wfile.write(img_byte_arr)
except BrokenPipeError:
pass
except Exception as e:
self.logger.error(f"Error serving status image: {e}")
def serve_bjorn_say(self, handler):
try:
bjorn_says_data = {
"text": self.shared_data.bjorn_says
}
handler.send_response(200)
handler.send_header("Content-Type", "application/json")
handler.end_headers()
handler.wfile.write(json.dumps(bjorn_says_data).encode('utf-8'))
except Exception as e:
handler.send_response(500)
handler.send_header("Content-Type", "application/json")
handler.end_headers()
handler.wfile.write(json.dumps({"status": "error", "message": str(e)}).encode('utf-8'))
def create_action(self, handler):
"""
Create a complete action: Python script + images + comment section.
Multipart form:
- action_name (str)
- attack_file (.py)
- status_icon (image)
- character_images (0..n images, optional)
- create_comments_section ('1' to auto-create)
"""
try:
ctype = handler.headers.get("Content-Type", "")
if not ctype.startswith("multipart/form-data"):
raise ValueError("Content-Type must be multipart/form-data")
content_length = int(handler.headers.get("Content-Length", 0))
body = handler.rfile.read(content_length)
form = cgi.FieldStorage(
fp=BytesIO(body),
headers=handler.headers,
environ={"REQUEST_METHOD": "POST"},
keep_blank_values=True,
)
action_name = (form.getvalue("action_name") or "").strip()
if not action_name:
raise ValueError("action_name is required")
if "attack_file" not in form or not getattr(form["attack_file"], "filename", ""):
raise ValueError("attack_file (.py) is required")
attack_file = form["attack_file"]
if "status_icon" not in form or not getattr(form["status_icon"], "filename", ""):
raise ValueError("status_icon is required")
status_icon = form["status_icon"]
create_comments = form.getvalue("create_comments_section") == "1"
# 1) script + DB card
self._import_python_script(action_name, attack_file)
# 2) images (fixed sizes; ensure at least 1 character)
self._create_action_images(action_name, status_icon, form)
# 3) comments (optional)
if create_comments:
self._create_comment_section(action_name)
self._send_json(handler, {"status": "success", "message": f'Action "{action_name}" created successfully'})
except Exception as e:
self.logger.error(f"create_action: {e}")
self._send_error(handler, str(e), 400)
def _import_python_script(self, action_name: str, file_item):
"""Persist the .py and upsert DB action card from b_* meta."""
filename = os.path.basename(file_item.filename)
module_name = os.path.splitext(filename)[0]
content = file_item.file.read().decode("utf-8")
meta = self._extract_action_meta(content)
if not meta or "b_class" not in meta:
raise ValueError("Python file must define b_class")
dst = os.path.join(self.shared_data.actions_dir, filename)
with open(dst, "w", encoding="utf-8") as f:
f.write(content)
meta.setdefault("b_module", module_name)
self.shared_data.db.upsert_simple_action(**meta)
def delete_action(self, handler):
"""Delete action: python script + images + comment section."""
try:
content_length = int(handler.headers.get("Content-Length", 0))
body = handler.rfile.read(content_length) if content_length > 0 else b"{}"
data = json.loads(body)
action_name = (data.get("action_name") or "").strip()
if not action_name:
raise ValueError("action_name is required")
self._remove_python_script(action_name)
self._delete_action_images(action_name)
self._delete_comment_section(action_name)
self._send_json(handler, {"status": "success", "message": f'Action "{action_name}" deleted successfully'})
except Exception as e:
self.logger.error(f"delete_action: {e}")
self._send_error(handler, str(e), 400)
def _remove_python_script(self, action_name: str):
row = self.shared_data.db.get_action_by_class(action_name)
if not row:
raise FileNotFoundError(f"Action '{action_name}' not found in DB")
module_name = row["b_module"]
path = os.path.join(self.shared_data.actions_dir, f"{module_name}.py")
if os.path.exists(path):
os.remove(path)
self.shared_data.db.delete_action(action_name)
def restore_defaults(self, handler):
"""Restore defaults for images, comments and actions (scripts)."""
try:
# Images
images_dir = self.shared_data.images_dir
default_images_dir = self.shared_data.default_images_dir
if not os.path.exists(default_images_dir):
raise FileNotFoundError(f"Default images directory not found: {default_images_dir}")
if os.path.exists(images_dir):
shutil.rmtree(images_dir)
shutil.copytree(default_images_dir, images_dir)
# Comments
inserted = self.shared_data.db.import_comments_from_json(
self.shared_data.default_comments_file, lang="fr", clear_existing=True
)
# Scripts
actions_dir = self.shared_data.actions_dir
default_actions_dir = self.shared_data.default_actions_dir
if os.path.exists(default_actions_dir):
if os.path.exists(actions_dir):
for f in os.listdir(actions_dir):
if f.endswith(".py"):
os.remove(os.path.join(actions_dir, f))
for f in os.listdir(default_actions_dir):
if f.endswith(".py"):
shutil.copy2(os.path.join(default_actions_dir, f), os.path.join(actions_dir, f))
# Rebuild cards
self._rebuild_action_cards()
self._send_json(
handler,
{"status": "success", "message": f"Defaults restored (actions, images, {inserted} comments)"},
)
except Exception as e:
self.logger.error(f"restore_defaults: {e}")
self._send_error(handler, str(e), 500)
def _rebuild_action_cards(self):
"""
Rebuild DB 'actions' + 'actions_studio' from filesystem .py files.
- 'actions' : info runtime (b_class, b_module, etc.)
- 'actions_studio': payload studio (on garde meta complet en JSON)
"""
actions_dir = self.shared_data.actions_dir
# Schéma minimum (au cas où la migration n'est pas faite)
self.shared_data.db.execute("""
CREATE TABLE IF NOT EXISTS actions (
name TEXT PRIMARY KEY,
b_class TEXT NOT NULL,
b_module TEXT NOT NULL,
meta_json TEXT
)
""")
self.shared_data.db.execute("""
CREATE TABLE IF NOT EXISTS actions_studio (
action_name TEXT PRIMARY KEY,
studio_meta_json TEXT
)
""")
# On reconstruit à partir du disque
self.shared_data.db.execute("DELETE FROM actions")
self.shared_data.db.execute("DELETE FROM actions_studio")
for filename in os.listdir(actions_dir):
if not filename.endswith(".py"):
continue
filepath = os.path.join(actions_dir, filename)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
meta = self._extract_action_meta(content)
if not (meta and "b_class" in meta):
continue
module_name = os.path.splitext(filename)[0]
meta.setdefault("b_module", module_name)
# Nom logique de l'action (prend 'name' si présent, sinon b_class)
action_name = (meta.get("name") or meta["b_class"]).strip()
# -- UPSERT dans actions
self.shared_data.db.execute(
"""
INSERT INTO actions (name, b_class, b_module, meta_json)
VALUES (?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
b_class = excluded.b_class,
b_module = excluded.b_module,
meta_json = excluded.meta_json
""",
(action_name, meta["b_class"], meta["b_module"], json.dumps(meta, ensure_ascii=False))
)
# -- UPSERT dans actions_studio (on stocke le même meta ou seulement ce qui est utile studio)
self.shared_data.db.execute(
"""
INSERT INTO actions_studio (action_name, studio_meta_json)
VALUES (?, ?)
ON CONFLICT(action_name) DO UPDATE SET
studio_meta_json = excluded.studio_meta_json
""",
(action_name, json.dumps(meta, ensure_ascii=False))
)
def _create_comment_section(self, action_name: str):
self.shared_data.db.execute(
"INSERT OR IGNORE INTO comments (text, status, theme, lang, weight) VALUES (?, ?, ?, ?, ?)",
("", action_name, action_name, "fr", 1),
)
def _delete_comment_section(self, action_name: str):
self.shared_data.db.execute("DELETE FROM comments WHERE status=?", (action_name,))
# ---------- images ----------
def get_actions(self, handler):
"""List action folders and whether status icon exists."""
try:
actions_dir = self.shared_data.status_images_dir
actions = []
for entry in os.scandir(actions_dir):
if entry.is_dir():
name = entry.name
has_status_icon = os.path.exists(os.path.join(entry.path, f"{name}.bmp"))
actions.append({"name": name, "has_status_icon": has_status_icon})
self._send_json(handler, {"status": "success", "actions": actions})
except Exception as e:
self.logger.error(f"get_actions: {e}")
self._send_error(handler, str(e))
def get_action_images(self, handler):
"""List all BMP images for a given action with dimensions."""
try:
q = parse_qs(urlparse(handler.path).query)
action = (q.get("action", [None])[0] or "").strip()
if not action:
raise ValueError("Action parameter is required")
action_dir = os.path.join(self.shared_data.status_images_dir, action)
if not os.path.exists(action_dir):
raise FileNotFoundError(f"Action '{action}' does not exist")
images = []
for entry in os.listdir(action_dir):
if entry.lower().endswith(".bmp"):
path = os.path.join(action_dir, entry)
with Image.open(path) as img:
w, h = img.size
images.append({"name": entry, "width": w, "height": h})
self._send_json(handler, {"status": "success", "images": images})
except Exception as e:
self.logger.error(f"get_action_images: {e}")
self._send_error(handler, str(e))
def serve_status_image(self, handler):
"""Serve any file under /images/status/<...> safely."""
try:
url_path = unquote(urlparse(handler.path).path)
prefix = "/images/status/"
if not url_path.startswith(prefix):
handler.send_error(400, "Bad Request")
return
rel = url_path[len(prefix) :]
base = Path(self.shared_data.status_images_dir).resolve()
target = (base / rel).resolve()
if not str(target).startswith(str(base)):
handler.send_error(403, "Forbidden")
return
if not target.exists():
handler.send_error(404, "Image not found")
return
with open(target, "rb") as f:
content = f.read()
handler.send_response(200)
handler.send_header("Content-Type", self._get_mime(str(target)))
handler.send_header("Content-Length", str(len(content)))
handler.end_headers()
handler.wfile.write(content)
except Exception as e:
self.logger.error(f"serve_status_image: {e}")
handler.send_error(500, "Internal Server Error")
def list_static_images_with_dimensions(self, handler):
"""List static images (any format readable by PIL) with dimensions."""
try:
static_dir = self.shared_data.static_images_dir
images = []
for f in os.listdir(static_dir):
path = os.path.join(static_dir, f)
if not os.path.isfile(path):
continue
try:
with Image.open(path) as img:
w, h = img.size
images.append({"name": f, "width": w, "height": h})
except Exception:
continue
self._send_json(handler, {"status": "success", "images": images})
except Exception as e:
self.logger.error(f"list_static_images_with_dimensions: {e}")
self._send_error(handler, str(e))
def upload_static_image(self, handler):
"""Upload a static image; store as BMP. Optional manual size via flags."""
try:
ctype, pdict = cgi.parse_header(handler.headers.get("Content-Type"))
if ctype != "multipart/form-data":
raise ValueError("Content-Type must be multipart/form-data")
pdict["boundary"] = bytes(pdict["boundary"], "utf-8")
pdict["CONTENT-LENGTH"] = int(handler.headers.get("Content-Length"))
form = cgi.FieldStorage(
fp=BytesIO(handler.rfile.read(pdict["CONTENT-LENGTH"])),
headers=handler.headers,
environ={"REQUEST_METHOD": "POST"},
keep_blank_values=True,
)
if "static_image" not in form or not getattr(form["static_image"], "filename", ""):
raise ValueError("No static_image file provided")
filename = os.path.basename(form["static_image"].filename)
base, _ = os.path.splitext(filename)
filename = base + ".bmp"
raw = form["static_image"].file.read()
if self.should_resize_images:
bmp = self._to_bmp_resized(raw, self.resize_width, self.resize_height)
else:
# Store as-is but normalized to BMP; keep original size by reading image size first
with Image.open(BytesIO(raw)) as im:
w, h = im.size
bmp = self._to_bmp_resized(raw, w, h)
static_dir = self.shared_data.static_images_dir
self._ensure_dir(static_dir)
with open(os.path.join(static_dir, filename), "wb") as f:
f.write(bmp)
self._send_json(handler, {"status": "success", "message": "Static image uploaded successfully"})
except Exception as e:
self.logger.error(f"upload_static_image: {e}")
self._send_error(handler, str(e))
# ---------- CRUD that might touch action character files ----------
def delete_images(self, h):
"""Delete images in 'static'|'web'|'icons' or action folder. When type='action', call CharacterUtils to renumber."""
try:
data = json.loads(h.rfile.read(int(h.headers['Content-Length'])).decode('utf-8'))
tp = data.get('type'); action = data.get('action'); names = data.get('image_names', [])
if not tp or not names: raise ValueError('type and image_names are required')
if tp == 'action':
if not action: raise ValueError("action is required for type=action")
base = os.path.join(self.status_images_dir, self._safe(action))
for n in names:
p = os.path.join(base, self._safe(n))
if os.path.exists(p): os.remove(p)
if self.character_utils:
self.character_utils.update_character_image_numbers(action)
elif tp == 'static':
for n in names:
p = os.path.join(self.static_images_dir, self._safe(n))
if os.path.exists(p): os.remove(p)
elif tp == 'web':
for n in names:
p = os.path.join(self.web_images_dir, self._safe(n))
if os.path.exists(p): os.remove(p)
elif tp == 'icons':
for n in names:
p = os.path.join(self.actions_icons_dir, self._safe(n))
if os.path.exists(p): os.remove(p)
else:
raise ValueError("type must be 'action','static','web','icons'")
self._send_json(h, {'status':'success','message':'Images deleted successfully'})
except Exception as e:
self.logger.error(e); self._err(h, str(e))
def resize_images(self, handler):
try:
data = json.loads(handler.rfile.read(int(handler.headers["Content-Length"])).decode("utf-8"))
image_type = data.get("type")
action_name = data.get("action")
image_names = data.get("image_names", [])
width = int(data.get("width", 100))
height = int(data.get("height", 100))
if image_type == "action":
base = os.path.join(self.shared_data.status_images_dir, action_name)
mode = "bmp_only"
elif image_type == "static":
base = self.shared_data.static_images_dir
mode = "bmp_only"
elif image_type == "web":
base = self.web_images_dir
mode = "preserve_format"
elif image_type == "icons":
base = self.actions_icons_dir
mode = "preserve_format"
else:
raise ValueError("Invalid image type")
for name in image_names:
path = os.path.join(base, name)
if not os.path.exists(path):
self.logger.error(f"Missing image: {path}")
continue
# Ouvrir, redimensionner
with Image.open(path) as im:
try:
resample = Image.Resampling.LANCZOS
except AttributeError:
resample = Image.LANCZOS
im = im.resize((width, height), resample)
if mode == "bmp_only":
im = im.convert("RGB")
im.save(path, "BMP")
else:
ext = os.path.splitext(name)[1].lower()
fmt = {
".png": "PNG", ".jpg": "JPEG", ".jpeg": "JPEG",
".gif": "GIF", ".ico": "ICO", ".bmp": "BMP", ".webp": "WEBP"
}.get(ext, "PNG")
if fmt in ("JPEG", "BMP"): # formats sans alpha
im = im.convert("RGB")
im.save(path, fmt)
self._send_json(handler, {"status": "success"})
except Exception as e:
self.logger.error(f"resize_images: {e}")
self._send_error(handler, str(e))
def rename_image(self, handler):
"""Rename a static image, an action image, or an action folder."""
try:
data = json.loads(handler.rfile.read(int(handler.headers["Content-Length"])).decode("utf-8"))
entity_type = data.get("type") # 'action' | 'static' | 'image'
old_name = data.get("old_name")
new_name = data.get("new_name")
action = data.get("action")
if not entity_type or not old_name or not new_name:
raise ValueError("type, old_name, and new_name are required")
if entity_type == "action":
root = self.shared_data.status_images_dir
oldp = os.path.join(root, old_name)
newp = os.path.join(root, new_name)
if not os.path.exists(oldp):
raise FileNotFoundError(f"Action '{old_name}' does not exist")
os.rename(oldp, newp)
elif entity_type == "static":
root = self.shared_data.static_images_dir
oldp = os.path.join(root, old_name)
newp = os.path.join(root, new_name)
if not os.path.exists(oldp):
raise FileNotFoundError(f"Static image '{old_name}' does not exist")
os.rename(oldp, newp)
elif entity_type == "web":
root = self.web_images_dir
oldp = os.path.join(root, old_name); newp = os.path.join(root, new_name)
if not os.path.exists(oldp): raise FileNotFoundError(f"Web image '{old_name}' does not exist")
os.rename(oldp, newp)
elif entity_type == "icons":
root = self.actions_icons_dir
oldp = os.path.join(root, old_name); newp = os.path.join(root, new_name)
if not os.path.exists(oldp): raise FileNotFoundError(f"Icon '{old_name}' does not exist")
os.rename(oldp, newp)
elif entity_type == "image":
if not action:
raise ValueError("action is required to rename an action image")
root = os.path.join(self.shared_data.status_images_dir, action)
oldp = os.path.join(root, old_name)
newp = os.path.join(root, new_name)
if not os.path.exists(oldp):
raise FileNotFoundError(f"Image '{old_name}' does not exist in '{action}'")
os.rename(oldp, newp)
self._renumber_character_images(action)
else:
raise ValueError("type must be 'action', 'static', or 'image'")
self._send_json(handler, {"status": "success", "message": "Renamed successfully"})
except Exception as e:
self.logger.error(f"rename_image: {e}")
self._send_error(handler, str(e))
def replace_image(self, h):
import cgi
try:
ctype, pdict = cgi.parse_header(h.headers.get('Content-Type'))
if ctype != 'multipart/form-data':
raise ValueError('Content-Type must be multipart/form-data')
pdict['boundary'] = bytes(pdict['boundary'], 'utf-8')
pdict['CONTENT-LENGTH'] = int(h.headers.get('Content-Length'))
form = cgi.FieldStorage(
fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])),
headers=h.headers,
environ={'REQUEST_METHOD': 'POST'},
keep_blank_values=True,
)
tp = form.getvalue('type')
image_name = self._safe(form.getvalue('image_name') or '')
file_item = form['new_image'] if 'new_image' in form else None
# ⚠️ NE PAS faire "not file_item" (FieldStorage n'est pas booléable)
if not tp or not image_name or file_item is None or not getattr(file_item, 'filename', ''):
raise ValueError('type, image_name and new_image are required')
if tp == 'action':
action = self._safe(form.getvalue('action') or '')
if not action:
raise ValueError("action is required for type=action")
base = os.path.join(self.status_images_dir, action)
target = os.path.join(base, image_name)
if not os.path.exists(target):
raise FileNotFoundError(f"{image_name} not found")
raw = file_item.file.read()
# Si c'est le status icon <action>.bmp => BMP 28x28 imposé
if image_name.lower() == f"{action.lower()}.bmp":
out = self._to_bmp_resized(raw, self.STATUS_W, self.STATUS_H)
with open(target, 'wb') as f:
f.write(out)
else:
# Déléguer aux character utils pour une image perso numérotée
if not self.character_utils:
raise RuntimeError("CharacterUtils not wired into ImageUtils")
return self.character_utils.replace_character_image(h, form, action, image_name)
elif tp == 'static':
path = os.path.join(self.static_images_dir, image_name)
if not os.path.exists(path):
raise FileNotFoundError(image_name)
with Image.open(path) as im:
w, hh = im.size
raw = file_item.file.read()
out = self._to_bmp_resized(raw, w, hh)
with open(path, 'wb') as f:
f.write(out)
elif tp == 'web':
path = os.path.join(self.web_images_dir, image_name)
if not os.path.exists(path):
raise FileNotFoundError(image_name)
with open(path, 'wb') as f:
f.write(file_item.file.read())
elif tp == 'icons':
path = os.path.join(self.actions_icons_dir, image_name)
if not os.path.exists(path):
raise FileNotFoundError(image_name)
with open(path, 'wb') as f:
f.write(file_item.file.read())
else:
raise ValueError("type must be 'action'|'static'|'web'|'icons'")
self._send_json(h, {'status':'success','message':'Image replaced successfully'})
except Exception as e:
self.logger.error(e)
self._err(h, str(e))
def upload_status_image(self, handler):
"""
Add or replace the STATUS image for an action; always saved as
<status_images_dir>/<action>/<action>.bmp (28x28).
Creates the action folder if it doesn't exist.
"""
try:
ctype, pdict = cgi.parse_header(handler.headers.get("Content-Type"))
if ctype != "multipart/form-data":
raise ValueError("Content-Type must be multipart/form-data")
pdict["boundary"] = bytes(pdict["boundary"], "utf-8")
pdict["CONTENT-LENGTH"] = int(handler.headers.get("Content-Length"))
form = cgi.FieldStorage(
fp=BytesIO(handler.rfile.read(pdict["CONTENT-LENGTH"])),
headers=handler.headers,
environ={"REQUEST_METHOD": "POST"},
keep_blank_values=True,
)
required = ["type", "action_name", "status_image"]
for key in required:
if key not in form:
raise ValueError(f"Missing field: {key}")
image_type = (form.getvalue("type") or "").strip()
action_name = (form.getvalue("action_name") or "").strip()
file_item = form["status_image"]
if image_type != "action":
raise ValueError("type must be 'action' for status upload")
if not action_name or not getattr(file_item, "filename", ""):
raise ValueError("action_name and status_image are required")
action_dir = self._ensure_action_dir(action_name)
raw = file_item.file.read()
bmp = self._to_bmp_resized(raw, self.STATUS_W, self.STATUS_H)
with open(os.path.join(action_dir, f"{action_name}.bmp"), "wb") as f:
f.write(bmp)
self._send_json(
handler,
{"status": "success", "message": "Status image added/updated", "path": f"{action_name}/{action_name}.bmp"},
)
except Exception as e:
self.logger.error(f"upload_status_image: {e}")
self._send_error(handler, str(e))
def upload_character_images(self, handler):
"""
Append character images for an action (numbered <action>1.bmp, <action>2.bmp, ...).
Always resized to 78x78 BMP.
"""
try:
ctype, pdict = cgi.parse_header(handler.headers.get("Content-Type"))
if ctype != "multipart/form-data":
raise ValueError("Content-Type must be multipart/form-data")
pdict["boundary"] = bytes(pdict["boundary"], "utf-8")
pdict["CONTENT-LENGTH"] = int(handler.headers.get("Content-Length"))
form = cgi.FieldStorage(
fp=BytesIO(handler.rfile.read(pdict["CONTENT-LENGTH"])),
headers=handler.headers,
environ={"REQUEST_METHOD": "POST"},
keep_blank_values=True,
)
if "action_name" not in form:
raise ValueError("action_name is required")
action_name = (form.getvalue("action_name") or "").strip()
if not action_name:
raise ValueError("action_name is required")
if "character_images" not in form:
raise ValueError("No character_images provided")
action_dir = os.path.join(self.shared_data.status_images_dir, action_name)
if not os.path.exists(action_dir):
raise FileNotFoundError(f"Action '{action_name}' does not exist")
# find next N
nums: set[int] = set()
pat = re.compile(rf"^{re.escape(action_name)}(\d+)\.bmp$", re.IGNORECASE)
for p in Path(action_dir).glob("*.bmp"):
m = pat.match(p.name)
if m:
try:
nums.add(int(m.group(1)))
except ValueError:
pass
next_num = max(nums or {0}) + 1
items = form["character_images"]
if not isinstance(items, list):
items = [items]
for it in items:
if not getattr(it, "filename", ""):
continue
raw = it.file.read()
bmp = self._to_bmp_resized(raw, self.CHAR_W, self.CHAR_H)
with open(os.path.join(action_dir, f"{action_name}{next_num}.bmp"), "wb") as f:
f.write(bmp)
next_num += 1
self._send_json(handler, {"status": "success", "message": "Character images uploaded"})
except Exception as e:
self.logger.error(f"upload_character_images: {e}")
self._send_error(handler, str(e))
def _renumber_character_images(self, action_name: str):
"""Ensure <action>N.bmp are sequential after deletions/renames."""
action_dir = os.path.join(self.shared_data.status_images_dir, action_name)
if not os.path.isdir(action_dir):
return
pairs = []
pat = re.compile(rf"^{re.escape(action_name)}(\d+)\.bmp$", re.IGNORECASE)
for fn in os.listdir(action_dir):
m = pat.match(fn)
if m:
try:
pairs.append((int(m.group(1)), fn))
except ValueError:
pass
pairs.sort()
idx = 1
for _n, fn in pairs:
new_fn = f"{action_name}{idx}.bmp"
if fn != new_fn:
os.rename(os.path.join(action_dir, fn), os.path.join(action_dir, new_fn))
idx += 1
def _create_action_images(self, action_name, status_icon, form):
"""
Create action folder with:
- <action>.bmp (status, 28x28)
- <action>1.bmp.. (characters, 78x78); if none provided, derive 1 from status
"""
action_dir = self._ensure_action_dir(action_name)
if os.listdir(action_dir): # prevent accidental overwrite
raise FileExistsError(f"Action '{action_name}' already exists")
# Status icon 28x28
status_raw = status_icon.file.read()
status_bmp = self._to_bmp_resized(status_raw, self.STATUS_W, self.STATUS_H)
with open(os.path.join(action_dir, f"{action_name}.bmp"), "wb") as f:
f.write(status_bmp)
# Characters 78x78
provided = False
if "character_images" in form:
items = form["character_images"]
if not isinstance(items, list):
items = [items]
idx = 1
for it in items:
if not getattr(it, "filename", ""):
continue
provided = True
raw = it.file.read()
bmp = self._to_bmp_resized(raw, self.CHAR_W, self.CHAR_H)
with open(os.path.join(action_dir, f"{action_name}{idx}.bmp"), "wb") as f:
f.write(bmp)
idx += 1
if not provided:
# Derive character image from status (upscale to 78x78)
char_from_status = self._to_bmp_resized(status_bmp, self.CHAR_W, self.CHAR_H)
with open(os.path.join(action_dir, f"{action_name}1.bmp"), "wb") as f:
f.write(char_from_status)
def get_status_icon(self, handler):
"""
Serve <action>/<action>.bmp s'il existe.
NE PAS créer de placeholder ici (laisser le front gérer le fallback).
"""
try:
q = parse_qs(urlparse(handler.path).query)
action = (q.get("action", [None])[0] or "").strip()
if not action:
raise ValueError("action is required")
action_dir = os.path.join(self.shared_data.status_images_dir, action)
icon_path = os.path.join(action_dir, f"{action}.bmp")
if not os.path.exists(icon_path):
handler.send_response(404)
handler.end_headers()
return
with open(icon_path, "rb") as f:
data = f.read()
handler.send_response(200)
handler.send_header("Content-Type", "image/bmp")
handler.end_headers()
handler.wfile.write(data)
except Exception as e:
self.logger.error(f"get_status_icon: {e}")
handler.send_response(404)
handler.end_headers()
def get_character_image(self, handler):
"""Serve a specific character image for an action."""
try:
q = parse_qs(urlparse(handler.path).query)
action = (q.get("action", [None])[0] or "").strip()
image_name = (q.get("image", [None])[0] or "").strip()
if not action or not image_name:
raise ValueError("action and image are required")
path = os.path.join(self.shared_data.status_images_dir, action, image_name)
if not os.path.exists(path):
raise FileNotFoundError(f"{image_name} not found for '{action}'")
with open(path, "rb") as f:
data = f.read()
handler.send_response(200)
handler.send_header("Content-Type", "image/bmp")
handler.end_headers()
handler.wfile.write(data)
except Exception as e:
self.logger.error(f"get_character_image: {e}")
handler.send_response(404)
handler.end_headers()
def restore_default_images(self, handler):
"""Restore all images from the default images bundle."""
try:
images_dir = self.shared_data.images_dir
default_images_dir = self.shared_data.default_images_dir
if not os.path.exists(default_images_dir):
raise FileNotFoundError(f"Default images directory not found: {default_images_dir}")
if os.path.exists(images_dir):
shutil.rmtree(images_dir)
shutil.copytree(default_images_dir, images_dir)
self._send_json(handler, {"status": "success", "message": "Images restored successfully"})
except Exception as e:
self.logger.error(f"restore_default_images: {e}")
self._send_error(handler, str(e))
def set_resize_option(self, handler):
"""
Update optional resize settings used ONLY by /resize_images endpoint.
Status/character creation paths ignore these and use fixed sizes.
"""
try:
data = json.loads(handler.rfile.read(int(handler.headers["Content-Length"])).decode("utf-8"))
self.should_resize_images = bool(data.get("resize", False))
self.resize_width = int(data.get("width", 100))
self.resize_height = int(data.get("height", 100))
self._send_json(handler, {"status": "success", "message": "Resize options updated"})
except Exception as e:
self.logger.error(f"set_resize_option: {e}")
self._send_error(handler, str(e))
def serve_bjorn_status_image(self, handler):
"""Serve in-memory Bjorn status image (PNG)."""
try:
out = io.BytesIO()
self.shared_data.bjorn_status_image.save(out, format="PNG")
data = out.getvalue()
handler.send_response(200)
handler.send_header("Content-Type", "image/png")
handler.send_header("Cache-Control", "no-cache")
handler.end_headers()
handler.wfile.write(data)
except BrokenPipeError:
pass
except Exception as e:
self.logger.error(f"serve_bjorn_status_image: {e}")
def serve_image(self, handler):
"""Serve /web/screen.png (if present)."""
path = os.path.join(self.shared_data.web_dir, "screen.png")
try:
with open(path, "rb") as f:
handler.send_response(200)
handler.send_header("Content-type", "image/png")
handler.send_header("Cache-Control", "max-age=0, must-revalidate")
handler.end_headers()
handler.wfile.write(f.read())
except FileNotFoundError:
handler.send_response(404)
handler.end_headers()
except BrokenPipeError:
pass
except Exception as e:
self.logger.error(f"serve_image: {e}")
def serve_static_image(self, handler):
"""Serve a static image by filename from static_images_dir."""
try:
path = unquote(urlparse(handler.path).path)
name = os.path.basename(path)
full = os.path.join(self.shared_data.static_images_dir, name)
if not os.path.exists(full):
raise FileNotFoundError(f"Static image '{name}' not found")
with open(full, "rb") as f:
data = f.read()
handler.send_response(200)
handler.send_header("Content-Type", "image/bmp" if full.lower().endswith(".bmp") else "image/jpeg")
handler.end_headers()
handler.wfile.write(data)
except Exception as e:
self.logger.error(f"serve_static_image: {e}")
handler.send_response(404)
handler.end_headers()
# ---------- characters ----------
def _current_character(self) -> str:
try:
return self.shared_data.config.get("current_character", "BJORN") or "BJORN"
except Exception:
return "BJORN"
def list_characters(self, handler):
"""List available characters and mark current one."""
try:
chars_dir = self.shared_data.settings_dir
characters = []
for entry in os.scandir(chars_dir):
if entry.is_dir():
name = entry.name
idle_path = os.path.join(entry.path, "IDLE", "IDLE1.bmp") # legacy path?
characters.append({"name": name, "has_idle_image": os.path.exists(idle_path)})
self._send_json(
handler,
{"status": "success", "characters": characters, "current_character": self._current_character()},
)
except Exception as e:
self.logger.error(f"list_characters: {e}")
self._send_error(handler, str(e))
def get_character_icon(self, handler):
"""Serve IDLE1.bmp: if character is current, under status_images_dir; else in settings/<char>/status."""
try:
q = parse_qs(urlparse(handler.path).query)
character = (q.get("character", [None])[0] or "").strip()
if not character:
raise ValueError("character parameter is required")
if character == self._current_character():
idle = os.path.join(self.shared_data.status_images_dir, "IDLE", "IDLE1.bmp")
else:
idle = os.path.join(self.shared_data.settings_dir, character, "status", "IDLE", "IDLE1.bmp")
if not os.path.exists(idle):
raise FileNotFoundError(f"IDLE1.bmp for '{character}' not found")
with open(idle, "rb") as f:
data = f.read()
handler.send_response(200)
handler.send_header("Content-Type", "image/bmp")
handler.end_headers()
handler.wfile.write(data)
except Exception as e:
self.logger.error(f"get_character_icon: {e}")
handler.send_response(404)
handler.end_headers()
def create_character(self, handler):
"""Create a new character by copying current character's images."""
try:
data = json.loads(handler.rfile.read(int(handler.headers["Content-Length"])).decode("utf-8"))
name = (data.get("character_name") or "").strip()
if not name:
raise ValueError("character_name is required")
new_dir = os.path.join(self.shared_data.settings_dir, name)
if os.path.exists(new_dir):
raise FileExistsError(f"Character '{name}' already exists")
self._save_current_character_images(new_dir)
self._send_json(handler, {"status": "success", "message": "Character created successfully"})
except Exception as e:
self.logger.error(f"create_character: {e}")
self._send_error(handler, str(e))
def switch_character(self, handler):
"""Switch character: persist current images, load selected images as active."""
try:
data = json.loads(handler.rfile.read(int(handler.headers["Content-Length"])).decode("utf-8"))
target = (data.get("character_name") or "").strip()
if not target:
raise ValueError("character_name is required")
current = self._current_character()
if target == current:
self._send_json(handler, {"status": "success", "message": "Character already selected"})
return
# Save current
self._save_current_character_images(os.path.join(self.shared_data.settings_dir, current))
# Load target
src = os.path.join(self.shared_data.settings_dir, target)
if not os.path.exists(src):
raise FileNotFoundError(f"Character '{target}' does not exist")
self._copy_character_images(src, self.shared_data.status_images_dir, self.shared_data.static_images_dir)
# Update config
self.shared_data.config["bjorn_name"] = target
self.shared_data.config["current_character"] = target
self.shared_data.save_config()
self.shared_data.load_config()
time.sleep(1)
self.shared_data.load_images()
self._send_json(handler, {"status": "success", "message": "Character switched successfully"})
except Exception as e:
self.logger.error(f"switch_character: {e}")
self._send_error(handler, str(e))
def delete_character(self, handler):
"""Delete a character; if it's the current one, switch back to BJORN first."""
try:
data = json.loads(handler.rfile.read(int(handler.headers["Content-Length"])).decode("utf-8"))
name = (data.get("character_name") or "").strip()
if not name:
raise ValueError("character_name is required")
if name == "BJORN":
raise ValueError("Cannot delete the default 'BJORN' character")
char_dir = os.path.join(self.shared_data.settings_dir, name)
if not os.path.exists(char_dir):
raise FileNotFoundError(f"Character '{name}' does not exist")
if name == self._current_character():
bjorn_dir = os.path.join(self.shared_data.settings_dir, "BJORN")
if not os.path.exists(bjorn_dir):
raise FileNotFoundError("Default 'BJORN' character does not exist")
self._copy_character_images(bjorn_dir, self.shared_data.status_images_dir, self.shared_data.static_images_dir)
self.shared_data.config["bjorn_name"] = "BJORN"
self.shared_data.config["current_character"] = "BJORN"
self.shared_data.save_config()
self.shared_data.load_config()
self.shared_data.load_images()
shutil.rmtree(char_dir)
self._send_json(handler, {"status": "success", "message": "Character deleted successfully"})
except Exception as e:
self.logger.error(f"delete_character: {e}")
self._send_error(handler, str(e))
def _save_current_character_images(self, target_dir: str):
"""Save current active images to the character directory."""
try:
self._ensure_dir(target_dir)
dst_status = os.path.join(target_dir, "status")
if os.path.exists(dst_status):
shutil.rmtree(dst_status)
shutil.copytree(self.shared_data.status_images_dir, dst_status)
dst_static = os.path.join(target_dir, "static")
if os.path.exists(dst_static):
shutil.rmtree(dst_static)
shutil.copytree(self.shared_data.static_images_dir, dst_static)
except Exception as e:
self.logger.error(f"_save_current_character_images: {e}")
def _copy_character_images(self, src_dir: str, dst_status_dir: str, dst_static_dir: str):
"""Activate a character: copy its stored images into the live folders."""
try:
src_status = os.path.join(src_dir, "status")
if os.path.exists(src_status):
if os.path.exists(dst_status_dir):
shutil.rmtree(dst_status_dir)
shutil.copytree(src_status, dst_status_dir)
src_static = os.path.join(src_dir, "static")
if os.path.exists(src_static):
if os.path.exists(dst_static_dir):
shutil.rmtree(dst_static_dir)
shutil.copytree(src_static, dst_static_dir)
except Exception as e:
self.logger.error(f"_copy_character_images: {e}")
# =============================================================================
# Comments (web_utils/comment_utils.py merged)
# =============================================================================
def get_sections(self, handler):
"""Get list of comment sections (statuses) from DB."""
try:
rows = self.shared_data.db.query("SELECT DISTINCT status FROM comments ORDER BY status;")
sections = [r["status"] for r in rows]
handler.send_response(200)
handler.send_header('Content-Type', 'application/json')
handler.end_headers()
response = json.dumps({'status': 'success', 'sections': sections})
handler.wfile.write(response.encode('utf-8'))
except Exception as e:
self.logger.error(f"Error in get_sections: {e}")
handler.send_response(500)
handler.send_header('Content-Type', 'application/json')
handler.end_headers()
error_response = json.dumps({'status': 'error', 'message': str(e)})
handler.wfile.write(error_response.encode('utf-8'))
def get_comments(self, handler):
"""Get comments for a specific section from DB."""
try:
query_components = parse_qs(urlparse(handler.path).query)
section = query_components.get('section', [None])[0]
if not section:
raise ValueError('Section parameter is required')
rows = self.shared_data.db.query(
"SELECT text FROM comments WHERE status=? ORDER BY id;",
(section,)
)
comments = [r["text"] for r in rows]
handler.send_response(200)
handler.send_header('Content-Type', 'application/json')
handler.end_headers()
response = json.dumps({'status': 'success', 'comments': comments})
handler.wfile.write(response.encode('utf-8'))
except Exception as e:
self.logger.error(f"Error in get_comments: {e}")
handler.send_response(500)
handler.send_header('Content-Type', 'application/json')
handler.end_headers()
error_response = json.dumps({'status': 'error', 'message': str(e)})
handler.wfile.write(error_response.encode('utf-8'))
def save_comments(self, data):
"""Save comment list for a section to DB (replaces existing)."""
try:
section = data.get('section')
comments = data.get('comments')
lang = data.get('lang', 'fr')
theme = data.get('theme', section or 'general')
weight = int(data.get('weight', 1))
if not section or comments is None:
return {'status': 'error', 'message': 'Section and comments are required'}
if not isinstance(comments, list):
return {'status': 'error', 'message': 'Comments must be a list of strings'}
# Replace section content
with self.shared_data.db.transaction(immediate=True):
self.shared_data.db.execute("DELETE FROM comments WHERE status=? AND lang=?", (section, lang))
rows = []
for txt in comments:
t = str(txt).strip()
if not t:
continue
rows.append((t, section, theme, lang, weight))
if rows:
self.shared_data.db.insert_comments(rows)
return {'status': 'success', 'message': 'Comments saved successfully'}
except Exception as e:
self.logger.error(f"Error in save_comments: {e}")
return {'status': 'error', 'message': str(e)}
def restore_default_comments(self, data=None):
"""Restore default comments from JSON file to DB."""
try:
inserted = self.shared_data.db.import_comments_from_json(
self.shared_data.default_comments_file,
lang=(data.get('lang') if isinstance(data, dict) else None) or 'fr',
clear_existing=True
)
return {
'status': 'success',
'message': f'Comments restored ({inserted} entries).'
}
except Exception as e:
self.logger.error(f"Error in restore_default_comments: {e}")
self.logger.error(traceback.format_exc())
return {'status': 'error', 'message': str(e)}
def delete_comment_section(self, data):
"""Delete a comment section and its associated comments from DB."""
try:
section_name = data.get('section')
lang = data.get('lang', 'fr')
if not section_name:
return {'status': 'error', 'message': "Section name is required."}
if not re.match(r'^[\w\-\s]+$', section_name):
return {'status': 'error', 'message': "Invalid section name."}
count = self.shared_data.db.execute(
"DELETE FROM comments WHERE status=? AND lang=?;",
(section_name, lang)
)
if count == 0:
return {'status': 'error', 'message': f"Section '{section_name}' not found for lang='{lang}'."}
return {'status': 'success', 'message': 'Section deleted successfully.'}
except Exception as e:
self.logger.error(f"Error in delete_comment_section: {e}")
self.logger.error(traceback.format_exc())
return {'status': 'error', 'message': str(e)}
# =============================================================================
# Attacks (web_utils/attack_utils.py merged)
# =============================================================================
def _write_json(self, handler, obj: dict, code: int = 200):
handler.send_response(code)
handler.send_header('Content-Type', 'application/json')
handler.end_headers()
handler.wfile.write(json.dumps(obj).encode('utf-8'))
def _send_error_response(self, handler, message: str, status_code: int = 500):
handler.send_response(status_code)
handler.send_header('Content-Type', 'application/json')
handler.end_headers()
response = {'status': 'error', 'message': message}
handler.wfile.write(json.dumps(response).encode('utf-8'))
def _extract_action_meta_from_content(self, content: str) -> dict | None:
"""Extract action metadata (b_* variables) from Python content."""
try:
tree = ast.parse(content)
meta = {}
for node in tree.body:
if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
key = node.targets[0].id
if key.startswith("b_"):
val = ast.literal_eval(node.value) if isinstance(
node.value, (ast.Constant, ast.List, ast.Dict, ast.Tuple)
) else None
meta[key] = val
return meta if meta else None
except Exception:
return None
def get_first_class_name(self, filepath: str) -> str:
"""Extract first class name from Python file using AST."""
try:
with open(filepath, 'r', encoding='utf-8') as file:
tree = ast.parse(file.read(), filename=filepath)
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
self.logger.debug(f"Found class: {node.name} in {filepath}")
return node.name
except Exception as e:
self.logger.error(f"Error parsing file {filepath}: {e}")
self.logger.warning(f"No class found in {filepath}")
return ''
def get_first_class_name_from_content(self, content: str) -> str:
"""Extract first class name from Python content using AST."""
try:
tree = ast.parse(content)
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
self.logger.debug(f"Found class in content: {node.name}")
return node.name
except Exception as e:
self.logger.error(f"Error parsing content: {e}")
self.logger.warning("No class found in provided content.")
return ''
# ---------- endpoints ----------
# def get_attacks(self, handler):
# """List all attack cards from database."""
# try:
# cards = self.shared_data.db.list_action_cards()
# resp = {"attacks": [{"name": c["name"], "image": c["image"]} for c in cards]}
# handler.send_response(200)
# handler.send_header('Content-Type', 'application/json')
# handler.end_headers()
# handler.wfile.write(json.dumps(resp).encode('utf-8'))
# except Exception as e:
# self.logger.error(f"get_attacks error: {e}")
# self._send_error_response(handler, str(e))
def get_attacks(self, handler):
"""List all attack cards from DB (name + enabled)."""
try:
cards = self.shared_data.db.list_action_cards() # déjà mappe b_enabled -> enabled
attacks = []
for c in cards:
name = c.get("name") or c.get("b_class")
if not name:
continue
enabled = int(c.get("enabled", c.get("b_enabled", 0)) or 0)
attacks.append({"name": name, "enabled": enabled})
resp = {"attacks": attacks}
handler.send_response(200)
handler.send_header('Content-Type', 'application/json')
handler.end_headers()
handler.wfile.write(json.dumps(resp).encode('utf-8'))
except Exception as e:
self.logger.error(f"get_attacks error: {e}")
self._send_error_response(handler, str(e))
def set_action_enabled(self, handler):
"""Body: { action_name: str, enabled: 0|1 }"""
try:
length = int(handler.headers.get('Content-Length', 0))
body = handler.rfile.read(length) if length else b'{}'
data = json.loads(body or b'{}')
action_name = (data.get('action_name') or '').strip()
enabled = 1 if int(data.get('enabled', 0)) else 0
if not action_name:
raise ValueError("action_name is required")
# Met à jour la colonne correcte avec l'API DB existante
rowcount = self.shared_data.db.execute(
"UPDATE actions SET b_enabled = ? WHERE b_class = ?;",
(enabled, action_name)
)
if not rowcount:
raise ValueError(f"Action '{action_name}' not found (b_class)")
out = {"status": "success", "action_name": action_name, "enabled": enabled}
handler.send_response(200)
handler.send_header('Content-Type', 'application/json')
handler.end_headers()
handler.wfile.write(json.dumps(out).encode('utf-8'))
except Exception as e:
self.logger.error(f"set_action_enabled error: {e}")
self._send_error_response(handler, str(e))
def get_attack_content(self, handler):
"""Get source code content of an attack."""
try:
params = dict(parse_qs(handler.path.split('?')[-1]))
attack_name = (params.get('name', [''])[0] or '').strip()
if not attack_name:
raise ValueError("Attack name not provided.")
row = self.shared_data.db.get_action_by_class(attack_name)
if not row:
raise FileNotFoundError(f"Attack '{attack_name}' not found in DB.")
module_name = row["b_module"]
script_path = os.path.join(self.shared_data.actions_dir, f"{module_name}.py")
with open(script_path, 'r', encoding='utf-8') as f:
content = f.read()
self._write_json(handler, {"status": "success", "content": content})
except Exception as e:
self.logger.error(f"Error retrieving attack content: {e}")
self._send_error_response(handler, str(e))
def add_attack(self, handler):
"""Import a new attack from uploaded .py file, parse b_* meta, upsert DB."""
try:
ctype = handler.headers.get('Content-Type') or ""
if 'multipart/form-data' not in ctype:
raise ValueError("Content-Type must be multipart/form-data.")
form = cgi.FieldStorage(fp=handler.rfile, headers=handler.headers, environ={'REQUEST_METHOD': 'POST'})
if 'attack_file' not in form:
raise ValueError("No attack_file field in form.")
file_item = form['attack_file']
if not file_item.filename.endswith('.py'):
raise ValueError("Only .py files are allowed.")
filename = file_item.filename
module_name = os.path.splitext(filename)[0]
content = file_item.file.read().decode('utf-8')
meta = self._extract_action_meta_from_content(content)
if not meta or "b_class" not in meta:
raise ValueError("File must define b_class (and ideally b_module/b_port).")
# Write file
dst = os.path.join(self.shared_data.actions_dir, filename)
with open(dst, "w", encoding="utf-8") as f:
f.write(content)
# Upsert DB
meta.setdefault("b_module", module_name)
self.shared_data.db.upsert_simple_action(**meta)
# Optional copy to defaults
if handler.headers.get('Import-Default', 'false').lower() == 'true':
os.makedirs(self.shared_data.default_actions_dir, exist_ok=True)
shutil.copyfile(dst, os.path.join(self.shared_data.default_actions_dir, filename))
self._write_json(handler, {"status": "success", "message": "Attack imported successfully."})
except Exception as e:
self.logger.error(f"Error importing attack: {e}")
self._send_error_response(handler, str(e))
def remove_attack(self, handler):
"""Remove an attack (file + DB row)."""
try:
body = handler.rfile.read(int(handler.headers.get('Content-Length', 0)) or 0)
data = json.loads(body or "{}")
attack_name = (data.get("name") or "").strip()
if not attack_name:
raise ValueError("Attack name not provided.")
row = self.shared_data.db.get_action_by_class(attack_name)
if not row:
raise FileNotFoundError(f"Attack '{attack_name}' not found in DB.")
module_name = row["b_module"]
path = os.path.join(self.shared_data.actions_dir, f"{module_name}.py")
if os.path.exists(path):
os.remove(path)
self.shared_data.db.delete_action(attack_name)
self._write_json(handler, {"status": "success", "message": "Attack removed successfully."})
except Exception as e:
self.logger.error(f"Error removing attack: {e}")
self._send_error_response(handler, str(e))
def save_attack(self, handler):
"""Save/update attack source code and refresh DB metadata if b_class changed."""
try:
body = handler.rfile.read(int(handler.headers.get('Content-Length', 0)) or 0)
data = json.loads(body or "{}")
attack_name = (data.get('name') or '').strip()
content = data.get('content') or ""
if not attack_name or not content:
raise ValueError("Missing name or content.")
row = self.shared_data.db.get_action_by_class(attack_name)
if not row:
raise FileNotFoundError(f"Attack '{attack_name}' not found in DB.")
module_name = row["b_module"]
script_path = os.path.join(self.shared_data.actions_dir, f"{module_name}.py")
with open(script_path, "w", encoding="utf-8") as f:
f.write(content)
# If b_class changed, update DB
meta = self._extract_action_meta_from_content(content) or {}
new_b_class = meta.get("b_class")
if new_b_class and new_b_class != attack_name:
self.shared_data.db.delete_action(attack_name)
meta.setdefault("b_module", module_name)
self.shared_data.db.upsert_simple_action(**meta)
else:
meta.setdefault("b_class", attack_name)
meta.setdefault("b_module", module_name)
self.shared_data.db.upsert_simple_action(**meta)
self._write_json(handler, {"status": "success", "message": "Attack saved successfully."})
except Exception as e:
self.logger.error(f"Error saving attack: {e}")
self._send_error_response(handler, str(e))
def restore_attack(self, handler):
"""Restore an attack from default_actions_dir and re-upsert metadata."""
try:
body = handler.rfile.read(int(handler.headers.get('Content-Length', 0)) or 0)
data = json.loads(body or "{}")
attack_name = (data.get('name') or '').strip()
if not attack_name:
raise ValueError("Attack name not provided.")
row = self.shared_data.db.get_action_by_class(attack_name)
if not row:
raise FileNotFoundError(f"Attack '{attack_name}' not found in DB.")
module_name = row["b_module"]
filename = f"{module_name}.py"
src = os.path.join(self.shared_data.default_actions_dir, filename)
dst = os.path.join(self.shared_data.actions_dir, filename)
if not os.path.exists(src):
raise FileNotFoundError(f"Default version not found: {src}")
shutil.copyfile(src, dst)
with open(dst, "r", encoding="utf-8") as f:
meta = self._extract_action_meta_from_content(f.read()) or {}
meta.setdefault("b_class", attack_name)
meta.setdefault("b_module", module_name)
self.shared_data.db.upsert_simple_action(**meta)
self._write_json(handler, {"status": "success", "message": "Attack restored to default successfully."})
except Exception as e:
self.logger.error(f"Error restoring attack: {e}")
self._send_error_response(handler, str(e))
def serve_actions_icons(self, handler):
"""Serve action icons from actions_icons_dir."""
try:
rel = handler.path[len('/actions_icons/'):]
rel = os.path.normpath(rel).replace("\\", "/")
if rel.startswith("../"):
handler.send_error(400, "Invalid path")
return
image_path = os.path.join(self.shared_data.actions_icons_dir, rel)
if not os.path.exists(image_path):
handler.send_error(404, "Image not found")
return
if image_path.endswith('.bmp'):
mime = 'image/bmp'
elif image_path.endswith('.png'):
mime = 'image/png'
elif image_path.endswith('.jpg') or image_path.endswith('.jpeg'):
mime = 'image/jpeg'
else:
mime = 'application/octet-stream'
with open(image_path, 'rb') as f:
content = f.read()
handler.send_response(200)
handler.send_header('Content-Type', mime)
handler.send_header('Content-Length', str(len(content)))
handler.end_headers()
handler.wfile.write(content)
self.logger.info(f"Served action icon: {image_path}")
except Exception as e:
self.logger.error(f"Error serving action icon {handler.path}: {e}")
handler.send_error(500, "Internal Server Error")
# ---------- WEB IMAGES & ACTION ICONS ----------
def _list_images(self, directory: str, with_dims: bool=False):
if not os.path.isdir(directory): return []
items = []
for fname in os.listdir(directory):
p = os.path.join(directory, fname)
if not os.path.isfile(p): continue
ext = os.path.splitext(fname)[1].lower()
if ext not in ALLOWED_IMAGE_EXTS: continue
if with_dims:
try:
with Image.open(p) as img: w, h = img.size
items.append({'name': fname, 'width': w, 'height': h})
except Exception:
items.append({'name': fname, 'width': None, 'height': None})
else:
items.append(fname)
return items
def _mime(self, path: str) -> str:
p = path.lower()
if p.endswith('.bmp'): return 'image/bmp'
if p.endswith('.png'): return 'image/png'
if p.endswith('.jpg') or p.endswith('.jpeg'): return 'image/jpeg'
if p.endswith('.gif'): return 'image/gif'
if p.endswith('.ico'): return 'image/x-icon'
if p.endswith('.webp'): return 'image/webp'
return 'application/octet-stream'
def list_web_images_with_dimensions(self, h):
try: self._send_json(h, {'status':'success','images': self._list_images(self.web_images_dir, with_dims=True)})
except Exception as e: self.logger.error(e); self._err(h, str(e))
def upload_web_image(self, h):
import cgi
try:
ctype, pdict = cgi.parse_header(h.headers.get('Content-Type'))
if ctype != 'multipart/form-data': raise ValueError('Content-Type doit être multipart/form-data')
pdict['boundary']=bytes(pdict['boundary'],'utf-8'); pdict['CONTENT-LENGTH']=int(h.headers.get('Content-Length'))
form = cgi.FieldStorage(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])),
headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True)
if 'web_image' not in form or not getattr(form['web_image'],'filename',''): raise ValueError('Aucun fichier web_image fourni')
file_item = form['web_image']; filename = self._safe(file_item.filename)
base, ext = os.path.splitext(filename);
if ext.lower() not in ALLOWED_IMAGE_EXTS: filename = base + '.png'
data = file_item.file.read()
if self.should_resize_images:
with Image.open(BytesIO(data)) as im:
try: resample = Image.Resampling.LANCZOS
except AttributeError: resample = Image.LANCZOS
im = im.resize((self.resize_width, self.resize_height), resample)
out = BytesIO()
ext = os.path.splitext(filename)[1].lower()
fmt = {'.png':'PNG','.jpg':'JPEG','.jpeg':'JPEG','.gif':'GIF','.ico':'ICO','.bmp':'BMP','.webp':'WEBP'}.get(ext, 'PNG')
if fmt in ('JPEG','BMP'): im = im.convert('RGB')
im.save(out, fmt)
data = out.getvalue()
with open(os.path.join(self.web_images_dir, filename), 'wb') as f:
f.write(data)
self._send_json(h, {'status':'success','message':'Web image uploaded','file':filename})
except Exception as e:
self.logger.error(e); self._err(h, str(e))
def serve_web_image(self, h):
try:
url_path = unquote(urlparse(h.path).path); prefix='/web/images/'
if not url_path.startswith(prefix): h.send_error(400,"Bad Request"); return
rel = self._safe(url_path[len(prefix):]); target = os.path.join(self.web_images_dir, rel)
if not os.path.isfile(target): h.send_error(404,"Not found"); return
with open(target,'rb') as f: content = f.read()
h.send_response(200); h.send_header('Content-Type', self._mime(target))
h.send_header('Content-Length', str(len(content))); h.end_headers(); h.wfile.write(content)
except Exception as e:
self.logger.error(e); h.send_error(500,"Internal Server Error")
def list_actions_icons_with_dimensions(self, h):
try: self._send_json(h, {'status':'success','images': self._list_images(self.actions_icons_dir, with_dims=True)})
except Exception as e: self.logger.error(e); self._err(h, str(e))
def upload_actions_icon(self, h):
import cgi
try:
ctype, pdict = cgi.parse_header(h.headers.get('Content-Type'))
if ctype != 'multipart/form-data': raise ValueError('Content-Type doit être multipart/form-data')
pdict['boundary']=bytes(pdict['boundary'],'utf-8'); pdict['CONTENT-LENGTH']=int(h.headers.get('Content-Length'))
form = cgi.FieldStorage(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])),
headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True)
if 'icon_image' not in form or not getattr(form['icon_image'],'filename',''): raise ValueError('Aucun fichier icon_image fourni')
file_item = form['icon_image']; filename = self._safe(file_item.filename)
base, ext = os.path.splitext(filename);
if ext.lower() not in ALLOWED_IMAGE_EXTS: filename = base + '.png'
data = file_item.file.read()
if self.should_resize_images:
with Image.open(BytesIO(data)) as im:
try: resample = Image.Resampling.LANCZOS
except AttributeError: resample = Image.LANCZOS
im = im.resize((self.resize_width, self.resize_height), resample)
out = BytesIO()
ext = os.path.splitext(filename)[1].lower()
fmt = {'.png':'PNG','.jpg':'JPEG','.jpeg':'JPEG','.gif':'GIF','.ico':'ICO','.bmp':'BMP','.webp':'WEBP'}.get(ext, 'PNG')
if fmt in ('JPEG','BMP'): im = im.convert('RGB')
im.save(out, fmt)
data = out.getvalue()
with open(os.path.join(self.web_images_dir, filename), 'wb') as f:
f.write(data)
self._send_json(h, {'status':'success','message':'Action icon uploaded','file':filename})
except Exception as e:
self.logger.error(e); self._err(h, str(e))
def serve_actions_icon(self, h):
try:
rel = h.path[len('/actions_icons/'):].lstrip('/')
rel = os.path.normpath(rel).replace("\\","/")
if rel.startswith("../"): h.send_error(400,"Invalid path"); return
image_path = os.path.join(self.actions_icons_dir, rel)
if not os.path.exists(image_path): h.send_error(404,"Image not found"); return
with open(image_path,'rb') as f: content = f.read()
h.send_response(200); h.send_header('Content-Type', self._mime(image_path))
h.send_header('Content-Length', str(len(content))); h.end_headers(); h.wfile.write(content)
except Exception as e:
self.logger.error(e); h.send_error(500,"Internal Server Error")