MVP сайта
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user