"""FlowDeck — custom workspace emojis (v5.6.0). Uploaded emoji images stored per-workspace and usable as page icons. Kept on a prefix-less router so the paths stay ``/api/custom-emojis`` (the board router's ``/{owner}/{repo}`` HTML catch-all would otherwise shadow them). """ from __future__ import annotations import datetime import re from pathlib import Path from fastapi import APIRouter, HTTPException, Request from app.config import settings from app.db import get_conn router = APIRouter(tags=["emojis"]) _IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"} def _upload_root() -> Path: return Path(settings.data_dir) def _active_ws(request: Request) -> int: """Workspace id from the active-workspace cookie, fallback 1.""" try: ws_id = int(request.cookies.get("flowdeck_workspace", "") or 0) if ws_id > 0: return ws_id except (ValueError, TypeError): pass return 1 @router.get("/api/custom-emojis") def list_custom_emojis(request: Request): """List the current workspace's custom emojis.""" ws_id = _active_ws(request) with get_conn() as conn: rows = conn.execute( "SELECT id, name, url FROM custom_emojis WHERE workspace_id=? ORDER BY created_at DESC", (ws_id,), ).fetchall() return {"status": "ok", "emojis": [dict(r) for r in rows]} @router.post("/api/custom-emojis") async def create_custom_emoji(request: Request): """Upload a custom emoji image (multipart: ``name`` + ``file``).""" form = await request.form() name = (form.get("name") or "").strip()[:40] or "emoji" upload = form.get("file") if upload is None or not hasattr(upload, "filename"): raise HTTPException(400, "file field required") original = (upload.filename or "emoji.png").replace("\\", "/").rsplit("/", 1)[-1] safe = re.sub(r"[^A-Za-z0-9._-]", "_", original)[:80] ext = safe.rsplit(".", 1)[-1].lower() if "." in safe else "png" if ext not in _IMAGE_EXTS: raise HTTPException(400, "Unsupported image format") ws_id = _active_ws(request) stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S%f") folder = _upload_root() / f"uploads/workspace_{ws_id}" folder.mkdir(parents=True, exist_ok=True) final = f"emoji_{stamp}_{safe}" (folder / final).write_bytes(await upload.read()) url = f"/api/files/{ws_id}/{final}" with get_conn() as conn: cur = conn.execute( "INSERT INTO custom_emojis (workspace_id, name, url) VALUES (?, ?, ?)", (ws_id, name, url), ) conn.commit() emoji_id = cur.lastrowid return {"status": "ok", "emoji": {"id": emoji_id, "name": name, "url": url}} @router.delete("/api/custom-emojis/{emoji_id}") def delete_custom_emoji(request: Request, emoji_id: int): """Delete a custom emoji (and its stored file).""" ws_id = _active_ws(request) with get_conn() as conn: row = conn.execute( "SELECT url FROM custom_emojis WHERE id=? AND workspace_id=?", (emoji_id, ws_id), ).fetchone() if not row: raise HTTPException(404, "emoji not found") conn.execute("DELETE FROM custom_emojis WHERE id=?", (emoji_id,)) conn.commit() try: fname = (row["url"] or "").rsplit("/", 1)[-1] if fname: (_upload_root() / f"uploads/workspace_{ws_id}" / fname).unlink(missing_ok=True) except OSError: pass return {"status": "ok", "id": emoji_id}