"""链接卡片落地页:带 SEO meta 与自动跳转。""" from __future__ import annotations import html import json import os import re import secrets from io import BytesIO from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from fastapi import Request _SLUG_RE = re.compile(r"^[a-zA-Z0-9_-]{6,64}$") FAVICON_SIZE = 32 COVER_SIZE = 256 FAVICON_MIME = "image/png" def generate_slug() -> str: return secrets.token_urlsafe(9).replace("-", "_").replace(".", "_")[:12] def is_valid_slug(slug: str) -> bool: return bool(_SLUG_RE.match(slug or "")) def public_base_url(request: Optional["Request"] = None) -> str: env = os.getenv("KEFU_PUBLIC_BASE_URL", "").strip().rstrip("/") if env: return env if request is not None: return str(request.base_url).rstrip("/") return "" def absolute_media_url(path: str, request: Optional["Request"] = None) -> str: path = (path or "").strip() if not path: return "" if path.startswith("http://") or path.startswith("https://"): return path base = public_base_url(request) if not base: return path if not path.startswith("/"): path = f"/{path}" return f"{base}{path}" def favicon_mime(image_path: str) -> str: ext = (image_path or "").rsplit(".", 1)[-1].lower() return { "svg": "image/svg+xml", "png": "image/png", "gif": "image/gif", "webp": "image/webp", "jpg": "image/jpeg", "jpeg": "image/jpeg", "ico": "image/x-icon", }.get(ext, FAVICON_MIME) def favicon_path_for_cover(cover_path: str) -> str: """由封面路径推导同名 favicon 文件路径。""" path = (cover_path or "").strip() if not path: return "" if path.endswith(".favicon.png"): return path if path.endswith(".png"): return f"{path[:-4]}.favicon.png" base, _ = os.path.splitext(path) return f"{base}.favicon.png" def media_path_to_disk(upload_dir: str, media_path: str) -> str: prefix = "/api/media/link-cards/" path = (media_path or "").strip() if not path.startswith(prefix): return "" rel = path[len(prefix) :].replace("/", os.sep) return os.path.join(upload_dir, rel) def process_card_upload(raw: bytes) -> tuple[bytes, bytes]: """将任意位图转为封面 PNG (256x256) 与 favicon PNG (32x32)。""" if not raw: raise ValueError("图片为空") try: from PIL import Image except ImportError as exc: raise RuntimeError("服务器未安装 Pillow,无法转换 favicon") from exc try: with Image.open(BytesIO(raw)) as img: if getattr(img, "is_animated", False): img.seek(0) if img.mode not in ("RGB", "RGBA"): img = img.convert("RGBA") else: img = img.copy() width, height = img.size if width < 1 or height < 1: raise ValueError("图片尺寸无效") side = min(width, height) left = (width - side) // 2 top = (height - side) // 2 square = img.crop((left, top, left + side, top + side)) resample = Image.Resampling.LANCZOS cover = square.resize((COVER_SIZE, COVER_SIZE), resample) favicon = square.resize((FAVICON_SIZE, FAVICON_SIZE), resample) cover_buf = BytesIO() cover.save(cover_buf, format="PNG", optimize=True) favicon_buf = BytesIO() favicon.save(favicon_buf, format="PNG", optimize=True) return cover_buf.getvalue(), favicon_buf.getvalue() except ValueError: raise except Exception as exc: raise ValueError("无法识别图片格式,请上传 JPG、PNG、GIF 或 WebP") from exc def normalize_target_url(url: str) -> str: url = (url or "").strip() if not url: return "" if not url.startswith("http://") and not url.startswith("https://"): url = f"https://{url}" return url def build_keywords(title: str, content: str) -> str: parts = [p.strip() for p in (title, content) if p and p.strip()] return ", ".join(dict.fromkeys(parts)) def render_link_card_page( *, title: str, content: str, keywords: str, cover_url: str, favicon_url: str, target_url: str, ) -> str: safe_title = html.escape(title or "跳转中") safe_desc = html.escape(content or title or "") safe_keywords = html.escape(keywords or build_keywords(title, content)) safe_cover = html.escape(cover_url or "") safe_favicon = html.escape(favicon_url or cover_url or "") redirect_url = normalize_target_url(target_url) safe_target = html.escape(redirect_url) js_target = json.dumps(redirect_url, ensure_ascii=False) favicon_tag = "" og_image_tag = "" if safe_favicon: favicon_tag = f' \n' if safe_cover: og_image_tag = f' \n' return f"""
正在跳转到目标页面…
"""