Files
Web-Parser-Constructor/src/parser_lab/app.py
T
2026-10-01 00:28:16 +10:00

157 lines
8.6 KiB
Python

"""FastAPI application: server-rendered pages and small progressive-enhancement APIs."""
import secrets
from urllib.parse import parse_qs
from fastapi import FastAPI, Request
from starlette.responses import HTMLResponse, RedirectResponse, JSONResponse, Response
from starlette.staticfiles import StaticFiles
from .config import Settings
from .repository import Repository, NotFound, Conflict
from .auth import Auth, Unauthorized, RateLimited
from .web import render, public_page, ROOT
class Boundary:
def __init__(self,app,auth): self.app=app; self.auth=auth
async def __call__(self,scope,receive,send):
if scope['type']!='http': return await self.app(scope,receive,send)
headers={k.decode().lower():v.decode() for k,v in scope.get('headers',[])}
changing=scope.get('method') in ('POST','PUT','PATCH','DELETE')
if changing:
if headers.get('origin','').rstrip('/') not in self.auth.settings.origins:
return await JSONResponse({'error':'Недопустимый источник запроса'},403)(scope,receive,send)
if scope['path'].startswith('/api/admin/'):
try: token=self.auth.from_headers(headers)
except Unauthorized:
return await JSONResponse({'error':'Войдите в кабинет'},401)(scope,receive,send)
if not self.auth.valid_csrf(token,headers.get('x-csrf-token','')):
return await JSONResponse({'error':'Обновите страницу кабинета'},403)(scope,receive,send)
limit=self.auth.settings.max_bytes+65536 if scope['path'] in ('/api/admin/import','/api/admin/inspect') else 1024*1024
try: declared=int(headers.get('content-length','0'))
except ValueError: return await Response(status_code=400)(scope,receive,send)
if declared>limit: return await JSONResponse({'error':'Файл или запрос слишком большой'},413)(scope,receive,send)
body=bytearray()
while True:
event=await receive()
if event['type']=='http.disconnect': return
body.extend(event.get('body',b''))
if len(body)>limit: return await JSONResponse({'error':'Файл или запрос слишком большой'},413)(scope,receive,send)
if not event.get('more_body',False): break
async def replay(): return {'type':'http.request','body':bytes(body),'more_body':False}
return await self.app(scope,replay,send)
await self.app(scope,receive,send)
def create_app(settings=None):
settings=settings or Settings.from_env()
repo=Repository(settings.data_dir);auth=Auth(settings,repo)
app=FastAPI(title='Parser Lab',docs_url=None,redoc_url=None,openapi_url=None)
app.state.repo=repo;app.state.auth=auth
app.add_middleware(Boundary,auth=auth)
app.mount('/static',StaticFiles(directory=ROOT/'static'),name='static')
@app.exception_handler(ValueError)
async def value_error(request,error):
status=404 if isinstance(error,NotFound) else 409 if isinstance(error,Conflict) else 400
if request.url.path.startswith('/api/') or request.query_params.get('fragment'):
return JSONResponse({'error':str(error)},status)
return HTMLResponse(render('error.html',message=str(error)),status)
@app.exception_handler(Unauthorized)
async def unauthorized(request,error):
if request.url.path.startswith('/api/'): return JSONResponse({'error':str(error)},401)
return RedirectResponse('/login',303)
@app.get('/healthz')
async def health():
with repo.connect() as db: db.execute('SELECT 1').fetchone()
return {'status':'ok'}
@app.get('/')
async def home(): return HTMLResponse(render('home.html'))
@app.get('/login')
async def login_page():
nonce=secrets.token_urlsafe(32)
response=HTMLResponse(render('login.html',csrf=auth.csrf(nonce)))
response.set_cookie('lab_login',nonce,httponly=True,secure=settings.secure_cookie,samesite='strict',max_age=600)
response.headers['Cache-Control']='no-store'
return response
@app.post('/login')
async def login(request:Request):
raw=await request.body()
if len(raw)>8192:return Response(status_code=413)
form={k:v[0] for k,v in parse_qs(raw.decode()).items()}
nonce=request.cookies.get('lab_login','')
if not auth.valid_csrf(nonce,form.get('csrf','')):return Response('Обновите страницу входа',status_code=403)
try:token=auth.login(form.get('username',''),form.get('password',''),request.client.host)
except (Unauthorized,RateLimited) as e:
return HTMLResponse(render('login.html',csrf=auth.csrf(nonce),error=str(e)),429 if isinstance(e,RateLimited) else 401)
response=RedirectResponse('/admin',303)
response.set_cookie('lab_session',token,httponly=True,secure=settings.secure_cookie,samesite='strict',max_age=43200)
response.delete_cookie('lab_login')
return response
@app.post('/logout')
async def logout(request:Request):
token=auth.from_headers(request.headers)
form=parse_qs((await request.body()).decode())
if not auth.valid_csrf(token,form.get('csrf',[''])[0]):return Response(status_code=403)
auth.logout(token)
response=RedirectResponse('/login',303);response.delete_cookie('lab_session')
return response
async def serve(request,slug,version,kind,record_id=None,mode='html'):
p=repo.publication(slug,version)
return public_response(request,p,kind,f'/{mode}/{slug}/v/{version}/{kind}',record_id,False,mode=='interactive')
def public_response(request,p,kind,base,record_id=None,preview=False,live=False):
page=int(request.query_params.get('page','1'))
search=request.query_params.get('q',''); category=request.query_params.get('category') or None
if request.query_params.get('fragment')=='1' and live:
from .query import select_records
from .web import records_fragment
view=p['views'].get(kind)
if view is None:raise NotFound('Представление не найдено')
result=select_records(p['records'],view,page,search,category)
return JSONResponse({'html':records_fragment(result['items'],kind,base),'total':result['total'],
'has_next':result['has_next'],'page':page},headers={'Cache-Control':'no-store'})
return HTMLResponse(public_page(p,kind,base,page,search,category,record_id,preview,live),headers={'Cache-Control':'no-store'})
@app.get('/html/{slug}/v/{version}/{kind}')
async def listing(request:Request,slug:str,version:int,kind:str):return await serve(request,slug,version,kind)
@app.get('/html/{slug}/v/{version}/{kind}/records/{record_id}')
async def detail(request:Request,slug:str,version:int,kind:str,record_id:str):return await serve(request,slug,version,kind,record_id)
@app.get('/interactive/{slug}/v/{version}/{kind}')
async def interactive(request:Request,slug:str,version:int,kind:str):return await serve(request,slug,version,kind,mode='interactive')
@app.get('/interactive/{slug}/v/{version}/{kind}/records/{record_id}')
async def interactive_detail(request:Request,slug:str,version:int,kind:str,record_id:str):return await serve(request,slug,version,kind,record_id,'interactive')
@app.get('/preview/{ident}/{kind}')
async def preview(request:Request,ident:str,kind:str):
auth.from_headers(request.headers)
return public_response(request,repo.get(ident),kind,f'/preview/{ident}/{kind}',preview=True)
@app.get('/preview/{ident}/{kind}/records/{record_id}')
async def preview_detail(request:Request,ident:str,kind:str,record_id:str):
auth.from_headers(request.headers)
return public_response(request,repo.get(ident),kind,f'/preview/{ident}/{kind}',record_id,True)
@app.get('/dynamic-preview/{ident}/{kind}')
async def dynamic_preview(request:Request,ident:str,kind:str):
auth.from_headers(request.headers)
return public_response(request,repo.get(ident),kind,f'/dynamic-preview/{ident}/{kind}',preview=True,live=True)
@app.get('/dynamic-preview/{ident}/{kind}/records/{record_id}')
async def dynamic_detail(request:Request,ident:str,kind:str,record_id:str):
auth.from_headers(request.headers)
return public_response(request,repo.get(ident),kind,f'/dynamic-preview/{ident}/{kind}',record_id,True,True)
from .admin import install_admin
install_admin(app,auth,repo,settings)
return app