更新
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
"""链接卡片落地页:带 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' <link rel="icon" type="{FAVICON_MIME}" href="{safe_favicon}" />\n'
|
||||
if safe_cover:
|
||||
og_image_tag = f' <meta property="og:image" content="{safe_cover}" />\n'
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{safe_title}</title>
|
||||
<meta name="description" content="{safe_desc}" />
|
||||
<meta name="keywords" content="{safe_keywords}" />
|
||||
<meta property="og:title" content="{safe_title}" />
|
||||
<meta property="og:description" content="{safe_desc}" />
|
||||
{og_image_tag}{favicon_tag} <meta http-equiv="refresh" content="0;url={safe_target}" />
|
||||
<script>location.replace({js_target});</script>
|
||||
</head>
|
||||
<body>
|
||||
<p>正在跳转到目标页面…</p>
|
||||
<p><a href="{safe_target}">若未自动跳转,请点击这里</a></p>
|
||||
</body>
|
||||
</html>"""
|
||||
Reference in New Issue
Block a user