Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/routes/docs.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import re
from urllib.parse import quote

Expand Down Expand Up @@ -89,7 +90,7 @@ async def page(doc, request: Request):
if any(i in request.query_params for i in ['s', 'search']):
return PlainTextResponse(re.sub(r'^(#+ )|(- )|(> )|(```\w*)|(:::\w*)|(---)$', '', text, flags=re.MULTILINE).replace('\n\n', '\n'))

title, nav, body = render(text)
title, nav, body = await asyncio.to_thread(render, text)

if any(i in request.query_params for i in ['r', 'raw']):
return PlainTextResponse(body)
Expand Down
4 changes: 2 additions & 2 deletions app/routes/res.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,11 @@ async def _ensure_tinted(kind, name):
if not source:
raise HTTPException(404, f'Исходный файл для "{kind} {name}" не существует.')

if not is_tinted(kind, name, source):
if not is_tinted(kind, name, source, item['tint']):
async with compose_lock:
# Re-check: the request holding the lock before us may have
# composed this very image.
if not is_tinted(kind, name, source):
if not is_tinted(kind, name, source, item['tint']):
try:
await asyncio.to_thread(compose_tint, kind, name, source, item['tint'])
except Exception:
Expand Down
3 changes: 2 additions & 1 deletion app/routes/resources.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import html
import os
from pathlib import Path
Expand Down Expand Up @@ -218,7 +219,7 @@ async def browser(request: Request, path=''):
if target.is_file():
size = target.stat().st_size
kind = FILE_KINDS.get(target.suffix.lower(), 'other')
view = _file_view(target)
view = await asyncio.to_thread(_file_view, target)
sub = [KIND_LABELS[kind], _human_size(size)]
if view['mode'] == 'code':
sub.append(view['lines'])
Expand Down
31 changes: 27 additions & 4 deletions app/routes/search_discovery.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""Crawler entry points for the public pages of the site."""

from urllib.parse import quote
from urllib.parse import quote, urlsplit, urlunsplit
from xml.etree import ElementTree

from fastapi import Response
from fastapi import HTTPException, Response
from fastapi.responses import PlainTextResponse

from ..utils.config import CONFIG
Expand All @@ -19,13 +19,36 @@
)


def _site_origin():
value = CONFIG.site_url
if not isinstance(value, str) or not value.strip():
return None
origin = value.strip().rstrip('/')
if any(char.isspace() for char in origin):
return None
try:
parsed = urlsplit(origin)
if (parsed.scheme not in ('http', 'https') or not parsed.hostname
or parsed.username is not None or parsed.password is not None
or parsed.path or parsed.query or parsed.fragment):
return None
parsed.port # Validate the optional port before publishing the URL.
return urlunsplit((parsed.scheme, parsed.netloc, '', '', ''))
except ValueError:
return None


@main_router.get('/robots.txt', response_class=PlainTextResponse)
async def robots_txt():
return f'User-agent: *\nAllow: /\n\nSitemap: {CONFIG.site_url}/sitemap.xml\n'
origin = _site_origin()
return 'User-agent: *\nAllow: /\n' + (f'\nSitemap: {origin}/sitemap.xml\n' if origin else '')


@main_router.get('/sitemap.xml')
async def sitemap_xml():
origin = _site_origin()
if not origin:
raise HTTPException(404, 'Адрес сайта не настроен.')
paths = list(STATIC_PAGES)
paths.extend(f'/docs/{quote(doc["slug"], safe="")}' for doc in CONFIG.search_index)
paths.extend(
Expand All @@ -39,7 +62,7 @@ async def sitemap_xml():
root = ElementTree.Element(f'{{{SITEMAP_NS}}}urlset')
for path in dict.fromkeys(paths):
entry = ElementTree.SubElement(root, f'{{{SITEMAP_NS}}}url')
ElementTree.SubElement(entry, f'{{{SITEMAP_NS}}}loc').text = CONFIG.site_url + path
ElementTree.SubElement(entry, f'{{{SITEMAP_NS}}}loc').text = origin + path

return Response(
content=ElementTree.tostring(root, encoding='utf-8', xml_declaration=True),
Expand Down
17 changes: 16 additions & 1 deletion app/routes/static.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@
_VERSIONED_CACHE = 'public, max-age=31536000, immutable'


def _asset_path(folder: str, name: str):
if not name or any(char in name for char in ('/', '\\', ':', '\x00')):
raise HTTPException(404, 'Файл не существует.')
root = (ROOT / 'static' / folder).resolve()
try:
path = (root / name).resolve()
if path.is_relative_to(root) and path.is_file():
return path
except (OSError, ValueError):
pass
raise HTTPException(404, 'Файл не существует.')


def _text_asset(request: Request, rel: str, media_type: str) -> Response:
try:
body, digest = load_asset(rel)
Expand All @@ -44,10 +57,12 @@ async def favicon():

@router.get('/static/css/{name}')
async def css(name, request: Request):
_asset_path('css', name)
return _text_asset(request, f'static/css/{name}', 'text/css; charset=utf-8')

@router.get('/static/js/{name}')
async def js(name, request: Request):
_asset_path('js', name)
return _text_asset(request, f'static/js/{name}', 'application/javascript; charset=utf-8')


Expand All @@ -62,7 +77,7 @@ async def font(name):
# Binary asset: FileResponse, not read_text (which decodes as UTF-8).
# Immutable, content-hashed by weight/subset, so cache hard.
return FileResponse(
str(ROOT / 'static' / 'fonts' / name),
str(_asset_path('fonts', name)),
media_type='font/woff2',
headers={
'Cache-Control': 'public, max-age=31536000, immutable',
Expand Down
3 changes: 2 additions & 1 deletion app/utils/lifespan/refresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def _watchers():
assets / 'nsfw.yaml',
)
community_files = _under(assets / 'community')
game_files = _under(res)
materials_yaml = _one_of(assets / 'materials.yaml')
materials_files = _under(assets / 'materials')

Expand All @@ -119,7 +120,7 @@ def _watchers():
res / 'scenario' / 'zhenya.rpy',
), _refresh_sprites),

('resources', lambda path: resource_sources(path) or community_files(path), _refresh_resources),
('resources', lambda path: resource_sources(path) or community_files(path) or game_files(path), _refresh_resources),
('browser', _under(res), _refresh_browser),

('specialists.yaml', _one_of(assets / 'specialists.yaml'), _refresh_specialists),
Expand Down
15 changes: 11 additions & 4 deletions app/utils/lifespan/sprites_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,18 @@ def sprite_file(name):
return sprites_path() / f'{name}.webp'

def is_composed(name):
# The disk cache survives restarts; a cached sprite is only stale if
# a declaration source changed after it was composed.
# Both the declaration and every layer contribute to the composed bytes.
path = sprite_file(name)
newest = max(p.stat().st_mtime for p in _sprite_sources() if p.exists())
return path.is_file() and path.stat().st_mtime >= newest
layers = CONFIG.sprite_layers.get(name)
if not path.is_file() or not layers:
return False
sources = [p for p in _sprite_sources() if p.exists()]
sources.extend(CONFIG.res_path / layer for layer in layers)
try:
newest = max(source.stat().st_mtime_ns for source in sources)
return path.stat().st_mtime_ns >= newest
except OSError:
return False

def compose_sprite(name):

Expand Down
25 changes: 21 additions & 4 deletions app/utils/lifespan/tint_cache.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import os, time
from PIL import Image

Expand All @@ -12,17 +13,29 @@ def tinted_path():
def tinted_file(kind, name):
return tinted_path() / kind / f'{name}.webp'

def is_tinted(kind, name, source):
# The disk cache survives restarts; a cached tint is only stale if the
# source image changed after it was composed.
def _signature(source, tint):
stat = source.stat()
return [str(source.resolve()), stat.st_mtime_ns, stat.st_size,
list(tint), CONFIG.setting('images.tint-quality')]


def is_tinted(kind, name, source, tint):
# Persist every input so changes are detected even across restarts.
path = tinted_file(kind, name)
return path.is_file() and path.stat().st_mtime >= source.stat().st_mtime
if not path.is_file():
return False
try:
saved = json.loads(path.with_suffix('.json').read_text('utf-8'))
return saved == _signature(source, tint)
except (OSError, ValueError):
return False

def compose_tint(kind, name, source, tint):
"""Ren'Py's `im.MatrixColor(file, im.matrix.tint(r, g, b))`: a diagonal
color matrix that scales the R/G/B channels independently and leaves
alpha untouched — exactly a per-channel multiply, no channel mixing."""
r, g, b = tint
signature = _signature(source, tint)

starttime = time.time()

Expand All @@ -47,6 +60,10 @@ def compose_tint(kind, name, source, tint):
tmp = path.with_suffix('.webp.tmp')
img.save(tmp, 'WEBP', quality=CONFIG.setting('images.tint-quality'))
os.replace(tmp, path)
metadata = path.with_suffix('.json')
temporary_metadata = metadata.with_suffix('.json.tmp')
temporary_metadata.write_text(json.dumps(signature), encoding='utf-8')
os.replace(temporary_metadata, metadata)

logger.info(f'Tinted image "{kind} {name}" composed in {time.time() - starttime:.4f}s')

Expand Down
35 changes: 15 additions & 20 deletions app/utils/md/banner.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ def render_banner_open(self, tokens, idx, options, env):
def render_banner_close(self, tokens, idx, options, env):
return '</div></div></aside>'

def banner_end(state: StateBlock, startLine: int, endLine: int):
"""Optional bounded note body and the line where parsing resumes."""
nextLine = startLine + 1
while nextLine < endLine:
pos = state.bMarks[nextLine] + state.tShift[nextLine]
line = state.src[pos:state.eMarks[nextLine]].strip()
if line == ':::':
return nextLine, nextLine + 1
if not line or line.startswith(':::'):
break
nextLine += 1
return startLine + 1, startLine + 1


def banner():
# `:::stub`, or `:::stub <короткая приписка>` on the same line. A multi-line
# note may follow, closed by `:::` — but only while the lines run on without
Expand Down Expand Up @@ -62,26 +76,7 @@ def block(state: StateBlock, startLine: int, endLine: int, silent: bool):

# Find the optional note body. bodyEnd stays at startLine + 1 for the
# bare single-line form; nextLine is where parsing resumes afterwards.
bodyEnd = nextLine = startLine + 1

while nextLine < endLine:
pos = state.bMarks[nextLine] + state.tShift[nextLine]
line = state.src[pos:state.eMarks[nextLine]].strip()

if line == ':::':
bodyEnd = nextLine
nextLine += 1
break

# Blank line, or the start of some other block — the banner was
# written in its bare form and this belongs to the document.
if not line or line.startswith(':::'):
bodyEnd = nextLine = startLine + 1
break

nextLine += 1
else:
bodyEnd = nextLine = startLine + 1
bodyEnd, nextLine = banner_end(state, startLine, endLine)

token = state.push('banner_open', 'aside', 1)
token.meta = {'kind': name}
Expand Down
23 changes: 18 additions & 5 deletions app/utils/md/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@
Banners are a deliberate exception on the *inside* — they keep their own
bounded scan (banner.py), because a banner is one or two sentences of page
status and should never grow into a container. They are still counted here,
because from the outside a banner opens and closes with the same markers as
everything else.
with the same bounded rule here, so a standalone banner consumes no closer.
"""

