2026-10-22 16:49:25 +08:00
"""Shared site/route/content rules, with content sanitization."""
import ipaddress
import json
import re
import socket
import uuid
from urllib.parse import urlparse , unquote
import bleach
from . import db
2026-10-23 12:31:03 +08:00
TYPES = ( 'page' , 'product' , 'post' , 'service' , 'case' , 'brand' , 'vehicle' , 'other' )
ROOT = __import__ ( 'pathlib' ) . Path ( __file__ ) . resolve () . parent . parent
2026-10-22 17:14:44 +08:00
def discover_themes ():
"""Discover built-in themes from themes/*/manifest.json (single source of truth)."""
2026-10-22 17:28:57 +08:00
themes_dir = ROOT / 'themes'
if not themes_dir . is_dir ():
raise RuntimeError ( f 'themes directory missing: { themes_dir } ' )
2026-10-22 17:14:44 +08:00
found = []
2026-10-22 17:28:57 +08:00
for entry in sorted ( themes_dir . iterdir ()):
2026-10-22 17:14:44 +08:00
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 )
THEMES = discover_themes ()
2026-10-22 16:49:25 +08:00
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