"""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