import re
Expand All @@ -37,6 +36,7 @@
# without the lead text that follows on the same line. Only the name matters
# here — what a block does with its lead is the parser's own business.
_OPEN_RE = re.compile(r'^:::[ \t]*([A-Za-z][\w-]*)')
_FENCE_RE = re.compile(r'^(`{3,}|~{3,})(.*)$')

CLOSER = ':::'

Expand Down Expand Up @@ -84,18 +84,31 @@ def find_closer(state, startLine: int, endLine: int) -> int | None:
"""
depth = 0
line = startLine + 1
fence = None

while line < endLine:
# bMarks + tShift skips the indent, so a closer indented inside a list
# item still reads as a closer — which the corpus relies on.
text = state.src[state.bMarks[line] + state.tShift[line]:state.eMarks[line]].strip()

if text == CLOSER:
marker = _FENCE_RE.match(text)
if fence:
if (marker and marker[1][0] == fence[0]
and len(marker[1]) >= len(fence) and not marker[2].strip()):
fence = None
elif marker and (marker[1][0] != '`' or '`' not in marker[2]):
fence = marker[1]
elif text == CLOSER:
if depth == 0:
return line
depth -= 1
elif opener_name(text):
depth += 1
elif name := opener_name(text):
if name in _NAMES:
depth += 1
else:
from .banner import banner_end
_, next_line = banner_end(state, line, endLine)
line = next_line - 1

line += 1

Expand Down
Loading