diff --git a/kibicara/webapi/__init__.py b/kibicara/webapi/__init__.py new file mode 100644 index 0000000..8a8a81c --- /dev/null +++ b/kibicara/webapi/__init__.py @@ -0,0 +1,20 @@ +# Copyright (C) 2020 by Thomas Lindner +# Copyright (C) 2020 by Cathy Hu +# +# SPDX-License-Identifier: 0BSD + +from fastapi import APIRouter +from kibicara.platforms.test.webapi import router as test_router +from kibicara.webapi.admin import router as admin_router +from kibicara.webapi.hoods import router as hoods_router +from kibicara.webapi.hoods.badwords import router as badwords_router +from kibicara.webapi.hoods.triggers import router as triggers_router + + +router = APIRouter() +router.include_router(admin_router, prefix='/admin', tags=['admin']) +hoods_router.include_router(triggers_router, prefix='/{hood_id}/triggers') +hoods_router.include_router(badwords_router, prefix='/{hood_id}/badwords') +hoods_router.include_router( + test_router, prefix='/{hood_id}/test', tags=['test']) +router.include_router(hoods_router, prefix='/hoods', tags=['hoods']) diff --git a/kibicara/webapi/admin.py b/kibicara/webapi/admin.py new file mode 100644 index 0000000..9fe7b0d --- /dev/null +++ b/kibicara/webapi/admin.py @@ -0,0 +1,110 @@ +# Copyright (C) 2020 by Thomas Lindner +# Copyright (C) 2020 by Cathy Hu +# +# SPDX-License-Identifier: 0BSD + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from kibicara.email import send_email +from kibicara.model import Admin, AdminHoodRelation +from logging import getLogger +from nacl.encoding import URLSafeBase64Encoder +from nacl.exceptions import CryptoError +from nacl.secret import SecretBox +from nacl.utils import random +from passlib.hash import argon2 +from ormantic.exceptions import NoMatch +from pickle import dumps, loads +from pydantic import BaseModel +from sqlite3 import IntegrityError + + +logger = getLogger(__name__) + + +class BodyAdmin(BaseModel): + email: str + password: str + + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/api/admin/login') +secret_box = SecretBox(random(SecretBox.KEY_SIZE)) + + +def to_token(**kwargs): + return secret_box.encrypt(dumps(kwargs), encoder=URLSafeBase64Encoder) \ + .decode('ascii') + + +def from_token(token): + return loads(secret_box.decrypt( + token.encode('ascii'), + encoder=URLSafeBase64Encoder)) + + +async def get_auth(email, password): + try: + admin = await Admin.objects.get(email=email) + if argon2.verify(password, admin.passhash): + return admin + raise ValueError + except NoMatch: + raise ValueError + + +async def get_admin(access_token=Depends(oauth2_scheme)): + try: + admin = await get_auth(**from_token(access_token)) + except (CryptoError, ValueError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Invalid authentication credentials', + headers={'WWW-Authenticate': 'Bearer'}) + return admin + + +router = APIRouter() + + +@router.post('/register/', status_code=status.HTTP_202_ACCEPTED) +async def admin_register(values: BodyAdmin): + register_token = to_token(**values.__dict__) + logger.debug(register_token) + try: + send_email( + to=values.email, subject='Confirm Account', + # XXX create real confirm link + body=register_token) + except ConnectionRefusedError: + logger.exception('Email sending failed') + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY) + return {} + + +@router.post('/confirm/{register_token}') +async def admin_confirm(register_token: str): + try: + values = from_token(register_token) + passhash = argon2.hash(values['password']) + await Admin.objects.create(email=values['email'], passhash=passhash) + return {'access_token': register_token, 'token_type': 'bearer'} + except IntegrityError: + raise HTTPException(status_code=status.HTTP_409_CONFLICT) + + +@router.post('/login/') +async def admin_login(form_data: OAuth2PasswordRequestForm = Depends()): + try: + await get_auth(form_data.username, form_data.password) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Incorrect email or password') + token = to_token(email=form_data.username, password=form_data.password) + return {'access_token': token, 'token_type': 'bearer'} + + +@router.get('/hoods/') +async def admin_hood_read_all(admin=Depends(get_admin)): + return await AdminHoodRelation.objects.select_related('hood') \ + .filter(admin=admin).all() diff --git a/kibicara/webapi/hoods/__init__.py b/kibicara/webapi/hoods/__init__.py new file mode 100644 index 0000000..fff0e17 --- /dev/null +++ b/kibicara/webapi/hoods/__init__.py @@ -0,0 +1,70 @@ +# Copyright (C) 2020 by Thomas Lindner +# Copyright (C) 2020 by Cathy Hu +# +# SPDX-License-Identifier: 0BSD + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from kibicara.model import AdminHoodRelation, Hood +from kibicara.webapi.admin import get_admin +from ormantic.exceptions import NoMatch +from pydantic import BaseModel +from sqlite3 import IntegrityError + + +class BodyHood(BaseModel): + name: str + landingpage: str = ''' + Default Landing Page + ''' + + +async def get_hood(hood_id: int, admin=Depends(get_admin)): + try: + hood = await Hood.objects.get(id=hood_id) + except NoMatch: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + try: + await AdminHoodRelation.objects.get(admin=admin, hood=hood) + except NoMatch: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + headers={'WWW-Authenticate': 'Bearer'}) + return hood + + +router = APIRouter() + + +@router.get('/') +async def hood_read_all(): + return await Hood.objects.all() + + +@router.post('/', status_code=status.HTTP_201_CREATED) +async def hood_create( + values: BodyHood, response: Response, + admin=Depends(get_admin)): + try: + hood = await Hood.objects.create(**values.__dict__) + await AdminHoodRelation.objects.create(admin=admin.id, hood=hood.id) + response.headers['Location'] = '%d' % hood.id + return hood + except IntegrityError: + raise HTTPException(status_code=status.HTTP_409_CONFLICT) + + +@router.get('/{hood_id}') +async def hood_read(hood=Depends(get_hood)): + return hood + + +@router.put('/{hood_id}', status_code=status.HTTP_204_NO_CONTENT) +async def hood_update(values: BodyHood, hood=Depends(get_hood)): + await hood.update(**values.__dict__) + + +@router.delete('/{hood_id}', status_code=status.HTTP_204_NO_CONTENT) +async def hood_delete(hood=Depends(get_hood)): + for relation in await AdminHoodRelation.objects.filter(hood=hood).all(): + await relation.delete() + await hood.delete() diff --git a/kibicara/webapi/hoods/badwords.py b/kibicara/webapi/hoods/badwords.py new file mode 100644 index 0000000..3a1ba85 --- /dev/null +++ b/kibicara/webapi/hoods/badwords.py @@ -0,0 +1,57 @@ +# Copyright (C) 2020 by Thomas Lindner +# Copyright (C) 2020 by Cathy Hu +# +# SPDX-License-Identifier: 0BSD + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from kibicara.model import BadWord +from kibicara.webapi.hoods import get_hood +from ormantic.exceptions import NoMatch +from pydantic import BaseModel +from sqlite3 import IntegrityError + + +class BodyBadWord(BaseModel): + pattern: str + + +async def get_badword(badword_id: int, hood=Depends(get_hood)): + try: + return await BadWord.objects.get(id=badword_id, hood=hood) + except NoMatch: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + +router = APIRouter() + + +@router.get('/') +async def badword_read_all(hood=Depends(get_hood)): + return await BadWord.objects.filter(hood=hood).all() + + +@router.post('/', status_code=status.HTTP_201_CREATED) +async def badword_create( + values: BodyBadWord, response: Response, + hood=Depends(get_hood)): + try: + badword = await BadWord.objects.create(hood=hood, **values.__dict__) + response.headers['Location'] = '%d' % badword.id + return badword + except IntegrityError: + raise HTTPException(status_code=status.HTTP_409_CONFLICT) + + +@router.get('/{badword_id}') +async def badword_read(badword=Depends(get_badword)): + return badword + + +@router.put('/{badword_id}', status_code=status.HTTP_204_NO_CONTENT) +async def badword_update(values: BodyBadWord, badword=Depends(get_badword)): + await badword.update(**values.__dict__) + + +@router.delete('/{badword_id}', status_code=status.HTTP_204_NO_CONTENT) +async def badword_delete(badword=Depends(get_badword)): + await badword.delete() diff --git a/kibicara/webapi/hoods/triggers.py b/kibicara/webapi/hoods/triggers.py new file mode 100644 index 0000000..2efa956 --- /dev/null +++ b/kibicara/webapi/hoods/triggers.py @@ -0,0 +1,57 @@ +# Copyright (C) 2020 by Thomas Lindner +# Copyright (C) 2020 by Cathy Hu +# +# SPDX-License-Identifier: 0BSD + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from kibicara.model import Trigger +from kibicara.webapi.hoods import get_hood +from ormantic.exceptions import NoMatch +from pydantic import BaseModel +from sqlite3 import IntegrityError + + +class BodyTrigger(BaseModel): + pattern: str + + +async def get_trigger(trigger_id: int, hood=Depends(get_hood)): + try: + return await Trigger.objects.get(id=trigger_id, hood=hood) + except NoMatch: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + +router = APIRouter() + + +@router.get('/') +async def trigger_read_all(hood=Depends(get_hood)): + return await Trigger.objects.filter(hood=hood).all() + + +@router.post('/', status_code=status.HTTP_201_CREATED) +async def trigger_create( + values: BodyTrigger, response: Response, + hood=Depends(get_hood)): + try: + trigger = await Trigger.objects.create(hood=hood, **values.__dict__) + response.headers['Location'] = '%d' % trigger.id + return trigger + except IntegrityError: + raise HTTPException(status_code=status.HTTP_409_CONFLICT) + + +@router.get('/{trigger_id}') +async def trigger_read(trigger=Depends(get_trigger)): + return trigger + + +@router.put('/{trigger_id}', status_code=status.HTTP_204_NO_CONTENT) +async def trigger_update(values: BodyTrigger, trigger=Depends(get_trigger)): + await trigger.update(**values.__dict__) + + +@router.delete('/{trigger_id}', status_code=status.HTTP_204_NO_CONTENT) +async def trigger_delete(trigger=Depends(get_trigger)): + await trigger.delete()