Files
cms-SiteForge/app/core.py
T
浅念 b1b4d12a19 polish: discovery hardening per code review (review round 2 minors)
- Fail fast with clear RuntimeError when themes/ directory is missing
- Document underscore-prefix skip rule
- Tests: broken-manifest/mismatch/no-manifest skip case, missing-dir fail-fast
- tests/test_theme_pro_v3.py: drop fourth hardcoded list, import from core

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 17:28:57 +08:00

131 lines
5.9 KiB
Python

"""Shared site/route/content rules, with content sanitization."""
import ipaddress
import json
import re
import socket
import uuid
from pathlib import Path
from urllib.parse import urlparse, unquote
import bleach
from . import db
ROOT = Path(__file__).resolve().parent.parent
def discover_themes():
"""Discover built-in themes from themes/*/manifest.json (single source of truth)."""
themes_dir = ROOT / 'themes'
if not themes_dir.is_dir():
raise RuntimeError(f'themes directory missing: {themes_dir}')
found = []
for entry in sorted(themes_dir.iterdir()):
# Underscore prefix marks non-theme assets (e.g. _professional_components.html).
if not entry.is_dir() or entry.name.startswith('_'):
continue
try:
manifest = json.loads((entry / 'manifest.json').read_text(encoding='utf-8'))
except (OSError, ValueError):
continue
if isinstance(manifest, dict) and manifest.get('id') == entry.name:
found.append(entry.name)
return tuple(found)
TYPES = ('page','product','post','service','case','brand','vehicle','other')
THEMES = discover_themes()
ALLOWED_TAGS = ['p','br','b','i','u','em','strong','h1','h2','h3','h4','h5','h6','ul','ol','li','blockquote','pre','code','a','img','figure','figcaption','table','thead','tbody','tr','th','td','hr','div','span']
ALLOWED_ATTRS = {'a':['href','title','target','rel'], 'img':['src','alt','title','loading','width','height'], 'td':['colspan','rowspan'], 'th':['colspan','rowspan']}
def uid():
return uuid.uuid4().hex[:12]
def sanitize(html):
return bleach.clean(html or '', tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS, protocols=['http','https','mailto'], strip=True)
def route_path(value, default='/'):
s = (value or default).strip()
if s.startswith(('http://','https://')):
s = urlparse(s).path or '/'
# Query strings cannot be represented as ordinary static file routes.
s = s.split('?',1)[0].split('#',1)[0]
if not s.startswith('/'):
s = '/' + s
if '\\' in s or '\x00' in s or any(part in ('.','..') for part in unquote(s).split('/')):
raise ValueError('不允许路径穿越或反斜杠')
if s.startswith(('/api/','/preview/','/__siteforge/')):
raise ValueError('该路径已保留给系统')
if len(s)>500:
raise ValueError('URL 路径过长')
return s
def slug_of(title):
s = re.sub(r'[^\w\-]+','-', title.strip().lower(), flags=re.UNICODE).strip('-')
return s[:70] or uid()
def clean_hex(s):
if not re.fullmatch(r'#[0-9a-fA-F]{6}', s or ''):
raise ValueError('品牌色需要 #RRGGBB 格式')
return s
def ensure_public_url(url):
parsed=urlparse(url)
if parsed.scheme not in ('https','http') or not parsed.hostname or parsed.username or parsed.password:
raise ValueError('仅支持公开的 HTTP/HTTPS URL')
hostname=parsed.hostname.lower()
if hostname in ('localhost','localhost.localdomain') or hostname.endswith(('.local','.internal')):
raise ValueError('禁止访问内网域名')
try:
addrs=socket.getaddrinfo(hostname, parsed.port or (443 if parsed.scheme=='https' else 80), type=socket.SOCK_STREAM)
for info in addrs:
ip=ipaddress.ip_address(info[4][0])
if not ip.is_global:
raise ValueError('不允许访问内网、回环或保留 IP 地址')
except socket.gaierror:
raise ValueError('无法解析域名')
return url
def save_entry(site_id, data, existing_id=None, source='manual', replace_route=False):
if not db.one('SELECT id FROM sites WHERE id=?',(site_id,)):
raise ValueError('网站不存在')
kind=str(data.get('type') or 'page').lower()
if kind not in TYPES:
kind='other'
title=str(data.get('title') or '').strip()
if not title:
raise ValueError('内容标题不能为空')
route=route_path(data.get('route'), '/' if kind=='page' and title.lower() in ('home','首页') else f'/{kind}/{slug_of(title)}/')
if route=='/' and kind!='page':
raise ValueError('首页必须是普通页面类型')
old=db.one('SELECT * FROM entries WHERE site_id=? AND route=?',(site_id,route))
if old and old['id'] != existing_id:
if replace_route:
existing_id=old['id']
else:
raise ValueError(f'URL 冲突:{route}')
eid=existing_id or uid()
prior=db.one('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,eid)) if existing_id else None
if existing_id and not prior:
raise ValueError('内容不存在')
meta=data.get('meta', {})
if isinstance(meta,str):
try: meta=json.loads(meta)
except json.JSONDecodeError: meta={}
if not isinstance(meta,dict): meta={}
if 'blocks' in meta:
from .blueprints import validate_blocks
meta['blocks']=validate_blocks(meta['blocks'])
vals=(kind,title,route,slug_of(title),str(data.get('summary') or ''),sanitize(str(data.get('body') or '')),
str(data.get('cover') or ''),str(data.get('seo_title') or ''),str(data.get('seo_description') or ''),
str(data.get('canonical') or ''),str(data.get('robots') or 'index,follow'),json.dumps(meta,ensure_ascii=False),
str(data.get('status') or 'published'))
if prior:
db.execute('''UPDATE entries SET type=?,title=?,route=?,slug=?,summary=?,body=?,cover=?,seo_title=?,seo_description=?,canonical=?,robots=?,meta_json=?,status=?,updated_at=? WHERE site_id=? AND id=?''',(*vals,db.now(),site_id,eid))
else:
db.insert('''INSERT INTO entries (type,title,route,slug,summary,body,cover,seo_title,seo_description,canonical,robots,meta_json,status,created_at,updated_at,site_id,id,source)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',(*vals,db.now(),db.now(),site_id,eid,source))
return eid
def entry_dict(e):
v=dict(e)
try:v['meta']=json.loads(v.pop('meta_json'))
except (ValueError,TypeError):v['meta']={}
return v