Compare commits

...

87 commits

Author SHA1 Message Date
missytake 51929f8071 [doc] Fix ending of a file in CONTRIBUTING.md 2023-04-09 10:31:07 +00:00
missytake c773308a28 [doc] Users -> hood admins 2023-04-09 10:31:07 +00:00
missytake 7468344d81 [doc] Document how to implement the frontend for a platform 2023-04-09 10:31:07 +00:00
missytake 061d29325f [mastodon] Dismiss old notifications in the migration script, so they aren't reposted by ticketfrei3 2023-04-08 16:53:21 +00:00
missytake e9eb98dba8 [misc] Migration script creates nice output now 2023-04-08 16:53:21 +00:00
missytake ea58f4a150 [misc] Wrote migration script ticketfrei 2 -> 3 2023-04-08 16:53:21 +00:00
missytake 91e93b3e20 [mastodon] Fix instance load which broke with #17 2023-04-01 21:25:49 +02:00
Thomas Lindner 5bff62487b [misc] Migrate to TortoiseORM 2023-04-01 19:41:53 +02:00
Thomas Lindner 26818da430 [doc] Avoid global angular installation 2023-04-01 19:32:44 +02:00
missytake c468543fa5 [tests] Rename asyncclient into client 2023-04-01 15:32:10 +02:00
missytake f829bf8694 [tests] Get rid of non-async test client 2023-04-01 15:30:42 +02:00
missytake 16c4ebc84b [tests] Ported telegram tests to async 2023-04-01 15:23:33 +02:00
missytake f0e15f1d37 [tests] Ported mastodon tests to async 2023-04-01 15:13:50 +02:00
missytake b876e645de [tests] Ported email tests to async 2023-04-01 14:57:41 +02:00
missytake 6b6a2777bb [tests] Ported other hood webAPI tests to async 2023-04-01 14:51:32 +02:00
missytake c1b8ad2984 [tests] Disable trio backend for AnyIO 2023-04-01 14:40:02 +02:00
missytake 48e3b6f6bc [tests] Port first test to async #23 2023-04-01 14:18:24 +02:00
missytake 64715f5aa5 [mastodon] Also dismiss notifications which have no status 2023-03-19 20:29:25 +01:00
missytake f7d9baa8a3 [mastodon] Fix: boosting public toots instead of posting them as own 2023-03-19 20:27:48 +01:00
missytake 0504d3083f [mastodon] Fix load order of API routers 2023-03-19 19:20:09 +01:00
ogdbd3h5qze42igcv8wcrqk3 ec0abb5e24 [frontend] Add initial mastodon frontend 2023-03-19 18:36:15 +01:00
ogdbd3h5qze42igcv8wcrqk3 d897f369f7 [mastodon] Fix delete endpoint for mastodon 2023-03-19 18:36:15 +01:00
ogdbd3h5qze42igcv8wcrqk3 9c7607b7ca [frontend] Regenerate openapi for frontend due to mastodon API changes 2023-03-19 18:36:15 +01:00
missytake 0f4b25fcde [tests] Make Mastodon tests a bit more readable 2023-03-19 18:36:15 +01:00
missytake 4f96dfbee7 [mastodon] Deprecate GET mastodon route 2023-03-19 18:36:15 +01:00
missytake afd0894952 [mastodon] Return instance name in read_public API 2023-03-19 18:36:15 +01:00
missytake f0757619a7 [tests] Test mastodon_read route 2023-03-19 18:36:15 +01:00
missytake 90469a052a [tests] Replace single quotes with double quotes 2023-03-19 18:36:15 +01:00
missytake 16c325a9cb [tests] Test mastodon_delete route 2023-03-19 18:36:15 +01:00
missytake b49c4767a0 [mastodon] Remove redundant error class 2023-03-19 18:36:15 +01:00
missytake a61c48e99e [mastodon] Fix tests 2023-03-19 18:36:15 +01:00
ogdbd3h5qze42igcv8wcrqk3 dfd17aa27c [mastodon] Fix locking issue with synchronous Mastodon.py and replace last_seen with notification_dismiss 2023-03-19 18:36:15 +01:00
ogdbd3h5qze42igcv8wcrqk3 66fff6fd7d [frontend] Fix openapi-generator run for mastodon 2023-03-19 18:36:15 +01:00
missytake a548c2febc [tests] Testing the mastodon_create API endpoint 2023-03-19 18:36:15 +01:00
missytake f533efee4f [mastodon] Return 422 error for invalid input when creating mastodon bot 2023-03-19 18:36:15 +01:00
missytake cb88c24e2e [mastodon] Change mastodon_create to accept json instead of URL parameters 2023-03-19 18:36:15 +01:00
missytake 36638b1c64 [mastodon] New style: double quotes instead of single quotes 2023-03-19 18:36:15 +01:00
missytake 7fd716cecc [misc] Added pytest and pytest-aiohttp to test dependencies 2023-03-19 18:36:15 +01:00
missytake 3d482dd5f5 [mastodon] Generated openAPI routes for frontend 2023-03-19 18:36:15 +01:00
missytake fb1e88ab03 [mastodon] Moved mastodon module to new backend directory 2023-03-19 18:36:15 +01:00
missytake 5fa5a9f48e Revert "[doc] Document how to disable strict CORS checking"
This reverts commit bd17d5321b.
2023-03-19 18:36:15 +01:00
missytake 9704ed4ddf [mastodon] Working now: toot reports from mastodon, but only when the next report arrives 2023-03-19 18:36:15 +01:00
missytake 12935b79cb [mastodon] Load database references 2023-03-19 18:36:15 +01:00
missytake 37f7b98c67 [mastodon] Import mastodon API correctly 2023-03-19 18:36:12 +01:00
missytake d120d718f9 [mastodon] Added a TODO flair 2023-03-19 18:34:57 +01:00
missytake b1f8c08d25 [mastodon] Some web routes to add a Mastodon Account to Ticketfrei 2023-03-19 18:34:57 +01:00
missytake 4dc4b9cfca [mastodon] Changed MastodonAccount column: instance_id -> instance 2023-03-19 18:34:57 +01:00
missytake 07bc5a2686 [mastodon] First approach to a mastodon bot 2023-03-19 18:34:57 +01:00
missytake 3ae4a08ad5 [doc] Document how to disable strict CORS checking 2023-03-19 18:34:57 +01:00
Thomas Lindner 767c92000b [misc] Add some type annotations 2023-03-19 01:15:37 +01:00
Thomas Lindner 13c20ca245 [misc] Use double-quotes (black default) 2023-03-18 18:47:02 +01:00
Thomas Lindner fd09b381a6 [misc] Add some type annotations 2023-03-18 18:42:59 +01:00
Thomas Lindner 35eff0c416 [core] Don't read configs at the module top-level 2023-03-18 18:18:44 +01:00
ogdbd3h5qze42igcv8wcrqk3 003a10b273 [frontend] Fix openapi-generator script 2023-03-18 17:56:54 +01:00
ogdbd3h5qze42igcv8wcrqk3 dc454800cd [doc] Add documentation to update angular and openapi-generator code 2023-03-18 17:39:21 +01:00
Thomas Lindner 82129b958e [tests] Remove twitter tests 2023-03-18 16:49:01 +01:00
Thomas Lindner 20fdb1ae11 [tests] Prevent pytest from hanging 2023-03-18 16:49:01 +01:00
Thomas Lindner 4c110e2c71 [misc] Cleanup python packaging 2023-03-18 16:49:01 +01:00
Thomas Lindner fb543cffb9 [misc] Move things to the right place 2023-03-18 16:48:59 +01:00
ogdbd3h5qze42igcv8wcrqk3 aed5710da7 [frontend] Migrate to material 15 using mdc-migration 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 d1ffcf928d [frontend] Fix issue with ajv dependencies 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 005e4955ae [frontend] Version bump ngx-markdown to 15 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 76dcec3ae2 [frontend] Version bump angular/material to 15 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 038a4bf976 [frontend] Version bump angular/core and angular/cli to 15 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 401b4f32b1 [frontend] Version bump ngx-markdown to 14 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 8486ce8fab [frontend] Remove deprecated imports from angular/core 14 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 74fca41d66 [frontend] Version bump angular/material to 14 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 c9c1fe029b [frontend] Version bump angular/core and angular/cli to 14 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 cfb48cc0ff [frontend] Version bump ngx-markdown to 13 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 e3f5fbad44 [frontend] Remove deprecated Routes import from angular/core 13 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 b256aff872 [frontend] Version bump angular/material to 13 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 d9d75b76f2 [frontend] Version bump angular/core and angular/cli to 13 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 8ad6ce66fc [frontend] Version bump ngx-markdown to 12 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 5976db0cfc [frontend] Version bump angular/material to 12 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 4d5b1ee26e [frontend] Version bump angular/core and angular/cli to 12 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 ac00733847 [frontend] Version bump ngx-markdown to 11 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 c6c67463c9 [frontend] Version bump angular/material to 11 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 1824057e3e [frontend] Version bump angular/core and angular/cli to 11 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 6cb8a46f6c [frontend] Version bump angular/material to 10 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 5df8db657f [frontend] Version bump angular/core and angular/cli to 10 2023-03-18 16:25:20 +01:00
ogdbd3h5qze42igcv8wcrqk3 400f5af716 [frontend] Prerequisites for version bumps 2023-03-18 16:25:20 +01:00
Q d943e956ab [doc] Add missing dependencies 2023-03-18 14:34:42 +00:00
missytake 39a21fe34a [doc] No Gods, No Masters - let's do releases on development branch 2023-03-18 14:50:15 +01:00
missytake 4029770c90 [tests] Update python in tox from 3.8 to 3.10 2023-03-18 14:33:44 +01:00
missytake eef6f0c473 [doc] Document pytest dependencies and webUI development requirements 2023-03-18 12:51:17 +00:00
missytake 1d1eb47fe6 [tests] Fix tox ImportError 2023-03-18 12:51:17 +00:00
Thomas Lindner eaf187513e [frontend] Add favicon 2023-03-18 12:48:10 +01:00
401 changed files with 31241 additions and 16120 deletions

View file

@ -3,39 +3,39 @@
## Setup Development Environment ## Setup Development Environment
### General
1. Install `python>=3.10` and development packages
(`apt install python3-dev g++` on Ubuntu)
2. Run `./setup.sh`
### Backend ### Backend
1. Install `python>=3.8` 0. `cd backend`
2. Create a virtual environment with `python3 -m venv .venv` 1. Activate your dev environment with `source .venv/bin/activate`
3. Activate your dev environment with `source .venv/bin/activate` 2. Install with `pip install .`
4. Update pip packages with `pip install -U pip setuptools wheel` 3. Create a config file: `echo "production = 0" > kibicara.conf`
5. Install with `pip install .`
6. Install development dependencies with `pip install tox black`
7. Add git-hook to run test and stylecheck before commmit with
`ln -s ../../git-hooks/pre-commit .git/hooks/pre-commit`
8. Add git-hook to check commmit message format with
`ln -s ../../git-hooks/commit-msg .git/hooks/commit-msg`
9. Turn off production mode: `sudo su -c 'echo "production = 0" >> /etc/kibicara.conf'`
#### Cheatsheet #### Cheatsheet
- Install Kibicara with `pip install .` - Install Kibicara with `pip install .`
- Execute Kibicara with `kibicara` (verbose: `kibicara -vvv`) - Execute Kibicara with `kibicara -f kibicara.conf`
(verbose: `kibicara -vvv -f kibicara.conf`)
- Interact with Swagger REST-API Documentation: `http://localhost:8000/api/docs` - Interact with Swagger REST-API Documentation: `http://localhost:8000/api/docs`
- Test and stylecheck with `tox` - Test and stylecheck with `tox`
- Fix style issues with `black -S kibicara tests` - Fix style issues with `black -S src tests`
### Frontend ### Frontend
1. Install node.js (e.g. via 1. Install node.js (e.g. via
[nvm](https://github.com/nvm-sh/nvm#installation-and-update)) [nvm](https://github.com/nvm-sh/nvm#installation-and-update))
2. `cd kibicara-frontend` 2. `cd frontend`
3. Install the dependencies with `npm i` 3. Install the dependencies with `npm i`
4. Install Angular with `npm i -g @angular/cli` 4. Install Angular with `npm i @angular/cli`
5. Turn off production mode if you have not already (see above in backend). 5. Turn off production mode if you have not already (see above in backend).
6. Start the backend in a different terminal 6. Start the backend in a different terminal
7. To serve and open the application, run `ng s -o`. The application will open 7. To serve and open the application, run `node_modules/@angular/cli/bin/ng.js s -o`.
under [http://127.0.0.1:4200](http://127.0.0.1:4200). The application will open under [http://127.0.0.1:4200](http://127.0.0.1:4200).
### Creating an account ### Creating an account
@ -60,14 +60,9 @@ email address and register via frontend or manually at `http://localhost:8000/ap
## Contribution Guidelines ## Contribution Guidelines
### Branches ### Branches
- **Master:** The master branch tracks the last stable release.
- Releases will be done using release tags.
- Force push and pushes without group consent are disallowed.
- There never should be a merge commit from development into master!
- **Development:** The development branch is used to add new features. - **Development:** The development branch is used to add new features.
- Only rebase of feature branches is allowed. - Only rebase of feature branches is allowed.
- On Release the development branch will be rebased onto master and a release - On Release a release tag will be created
tag will be created on master
- **Feature-Branches:** - **Feature-Branches:**
- A feature branch will be used to develop a feature. - A feature branch will be used to develop a feature.
- It belongs to one developer only and force push is allowed. - It belongs to one developer only and force push is allowed.
@ -133,15 +128,29 @@ development team.
## How to implement a new Platform/Social Network ## How to implement a new Platform/Social Network
### tl;dr For transferring messages, Kibicara supports a range of platforms/social
networks, e.g. Mastodon, E-Mail, and Telegram - but more can be added easily.
This guide explains what you need to do to add another platform, e.g. Matrix or
XMPP.
1. Implement the following modules in `platforms/<your-platform>/`: ### Overview:
1. Implement the backend modules in `platforms/<your-platform>/`:
- `bot.py` - `bot.py`
- `model.py` - `model.py`
- `webapi.py` - `webapi.py`
2. Import your bot in `kibicara/webapi/__init__.py`. 2. Import your bot in `kibicara/webapi/__init__.py`.
3. Generate the FastAPI boilerplate code
4. Generate the angular boilerplate code
5. Copy-paste frontend components from other bots into the angular boilerplate
and adjust them to your needs
### Explanation At the bottom you can find a checklist what your pull request needs to be
merged into kibicara.
### Step by step
#### Implement the backend modules
In `kibicara/platforms/<your-platform>/bot.py`, you write the functions through In `kibicara/platforms/<your-platform>/bot.py`, you write the functions through
which the platform asks the social network for new messages, and publishes which the platform asks the social network for new messages, and publishes
@ -157,14 +166,90 @@ You will probably need to store the following things:
* platform-specific settings * platform-specific settings
* anything else your platform needs * anything else your platform needs
In `kibicara/platforms/<your-platform>/webapi.py`, you can define HTTP routes. In `kibicara/platforms/<your-platform>/webapi.py`, you can define REST API
You will need them to: routes. You will need them to:
* let admins authenticate to the social network in the kibicara web interface * let admins authenticate to the social network in the kibicara web interface
* update platform-specific settings * update platform-specific settings
#### Import your bot into the kibicara REST API
To run the platform, you need to import the bot in To run the platform, you need to import the bot in
`kibicara/webapi/__init__.py`. `kibicara/webapi/__init__.py`. You can see how the other platforms did it.
#### Generate the FastAPI boilerplate code
Whenever you changed the REST API in the backend, you need to re-generate the
FastAPI boilerplate code:
1. Start backend with `kibicara > /dev/null 2>&1 &`
2. Go to the frontend directory: `cd frontend`
3. Use this command to download the openapi.json from the backend and
generate the boilerplate: `npm run openapi-generator`
4. (Now you can stop the backend again, e.g. with `pkill kibicara`)
5. Append `/api` to all relevant URLs:
`find src/app/core/api/ -type f -exec sed -i "s#{this.configuration.basePath}#{this.configuration.basePath}/api#g" {} +`
6. Check if everything is okay (e.g. all api calls need the `/api` prefix)
#### Generate the Angular boilerplate code
##### Generate the platform-specific "public facing" page
Generate boilerplate "card" for myplatform:
```
ng generate component platforms/myplatform/myplatform-bot-card
```
Generate boilerplate for the popup that shows the users the guide on how to use
the bot:
```
ng generate component platforms/myplatform/myplatform-bot-card/myplatform-bot-info-dialog
```
##### Generate the platform-specific "settings" page
Generate bot card for settings page:
```
ng generate component platforms/myplatform/myplatform-settings
```
Generate popup that will show when you click on "Add" to add the myplatform
credentials (e.g. login data, api tokens etc):
```
ng generate component platforms/myplatform/myplatform-settings/mastodon-dialog
```
If something does not work, try to check `platforms.module.ts` and check if the
module was imported there. Every module needs to be imported there
#### Adjust the Angular code for your specific platform
Every frontend part for a bot has a similar structure. Basically copy the
content of the other files e.g. the telegram bot into the generated boilerplate
above and search and replace all occurrences with `myplatform`. You can see
the UI with `ng s -o`, it will auto-update on code change.
A component in angular has 3-4 files, only these ones ending with
`*.component.ts` (typescript) and `*.component.html`(html) are important for
us. Basically the typescript controls what is shown in the html. Please correct
every error that stops the angular page build or shows up on the page while you
go, otherwise this can become a mess.
With that in mind, first write the logic to call the /create endpoint:
- `src/app/platforms/myplatform/myplatform-settings/myplatform-dialog/myplatform-dialog.component.ts`:
implement the form to take the user inputs and the onSubmit() function
- `src/app/platforms/myplatform/myplatform-settings/myplatform-dialog/myplatform-dialog.component.html`:
implement the html skeleton that takes the form from the user
Then, fix up the public user facing page:
- `src/app/platforms/myplatform/myplatform-bot-card/myplatform-bot-info-dialog/myplatform-bot-info-dialog.component.html`
Finally, check the other typescript and html pages and adjust e.g. the tutorial
text for the users.
### Acceptance criteria for bots (Checklist) ### Acceptance criteria for bots (Checklist)
@ -202,3 +287,11 @@ A bot should have at least this functionality:
- e.g. Twitter via posts or retweets - e.g. Twitter via posts or retweets
- e.g. Telegram via direct message from the bot - e.g. Telegram via direct message from the bot
- e.g. E-Mail via e-mail to the user's address - e.g. E-Mail via e-mail to the user's address
- Web Interface (hood admins and users)
- A card which allows hood admins to add, configure, start, stop, and
delete a platform to their hood
- A pop-up which explains to hood admins how to configure the platform
- A card which allows users to subscribe on a platform or links to the
platform's account
- A pop-up which explains to users how to use the platform

View file

@ -1,4 +1,4 @@
Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de> Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
Copyright (C) 2020 by Christian Hagenest <c.hagenest@pm.me> Copyright (C) 2020 by Christian Hagenest <c.hagenest@pm.me>
Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>

5
backend/README.md Normal file
View file

@ -0,0 +1,5 @@
# Kibicara backend
Kibicara relays messages between different platforms (= social networks).
This is just the backend. For info about the whole project see [our git
repo](https://git.0x90.space/ticketfrei/ticketfrei3).

6
backend/pyproject.toml Normal file
View file

@ -0,0 +1,6 @@
[build-system]
requires = [
"setuptools>=42",
"wheel"
]
build-backend = "setuptools.build_meta"

73
backend/setup.cfg Normal file
View file

@ -0,0 +1,73 @@
[metadata]
name = kibicara
version = 0.1.0
author = 0x90.space
author_email = people@schleuder.0x90.space
description = distribute messages across different social media
long_description = file: README.md
long_description_content_type = text/markdown
url = https://git.0x90.space/ticketfrei/ticketfrei3
project_urls =
Bug Tracker = https://git.0x90.space/ticketfrei/ticketfrei3/issues
classifiers =
Programming Language :: Python :: 3
License :: Public Domain
[options]
package_dir =
= src
packages = find:
python_requires = >=3.10
install_requires =
aiofiles
aiogram
aiosqlite
argon2_cffi
fastapi
httpx
hypercorn
Mastodon.py
passlib
peony-twitter[all]
pydantic[email]
pynacl
python-multipart
pytoml
requests
tortoise-orm
[options.packages.find]
where = src
[options.entry_points]
console_scripts =
kibicara = kibicara.kibicara:Main
kibicara_mda = kibicara.platforms.email.mda:Main
migrate_from_ticketfrei2 = kibicara.migratefromticketfrei:Main
[tox:tox]
envlist = lint, py310
isolated_build = True
[testenv:lint]
skip_install = True
deps =
black
flake8
commands =
black --check --diff src tests
flake8 src tests
[testenv]
deps =
mypy
pytest
pytest-asyncio
types-requests
commands =
# not yet
#mypy --ignore-missing-imports src tests
pytest tests
[flake8]
max_line_length = 88

View file

@ -0,0 +1,23 @@
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
"""Default configuration.
The default configuration gets overwritten by a configuration file if one exists.
"""
config = {
"database_connection": "sqlite://:memory:",
"frontend_url": "http://localhost:4200", # url of frontend, change in prod
# production params
"frontend_path": None, # required, path to frontend html/css/js files
"production": True,
"behind_proxy": False,
"keyfile": None, # optional for ssl
"certfile": None, # optional for ssl
# dev params
"root_url": "http://localhost:8000", # url of backend
"cors_allow_origin": "http://localhost:4200",
}

View file

@ -15,7 +15,7 @@ from socket import getfqdn
logger = getLogger(__name__) logger = getLogger(__name__)
def send_email(to, subject, sender='kibicara', body=''): def send_email(to, subject, sender="kibicara", body=""):
"""E-Mail sender. """E-Mail sender.
Sends an E-Mail to a specified recipient with a body Sends an E-Mail to a specified recipient with a body
@ -33,10 +33,10 @@ def send_email(to, subject, sender='kibicara', body=''):
body (str): The body of the e-mail body (str): The body of the e-mail
""" """
msg = MIMEMultipart() msg = MIMEMultipart()
msg['From'] = 'Kibicara <{0}@{1}>'.format(sender, getfqdn()) msg["From"] = "Kibicara <{0}@{1}>".format(sender, getfqdn())
msg['To'] = to msg["To"] = to
msg['Subject'] = '[Kibicara] {0}'.format(subject) msg["Subject"] = "[Kibicara] {0}".format(subject)
msg.attach(MIMEText(body)) msg.attach(MIMEText(body))
with SMTP('localhost') as smtp: with SMTP("localhost") as smtp:
smtp.send_message(msg) smtp.send_message(msg)

View file

@ -0,0 +1,122 @@
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
"""Entrypoint of Kibicara."""
from argparse import ArgumentParser
from asyncio import run as asyncio_run
from logging import DEBUG, INFO, WARNING, basicConfig, getLogger
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from hypercorn.asyncio import serve
from hypercorn.config import Config
from pytoml import load
from tortoise import Tortoise
from kibicara.config import config
from kibicara.platformapi import Spawner
from kibicara.webapi import router
logger = getLogger(__name__)
class Main:
"""Entrypoint for Kibicara.
Initializes the platform bots and starts the hypercorn webserver serving the
Kibicara application and the specified frontend on port 8000.
"""
def __init__(self):
parser = ArgumentParser()
parser.add_argument(
"-f",
"--config",
dest="configfile",
default="/etc/kibicara.conf",
help="path to config file",
)
parser.add_argument(
"-v",
"--verbose",
action="count",
help="Raise verbosity level",
)
args = parser.parse_args()
try:
with open(args.configfile) as configfile:
config.update(load(configfile))
except FileNotFoundError:
# run with default config
pass
LOGLEVELS = {
None: WARNING,
1: INFO,
2: DEBUG,
}
basicConfig(
level=LOGLEVELS.get(args.verbose, DEBUG),
format="%(asctime)s %(name)s %(message)s",
)
getLogger("aiosqlite").setLevel(WARNING)
asyncio_run(self.__run())
async def __run(self):
await Tortoise.init(
db_url=config["database_connection"],
modules={
"models": [
"kibicara.model",
"kibicara.platforms.email.model",
"kibicara.platforms.mastodon.model",
"kibicara.platforms.telegram.model",
"kibicara.platforms.test.model",
"kibicara.platforms.twitter.model",
]
},
)
await Tortoise.generate_schemas()
await Spawner.init_all()
await self.__start_webserver()
await Tortoise.close_connections()
async def __start_webserver(self):
class SinglePageApplication(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
if response.status_code == 404:
response = await super().get_response(".", scope)
return response
app = FastAPI()
server_config = Config()
server_config.accesslog = "-"
server_config.behind_proxy = config["behind_proxy"]
server_config.keyfile = config["keyfile"]
server_config.certfile = config["certfile"]
if config["production"]:
server_config.bind = ["0.0.0.0:8000", "[::]:8000"]
api = FastAPI()
api.include_router(router)
app.mount("/api", api)
if not config["production"] and config["cors_allow_origin"]:
app.add_middleware(
CORSMiddleware,
allow_origins=config["cors_allow_origin"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
if config["frontend_path"] is not None:
app.mount(
"/",
app=SinglePageApplication(directory=config["frontend_path"], html=True),
)
await serve(app, server_config)

View file

@ -0,0 +1,225 @@
import sqlite3
import argparse
from time import time, sleep
from os import urandom
from tortoise import Tortoise
from tortoise.exceptions import DoesNotExist
import asyncio
from mastodon import Mastodon
from kibicara.model import Admin, Hood, IncludePattern, ExcludePattern
from kibicara.platforms.mastodon.model import MastodonInstance, MastodonAccount
from kibicara.platforms.telegram.model import Telegram, TelegramSubscriber
from kibicara.platforms.email.model import Email, EmailSubscriber
from kibicara.platforms.twitter.model import Twitter
class OldDataBase:
def __init__(self, old_database_path):
self.conn = sqlite3.connect(old_database_path)
self.cur = self.conn.cursor()
def execute(self, *args, **kwargs):
return self.cur.execute(*args, **kwargs)
def commit(self):
start_time = time()
while 1:
try:
self.conn.commit()
break
except sqlite3.OperationalError:
# another thread may be writing, give it a chance to finish
sleep(0.1)
if time() - start_time > 5:
# if it takes this long, something is wrong
raise
def close(self):
self.conn.close()
class Main:
def __init__(self):
parser = argparse.ArgumentParser()
parser.add_argument(
"old_database_path", help="path to the ticketfrei2 sqlite3 database"
)
parser.add_argument(
"new_database_path",
help="path to the ticketfrei3 sqlite3 database",
default="ticketfrei3.sqlite",
)
args = parser.parse_args()
# open old database
self.old_db = OldDataBase(args.old_database_path)
# open new database
asyncio.run(self.new_database(args.new_database_path))
async def new_database(self, new_database_path):
await Tortoise.init(
db_url=f"sqlite://{new_database_path}",
modules={
"models": [
"kibicara.model",
"kibicara.platforms.email.model",
"kibicara.platforms.mastodon.model",
"kibicara.platforms.telegram.model",
"kibicara.platforms.test.model",
"kibicara.platforms.twitter.model",
]
},
)
await Tortoise.generate_schemas()
# read table per table and write it to new database.
# mastodon instances
old_mastodon_instances = self.old_db.execute(
"SELECT * FROM mastodon_instances"
).fetchall()
for instance in old_mastodon_instances:
url = instance[1]
client_id = instance[2]
client_secret = instance[3]
await MastodonInstance.create(
name=url, client_id=client_id, client_secret=client_secret
)
print(f"Created Mastodon Instance: {url}")
old_users = self.old_db.execute("SELECT * FROM user;").fetchall()
for user in old_users:
user_id = user[0]
user_passhash = user[1]
user_enabled = user[2]
if user_enabled == 0:
print(f"skipping user {user_id}, inactive")
email = self.old_db.execute(
"SELECT email FROM email WHERE user_id=?", (user_id,)
).fetchone()[0]
try:
await Admin.get(email=email)
continue # skip if the user already exists.
except DoesNotExist:
admin = await Admin.create(email=email, passhash=user_passhash)
city = self.old_db.execute(
"SELECT * FROM cities WHERE user_id=?", (user_id,)
).fetchone()
city_name = city[2]
city_markdown = city[3]
hood = await Hood.create(name=city_name, landingpage=city_markdown)
await hood.admins.add(admin)
print()
print(
"Migrated user %s with email %s for the city %s "
"with the following accounts:" % (user_id, email, city_name)
)
patterns = self.old_db.execute(
"SELECT patterns FROM triggerpatterns WHERE user_id=?", (user_id,)
).fetchone()[0]
for pattern in patterns.splitlines():
await IncludePattern.create(hood=hood, pattern=pattern)
badwords = self.old_db.execute(
"SELECT words FROM badwords WHERE user_id=?", (user_id,)
).fetchone()[0]
for badword in badwords.splitlines():
await ExcludePattern.create(hood=hood, pattern=badword)
mastodon_account = self.old_db.execute(
"SELECT * FROM mastodon_accounts WHERE user_id=?", (user_id,)
).fetchone()
if mastodon_account:
instance_url = self.old_db.execute(
"SELECT instance FROM mastodon_instances WHERE id=?",
(mastodon_account[3],),
).fetchone()[0]
new_instance = await MastodonInstance.get(name=instance_url)
access_token = mastodon_account[2]
mastodon_enabled = mastodon_account[4]
await MastodonAccount.create(
hood=hood,
instance=new_instance,
access_token=access_token,
enabled=mastodon_enabled,
)
await dismiss_notifications(hood)
print(f"Mastodon: {instance_url}, {access_token}")
telegram_account = self.old_db.execute(
"SELECT apikey, active FROM telegram_accounts WHERE user_id=?",
(user_id,),
).fetchone()
if telegram_account[0] != "":
telegram_apikey = telegram_account[0]
telegram_enabled = telegram_account[1]
telegram = await Telegram.create(
hood=hood,
api_token=telegram_apikey,
enabled=telegram_enabled,
welcome_message="",
)
telegram_subscribers = self.old_db.execute(
"SELECT subscriber_id FROM telegram_subscribers WHERE user_id=?",
(user_id,),
).fetchall()
for subscriber in telegram_subscribers:
subscriber_id = subscriber[0]
await TelegramSubscriber.create(bot=telegram, user_id=subscriber_id)
print(f"Telegram: {len(telegram_subscribers)} subscribers")
mail_subscribers = self.old_db.execute(
"SELECT email FROM mailinglist WHERE user_id=?", (user_id,)
).fetchall()
if mail_subscribers is not []:
email_name = f"kibicara-{city_name}"
email = await Email.create(
hood=hood, name=email_name, secret=urandom(32).hex()
)
for subscriber in mail_subscribers:
subscriber_email = subscriber[0]
await EmailSubscriber.create(email=subscriber_email, hood=hood)
print(f"E-Mail: {len(mail_subscribers)} subscribers")
twitter = self.old_db.execute(
"SELECT * FROM twitter_accounts WHERE user_id=?", (user_id,)
).fetchone()
if twitter:
client_id = twitter[2]
client_secret = twitter[3]
seen_tweet = self.old_db.execute(
"SELECT tweet_id FROM seen_tweets WHERE user_id=?", (user_id,)
).fetchone()[0]
await Twitter.create(
hood=hood,
dms_since_id=1, # didn't work in ticketfrei2 anyway
mentions_since_id=seen_tweet,
access_token=client_id,
access_token_secret=client_secret,
username=" ",
verified=False,
enabled=True,
)
print(f"Twitter: last ID: {seen_tweet}")
await Tortoise.close_connections()
async def dismiss_notifications(hood):
model = await MastodonAccount.get(hood=hood)
await model.fetch_related("instance")
account = Mastodon(
client_id=model.instance.client_id,
client_secret=model.instance.client_secret,
api_base_url=model.instance.name,
access_token=model.access_token,
)
notifications = account.notifications()
for status in notifications:
account.notifications_dismiss(status["id"])
print(f"Dismissed {len(notifications)} notifications on Mastodon.")

View file

@ -0,0 +1,57 @@
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
"""ORM Models for core."""
from tortoise import fields
from tortoise.models import Model
class Admin(Model):
id = fields.IntField(pk=True)
email = fields.CharField(64, unique=True)
passhash = fields.TextField()
hoods: fields.ManyToManyRelation["Hood"] = fields.ManyToManyField(
"models.Hood", related_name="admins", through="admin_hood_relations"
)
class Meta:
table = "admins"
class Hood(Model):
id = fields.IntField(pk=True)
name = fields.CharField(64, unique=True)
landingpage = fields.TextField()
email_enabled = fields.BooleanField(default=True)
admins: fields.ManyToManyRelation[Admin]
include_patterns: fields.ReverseRelation["IncludePattern"]
exclude_patterns: fields.ReverseRelation["ExcludePattern"]
class Meta:
table = "hoods"
class IncludePattern(Model):
id = fields.IntField(pk=True)
hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
"models.Hood", related_name="include_patterns"
)
pattern = fields.TextField()
class Meta:
table = "include_patterns"
class ExcludePattern(Model):
id = fields.IntField(pk=True)
hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
"models.Hood", related_name="exclude_patterns"
)
pattern = fields.TextField()
class Meta:
table = "exclude_patterns"

View file

@ -1,4 +1,4 @@
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de> # Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# #
@ -6,12 +6,15 @@
"""API classes for implementing bots for platforms.""" """API classes for implementing bots for platforms."""
from asyncio import Queue, create_task from asyncio import Queue, Task, create_task
from enum import Enum, auto from enum import Enum, auto
from logging import getLogger from logging import getLogger
from re import IGNORECASE, search from re import IGNORECASE, search
from typing import Generic, Optional, Type, TypeVar
from kibicara.model import BadWord, Trigger from tortoise.models import Model
from kibicara.model import ExcludePattern, Hood, IncludePattern
logger = getLogger(__name__) logger = getLogger(__name__)
@ -29,7 +32,7 @@ class Message:
**kwargs (object, optional): Other platform-specific data. **kwargs (object, optional): Other platform-specific data.
""" """
def __init__(self, text, **kwargs): def __init__(self, text: str, **kwargs) -> None:
self.text = text self.text = text
self.__dict__.update(kwargs) self.__dict__.update(kwargs)
@ -71,30 +74,31 @@ class Censor:
hood (Hood): A Hood Model object hood (Hood): A Hood Model object
""" """
__instances = {} __instances: dict[int, list["Censor"]] = {}
def __init__(self, hood): def __init__(self, hood: Hood) -> None:
self.hood = hood self.hood = hood
self.enabled = True self.enabled = True
self._inbox = Queue() self._inbox: Queue[Message] = Queue()
self.__task = None self.__task: Optional[Task[None]] = None
self.__hood_censors = self.__instances.setdefault(hood.id, []) self.__hood_censors = self.__instances.setdefault(hood.id, [])
self.__hood_censors.append(self) self.__hood_censors.append(self)
self.status = BotStatus.INSTANTIATED self.status = BotStatus.INSTANTIATED
def start(self): def start(self) -> None:
"""Start the bot.""" """Start the bot."""
if self.__task is None: if self.__task is None:
self.__task = create_task(self.__run()) self.__task = create_task(self.__run())
def stop(self): def stop(self) -> None:
"""Stop the bot.""" """Stop the bot."""
if self.__task is not None: if self.__task is not None:
self.__task.cancel() self.__task.cancel()
async def __run(self): async def __run(self) -> None:
await self.hood.load() assert self.__task is not None
self.__task.set_name('{0} {1}'.format(self.__class__.__name__, self.hood.name)) await self.hood.refresh_from_db()
self.__task.set_name("{0} {1}".format(self.__class__.__name__, self.hood.name))
try: try:
self.status = BotStatus.RUNNING self.status = BotStatus.RUNNING
await self.run() await self.run()
@ -104,7 +108,7 @@ class Censor:
self.__task = None self.__task = None
self.status = BotStatus.STOPPED self.status = BotStatus.STOPPED
async def run(self): async def run(self) -> None:
"""Entry point for a bot. """Entry point for a bot.
Note: Override this in the derived bot class. Note: Override this in the derived bot class.
@ -112,14 +116,14 @@ class Censor:
pass pass
@classmethod @classmethod
async def destroy_hood(cls, hood): async def destroy_hood(cls, hood: Hood) -> None:
"""Remove all of its database entries. """Remove all of its database entries.
Note: Override this in the derived bot class. Note: Override this in the derived bot class.
""" """
pass pass
async def publish(self, message): async def publish(self, message: Message) -> bool:
"""Distribute a message to the bots in a hood. """Distribute a message to the bots in a hood.
Args: Args:
@ -132,33 +136,37 @@ class Censor:
await censor._inbox.put(message) await censor._inbox.put(message)
return True return True
async def receive(self): async def receive(self) -> Message:
"""Receive a message. """Receive a message.
Returns (Message): Received message Returns (Message): Received message
""" """
return await self._inbox.get() return await self._inbox.get()
async def __is_appropriate(self, message): async def __is_appropriate(self, message: Message) -> bool:
for badword in await BadWord.objects.filter(hood=self.hood).all(): for exclude in await ExcludePattern.filter(hood=self.hood):
if search(badword.pattern, message.text, IGNORECASE): if search(exclude.pattern, message.text, IGNORECASE):
logger.debug( logger.info(
'Matched bad word - dropped message: {0}'.format(message.text) "Matched bad word - dropped message: {0}".format(message.text)
) )
return False return False
for trigger in await Trigger.objects.filter(hood=self.hood).all(): for include in await IncludePattern.filter(hood=self.hood):
if search(trigger.pattern, message.text, IGNORECASE): if search(include.pattern, message.text, IGNORECASE):
logger.debug( logger.info(
'Matched trigger - passed message: {0}'.format(message.text) "Matched trigger - passed message: {0}".format(message.text)
) )
return True return True
logger.debug( logger.info(
'Did not match any trigger - dropped message: {0}'.format(message.text) "Did not match any trigger - dropped message: {0}".format(message.text)
) )
return False return False
class Spawner: ORMClass = TypeVar("ORMClass", bound=Model)
BotClass = TypeVar("BotClass", bound=Censor)
class Spawner(Generic[ORMClass, BotClass]):
"""Spawns a bot with a specific bot model. """Spawns a bot with a specific bot model.
Examples: Examples:
@ -177,22 +185,22 @@ class Spawner:
BotClass (Censor subclass): A Bot Class object BotClass (Censor subclass): A Bot Class object
""" """
__instances = [] __instances: list["Spawner"] = []
def __init__(self, ORMClass, BotClass): def __init__(self, orm_class: Type[ORMClass], bot_class: Type[BotClass]) -> None:
self.ORMClass = ORMClass self.ORMClass = orm_class
self.BotClass = BotClass self.BotClass = bot_class
self.__bots = {} self.__bots: dict[int, BotClass] = {}
self.__instances.append(self) self.__instances.append(self)
@classmethod @classmethod
async def init_all(cls): async def init_all(cls) -> None:
"""Instantiate and start a bot for every row in the corresponding ORM model.""" """Instantiate and start a bot for every row in the corresponding ORM model."""
for spawner in cls.__instances: for spawner in cls.__instances:
await spawner._init() await spawner._init()
@classmethod @classmethod
async def destroy_hood(cls, hood): async def destroy_hood(cls, hood: Hood) -> None:
for spawner in cls.__instances: for spawner in cls.__instances:
for pk in list(spawner.__bots): for pk in list(spawner.__bots):
bot = spawner.__bots[pk] bot = spawner.__bots[pk]
@ -201,16 +209,16 @@ class Spawner:
bot.stop() bot.stop()
await spawner.BotClass.destroy_hood(hood) await spawner.BotClass.destroy_hood(hood)
async def _init(self): async def _init(self) -> None:
for item in await self.ORMClass.objects.all(): async for item in self.ORMClass.all():
self.start(item) self.start(item)
def start(self, item): def start(self, item: ORMClass) -> None:
"""Instantiate and start a bot with the provided ORM object. """Instantiate and start a bot with the provided ORM object.
Example: Example:
``` ```
xyz = await XYZ.objects.create(hood=hood, **values.__dict__) xyz = await XYZ.create(hood=hood, **values.__dict__)
spawner.start(xyz) spawner.start(xyz)
``` ```
@ -221,7 +229,7 @@ class Spawner:
if bot.enabled: if bot.enabled:
bot.start() bot.start()
def stop(self, item): def stop(self, item: ORMClass) -> None:
"""Stop and delete a bot. """Stop and delete a bot.
Args: Args:
@ -231,10 +239,10 @@ class Spawner:
if bot is not None: if bot is not None:
bot.stop() bot.stop()
def get(self, item): def get(self, item: ORMClass) -> BotClass:
"""Get a running bot. """Get a running bot.
Args: Args:
item (ORM Model object): ORM object corresponding to bot. item (ORM Model object): ORM object corresponding to bot.
""" """
return self.__bots.get(item.pk) return self.__bots[item.pk]

View file

@ -1,6 +1,6 @@
# Copyright (C) 2020 by Maike <maike@systemli.org> # Copyright (C) 2020 by Maike <maike@systemli.org>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de> # Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
@ -12,7 +12,7 @@ from kibicara import email
from kibicara.config import config from kibicara.config import config
from kibicara.model import Hood from kibicara.model import Hood
from kibicara.platformapi import Censor, Spawner from kibicara.platformapi import Censor, Spawner
from kibicara.platforms.email.model import Email, EmailSubscribers from kibicara.platforms.email.model import Email, EmailSubscriber
from kibicara.webapi.admin import to_token from kibicara.webapi.admin import to_token
logger = getLogger(__name__) logger = getLogger(__name__)
@ -26,9 +26,9 @@ class EmailBot(Censor):
@classmethod @classmethod
async def destroy_hood(cls, hood): async def destroy_hood(cls, hood):
"""Removes all its database entries.""" """Removes all its database entries."""
for inbox in await Email.objects.filter(hood=hood).all(): for inbox in await Email.filter(hood=hood).all():
await inbox.delete() await inbox.delete()
for subscriber in await EmailSubscribers.objects.filter(hood=hood).all(): for subscriber in await EmailSubscriber.filter(hood=hood).all():
await subscriber.delete() await subscriber.delete()
async def run(self): async def run(self):
@ -36,28 +36,26 @@ class EmailBot(Censor):
while True: while True:
message = await self.receive() message = await self.receive()
logger.debug( logger.debug(
'Received message from censor ({0}): {1}'.format( "Received message from censor ({0}): {1}".format(
self.hood.name, message.text self.hood.name, message.text
) )
) )
for subscriber in await EmailSubscribers.objects.filter( for subscriber in await EmailSubscriber.filter(hood=self.hood).all():
hood=self.hood
).all():
token = to_token(email=subscriber.email, hood=self.hood.id) token = to_token(email=subscriber.email, hood=self.hood.id)
body = ( body = (
'{0}\n\n--\n' "{0}\n\n--\n"
+ 'If you want to stop receiving these mails,' + "If you want to stop receiving these mails,"
+ 'follow this link: {1}/hoods/{2}/email-unsubscribe?token={3}' + "follow this link: {1}/hoods/{2}/email-unsubscribe?token={3}"
).format(message.text, config['frontend_url'], self.hood.id, token) ).format(message.text, config["frontend_url"], self.hood.id, token)
try: try:
logger.debug('Trying to send: \n{0}'.format(body)) logger.debug("Trying to send: \n{0}".format(body))
email.send_email( email.send_email(
subscriber.email, subscriber.email,
'Kibicara {0}'.format(self.hood.name), "Kibicara {0}".format(self.hood.name),
body=body, body=body,
) )
except (ConnectionRefusedError, SMTPException): except (ConnectionRefusedError, SMTPException):
logger.exception('Sending email to subscriber failed.') logger.exception("Sending email to subscriber failed.")
spawner = Spawner(Hood, EmailBot) spawner = Spawner(Hood, EmailBot)

View file

@ -0,0 +1,108 @@
# Copyright (C) 2020 by Maike <maike@systemli.org>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
from argparse import ArgumentParser
from asyncio import run as asyncio_run
from email.parser import BytesParser
from email.policy import default
from email.utils import parseaddr
from logging import getLogger
from re import sub
from sys import stdin
from fastapi import status
from ormantic import NoMatch
from pytoml import load
from requests import post
from kibicara.config import config
from kibicara.platforms.email.model import Email, EmailSubscriber
logger = getLogger(__name__)
class Main:
def __init__(self):
parser = ArgumentParser()
parser.add_argument(
"-f",
"--config",
dest="configfile",
default="/etc/kibicara.conf",
help="path to config file",
)
# the MDA passes the recipient address as command line argument
parser.add_argument("recipient")
args = parser.parse_args()
try:
with open(args.configfile) as configfile:
config.update(load(configfile))
except FileNotFoundError:
# run with default config
pass
# extract email from the recipient
email_name = args.recipient.lower()
asyncio_run(self.__run(email_name))
async def __run(self, email_name):
try:
email = await Email.get(name=email_name)
except NoMatch:
logger.error("No recipient with this name")
exit(1)
# read mail from STDIN and parse to EmailMessage object
message = BytesParser(policy=default).parsebytes(stdin.buffer.read())
sender = ""
if message.get("sender"):
sender = message.get("sender")
elif message.get("from"):
sender = message.get("from")
else:
logger.error("No Sender of From header")
exit(1)
sender = parseaddr(sender)[1]
if not sender:
logger.error("Could not parse sender")
exit(1)
maybe_subscriber = await EmailSubscriber.filter(email=sender).all()
if len(maybe_subscriber) != 1 or maybe_subscriber[0].hood.id != email.hood.id:
logger.error("Not a subscriber")
exit(1)
# extract relevant data from mail
text = sub(
r"<[^>]*>",
"",
message.get_body(preferencelist=("plain", "html")).get_content(),
)
response = post(
"{0}/api/hoods/{1}/email/messages/".format(
config["root_url"], email.hood.pk
),
json={"text": text, "secret": email.secret},
)
if response.status_code == status.HTTP_201_CREATED:
exit(0)
elif response.status_code == status.HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS:
logger.error("Message was't accepted: {0}".format(text))
elif response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY:
logger.error("Malformed request: {0}".format(response.json()))
elif response.status_code == status.HTTP_401_UNAUTHORIZED:
logger.error("Wrong API secret. kibicara_mda seems to be misconfigured")
else:
logger.error(
"REST-API failed with response status {0}".format(response.status_code)
)
exit(1)

View file

@ -0,0 +1,38 @@
# Copyright (C) 2020 by Maike <maike@systemli.org>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
from tortoise import fields
from tortoise.models import Model
from kibicara.model import Hood
class Email(Model):
"""This table is used to track the names. It also stores the token secret."""
id = fields.IntField(pk=True)
hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
"models.Hood", related_name="platforms_email", unique=True
)
name = fields.CharField(32, unique=True)
secret = fields.TextField()
class Meta:
table = "platforms_email"
class EmailSubscriber(Model):
"""This table stores all subscribers, who want to receive messages via email."""
id = fields.IntField(pk=True)
hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
"models.Hood", related_name="platforms_email_subscribers"
)
email = fields.CharField(64, unique=True)
class Meta:
table = "platforms_email_subscribers"

View file

@ -1,6 +1,6 @@
# Copyright (C) 2020 by Maike <maike@systemli.org> # Copyright (C) 2020 by Maike <maike@systemli.org>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de> # Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
@ -8,18 +8,18 @@
from logging import getLogger from logging import getLogger
from os import urandom from os import urandom
from smtplib import SMTPException from smtplib import SMTPException
from sqlite3 import IntegrityError
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from nacl import exceptions from nacl import exceptions
from ormantic.exceptions import NoMatch
from pydantic import BaseModel, validator from pydantic import BaseModel, validator
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara import email from kibicara import email
from kibicara.config import config from kibicara.config import config
from kibicara.model import Hood
from kibicara.platformapi import Message from kibicara.platformapi import Message
from kibicara.platforms.email.bot import spawner from kibicara.platforms.email.bot import spawner
from kibicara.platforms.email.model import Email, EmailSubscribers from kibicara.platforms.email.model import Email, EmailSubscriber
from kibicara.webapi.admin import from_token, to_token from kibicara.webapi.admin import from_token, to_token
from kibicara.webapi.hoods import get_hood, get_hood_unauthorized from kibicara.webapi.hoods import get_hood, get_hood_unauthorized
@ -29,10 +29,10 @@ logger = getLogger(__name__)
class BodyEmail(BaseModel): class BodyEmail(BaseModel):
name: str name: str
@validator('name') @validator("name")
def valid_prefix(cls, value): def valid_prefix(cls, value):
if not value.startswith('kibicara-'): if not value.startswith("kibicara-"):
raise ValueError('Recipient address didn\'t start with kibicara-') raise ValueError("Recipient address didn't start with kibicara-")
return value return value
@ -53,7 +53,7 @@ class BodySubscriber(BaseModel):
email: str email: str
async def get_email(email_id: int, hood=Depends(get_hood)): async def get_email(email_id: int, hood: Hood = Depends(get_hood)):
"""Get Email row by hood. """Get Email row by hood.
You can specify an email_id to nail it down, but it works without as well. You can specify an email_id to nail it down, but it works without as well.
@ -62,16 +62,16 @@ async def get_email(email_id: int, hood=Depends(get_hood)):
:return: Email row of the found email bot. :return: Email row of the found email bot.
""" """
try: try:
return await Email.objects.get(id=email_id, hood=hood) return await Email.get(id=email_id, hood=hood)
except NoMatch: except DoesNotExist:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
async def get_subscriber(subscriber_id: int, hood=Depends(get_hood)): async def get_subscriber(subscriber_id: int, hood: Hood = Depends(get_hood)):
try: try:
return await EmailSubscribers.objects.get(id=subscriber_id, hood=hood) return await EmailSubscriber.get(id=subscriber_id, hood=hood)
except NoMatch: except DoesNotExist:
return HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
# registers the routes, gets imported in /kibicara/webapi/__init__.py # registers the routes, gets imported in /kibicara/webapi/__init__.py
@ -79,31 +79,31 @@ router = APIRouter()
@router.get( @router.get(
'/public', "/public",
# TODO response_model # TODO response_model
operation_id='get_emails_public', operation_id="get_emails_public",
) )
async def email_read_all_public(hood=Depends(get_hood_unauthorized)): async def email_read_all_public(hood: Hood = Depends(get_hood_unauthorized)):
if hood.email_enabled: if hood.email_enabled:
emails = await Email.objects.filter(hood=hood).all() emails = await Email.filter(hood=hood)
return [BodyEmailPublic(name=email.name) for email in emails] return [BodyEmailPublic(name=email.name) for email in emails]
return [] return []
@router.get( @router.get(
'/', "/",
# TODO response_model # TODO response_model
operation_id='get_emails', operation_id="get_emails",
) )
async def email_read_all(hood=Depends(get_hood)): async def email_read_all(hood: Hood = Depends(get_hood)):
return await Email.objects.filter(hood=hood).select_related('hood').all() return await Email.filter(hood=hood)
@router.post( @router.post(
'/', "/",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
# TODO response_model # TODO response_model
operation_id='create_email', operation_id="create_email",
) )
async def email_create(values: BodyEmail, response: Response, hood=Depends(get_hood)): async def email_create(values: BodyEmail, response: Response, hood=Depends(get_hood)):
"""Create an Email bot. Call this when creating a hood. """Create an Email bot. Call this when creating a hood.
@ -112,30 +112,30 @@ async def email_create(values: BodyEmail, response: Response, hood=Depends(get_h
:return: Email row of the new email bot. :return: Email row of the new email bot.
""" """
try: try:
email = await Email.objects.create( email = await Email.create(
hood=hood, secret=urandom(32).hex(), **values.__dict__ hood=hood, secret=urandom(32).hex(), **values.__dict__
) )
response.headers['Location'] = str(hood.id) response.headers["Location"] = str(hood.id)
return email return email
except IntegrityError: except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
@router.get( @router.get(
'/status', "/status",
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
# TODO response_model # TODO response_model
operation_id='status_email', operation_id="status_email",
) )
async def email_status(hood=Depends(get_hood)): async def email_status(hood=Depends(get_hood)):
return {'status': spawner.get(hood).status.name} return {"status": spawner.get(hood).status.name}
@router.post( @router.post(
'/start', "/start",
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
# TODO response_model # TODO response_model
operation_id='start_email', operation_id="start_email",
) )
async def email_start(hood=Depends(get_hood)): async def email_start(hood=Depends(get_hood)):
await hood.update(email_enabled=True) await hood.update(email_enabled=True)
@ -144,10 +144,10 @@ async def email_start(hood=Depends(get_hood)):
@router.post( @router.post(
'/stop', "/stop",
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
# TODO response_model # TODO response_model
operation_id='stop_email', operation_id="stop_email",
) )
async def email_stop(hood=Depends(get_hood)): async def email_stop(hood=Depends(get_hood)):
await hood.update(email_enabled=False) await hood.update(email_enabled=False)
@ -156,16 +156,16 @@ async def email_stop(hood=Depends(get_hood)):
@router.get( @router.get(
'/{email_id}', "/{email_id}",
# TODO response_model # TODO response_model
operation_id='get_email', operation_id="get_email",
) )
async def email_read(email=Depends(get_email)): async def email_read(email=Depends(get_email)):
return email return email
@router.delete( @router.delete(
'/{email_id}', status_code=status.HTTP_204_NO_CONTENT, operation_id='delete_email' "/{email_id}", status_code=status.HTTP_204_NO_CONTENT, operation_id="delete_email"
) )
async def email_delete(email=Depends(get_email)): async def email_delete(email=Depends(get_email)):
"""Delete an Email bot. """Delete an Email bot.
@ -179,9 +179,9 @@ async def email_delete(email=Depends(get_email)):
@router.post( @router.post(
'/subscribe/', "/subscribe/",
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
operation_id='subscribe', operation_id="subscribe",
response_model=BaseModel, response_model=BaseModel,
) )
async def email_subscribe( async def email_subscribe(
@ -194,37 +194,37 @@ async def email_subscribe(
:return: Returns status code 200 after sending confirmation email. :return: Returns status code 200 after sending confirmation email.
""" """
token = to_token(hood=hood.id, email=subscriber.email) token = to_token(hood=hood.id, email=subscriber.email)
confirm_link = '{0}/hoods/{1}/email-confirm?token={2}'.format( confirm_link = "{0}/hoods/{1}/email-confirm?token={2}".format(
config['frontend_url'], config["frontend_url"],
hood.id, hood.id,
token, token,
) )
try: try:
subs = await EmailSubscribers.objects.filter(email=subscriber.email).all() subs = await EmailSubscriber.filter(email=subscriber.email).all()
if subs: if subs:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
email.send_email( email.send_email(
subscriber.email, subscriber.email,
'Subscribe to Kibicara {0}'.format(hood.name), "Subscribe to Kibicara {0}".format(hood.name),
body='To confirm your subscription, follow this link: {0}'.format( body="To confirm your subscription, follow this link: {0}".format(
confirm_link confirm_link
), ),
) )
return {} return {}
except ConnectionRefusedError: except ConnectionRefusedError:
logger.info(token) logger.info(token)
logger.error('Sending subscription confirmation email failed.', exc_info=True) logger.error("Sending subscription confirmation email failed.", exc_info=True)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY) raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY)
except SMTPException: except SMTPException:
logger.info(token) logger.info(token)
logger.error('Sending subscription confirmation email failed.', exc_info=True) logger.error("Sending subscription confirmation email failed.", exc_info=True)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY) raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY)
@router.post( @router.post(
'/subscribe/confirm/{token}', "/subscribe/confirm/{token}",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
operation_id='confirm_subscriber', operation_id="confirm_subscriber",
response_model=BaseModel, response_model=BaseModel,
) )
async def email_subscribe_confirm(token, hood=Depends(get_hood_unauthorized)): async def email_subscribe_confirm(token, hood=Depends(get_hood_unauthorized)):
@ -236,19 +236,19 @@ async def email_subscribe_confirm(token, hood=Depends(get_hood_unauthorized)):
""" """
payload = from_token(token) payload = from_token(token)
# If token.hood and url.hood are different, raise an error: # If token.hood and url.hood are different, raise an error:
if hood.id is not payload['hood']: if hood.id is not payload["hood"]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
try: try:
await EmailSubscribers.objects.create(hood=hood.id, email=payload['email']) await EmailSubscriber.create(hood=hood, email=payload["email"])
return {} return {}
except IntegrityError: except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
@router.delete( @router.delete(
'/unsubscribe/{token}', "/unsubscribe/{token}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
operation_id='unsubscribe', operation_id="unsubscribe",
) )
async def email_unsubscribe(token, hood=Depends(get_hood_unauthorized)): async def email_unsubscribe(token, hood=Depends(get_hood_unauthorized)):
"""Remove a subscriber from the database when they click on an unsubscribe link. """Remove a subscriber from the database when they click on an unsubscribe link.
@ -257,45 +257,45 @@ async def email_unsubscribe(token, hood=Depends(get_hood_unauthorized)):
:param hood: Hood the Email bot belongs to. :param hood: Hood the Email bot belongs to.
""" """
try: try:
logger.warning('token is: {0}'.format(token)) logger.warning("token is: {0}".format(token))
payload = from_token(token) payload = from_token(token)
# If token.hood and url.hood are different, raise an error: # If token.hood and url.hood are different, raise an error:
if hood.id is not payload['hood']: if hood.id is not payload["hood"]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
subscriber = await EmailSubscribers.objects.filter( subscriber = await EmailSubscriber.filter(
hood=payload['hood'], email=payload['email'] hood=payload["hood"], email=payload["email"]
).get() ).get()
await subscriber.delete() await subscriber.delete()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
except NoMatch: except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
except exceptions.CryptoError: except exceptions.CryptoError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
@router.get( @router.get(
'/subscribers/', "/subscribers/",
# TODO response_model # TODO response_model
operation_id='get_subscribers', operation_id="get_subscribers",
) )
async def subscribers_read_all(hood=Depends(get_hood)): async def subscribers_read_all(hood=Depends(get_hood)):
return await EmailSubscribers.objects.filter(hood=hood).all() return await EmailSubscriber.filter(hood=hood).all()
@router.get( @router.get(
'/subscribers/{subscriber_id}', "/subscribers/{subscriber_id}",
# TODO response_model # TODO response_model
operation_id='get_subscriber', operation_id="get_subscriber",
) )
async def subscribers_read(subscriber=Depends(get_subscriber)): async def subscribers_read(subscriber=Depends(get_subscriber)):
return subscriber return subscriber
@router.post( @router.post(
'/messages/', "/messages/",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
# TODO response_model # TODO response_model
operation_id='send_message', operation_id="send_message",
) )
async def email_message_create( async def email_message_create(
message: BodyMessage, hood=Depends(get_hood_unauthorized) message: BodyMessage, hood=Depends(get_hood_unauthorized)
@ -306,18 +306,18 @@ async def email_message_create(
:param hood: Hood the Email bot belongs to. :param hood: Hood the Email bot belongs to.
:return: returns status code 201 if the message is accepted by the censor. :return: returns status code 201 if the message is accepted by the censor.
""" """
for receiver in await Email.objects.filter(hood=hood).all(): for receiver in await Email.filter(hood=hood).all():
if message.secret == receiver.secret: if message.secret == receiver.secret:
# pass message.text to bot.py # pass message.text to bot.py
if await spawner.get(hood).publish(Message(message.text)): if await spawner.get(hood).publish(Message(message.text)):
logger.warning('Message was accepted: {0}'.format(message.text)) logger.warning("Message was accepted: {0}".format(message.text))
return {} return {}
else: else:
logger.warning('Message wasn\'t accepted: {0}'.format(message.text)) logger.warning("Message wasn't accepted: {0}".format(message.text))
raise HTTPException( raise HTTPException(
status_code=status.HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS status_code=status.HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS
) )
logger.warning( logger.warning(
'Someone is trying to submit an email without the correct API secret' "Someone is trying to submit an email without the correct API secret"
) )
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)

View file

@ -0,0 +1,101 @@
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
from asyncio import gather, get_event_loop, sleep
from logging import getLogger
import re
from mastodon import Mastodon, MastodonError
from kibicara.platformapi import Censor, Spawner, Message
from kibicara.platforms.mastodon.model import MastodonAccount
logger = getLogger(__name__)
class MastodonBot(Censor):
def __init__(self, mastodon_account_model):
super().__init__(mastodon_account_model.hood)
self.model = mastodon_account_model
self.enabled = self.model.enabled
self.polling_interval_sec = 60
@classmethod
async def destroy_hood(cls, hood):
"""Removes all its database entries."""
for mastodon in await MastodonAccount.filter(hood=hood).all():
await mastodon.delete()
async def run(self):
try:
await self.model.fetch_related("instance")
self.account = Mastodon(
client_id=self.model.instance.client_id,
client_secret=self.model.instance.client_secret,
api_base_url=self.model.instance.name,
access_token=self.model.access_token,
)
account_details = await get_event_loop().run_in_executor(
None, self.account.account_verify_credentials
)
if username := account_details.get("username"):
await self.model.update(username=username)
await gather(self.poll(), self.push())
except Exception as e:
logger.debug("Bot {0} threw an Error: {1}".format(self.model.hood.name, e))
finally:
logger.debug("Bot {0} stopped.".format(self.model.hood.name))
async def poll(self):
"""Get new mentions and DMs from Mastodon"""
while True:
try:
notifications = await get_event_loop().run_in_executor(
None, self.account.notifications
)
except MastodonError as e:
logger.warning("%s in hood %s" % (e, self.model.hood.name))
continue
for status in notifications:
try:
status_id = int(status["status"]["id"])
except KeyError:
self.account.notifications_dismiss(status["id"])
continue # ignore notifications which don't have a status
text = re.sub(r"<[^>]*>", "", status["status"]["content"])
text = re.sub(
"(?<=^|(?<=[^a-zA-Z0-9-_.]))@([A-Za-z]+[A-Za-z0-9-_]+)", "", text
)
logger.debug(
"Mastodon in %s received toot #%s: %s"
% (self.model.hood.name, status_id, text)
)
if status["status"]["visibility"] == "public":
await self.publish(Message(text, toot_id=status_id))
else:
await self.publish(Message(text))
await get_event_loop().run_in_executor(
None, self.account.notifications_dismiss, status["id"]
)
await sleep(self.polling_interval_sec)
async def push(self):
"""Push new Ticketfrei reports to Mastodon; if source is mastodon, boost it."""
while True:
message = await self.receive()
if hasattr(message, "toot_id"):
logger.debug("Boosting post %s: %s" % (message.toot_id, message.text))
await get_event_loop().run_in_executor(
None, self.account.status_reblog, message.toot_id
)
else:
logger.debug("Posting message: %s" % (message.text,))
await get_event_loop().run_in_executor(
None, self.account.status_post, message.text
)
spawner = Spawner(MastodonAccount, MastodonBot)

View file

@ -0,0 +1,36 @@
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
from tortoise import fields
from tortoise.models import Model
from kibicara.model import Hood
class MastodonInstance(Model):
id = fields.IntField(pk=True)
name = fields.TextField()
client_id = fields.TextField()
client_secret = fields.TextField()
accounts: fields.ReverseRelation["MastodonAccount"]
class Meta:
table = "platforms_mastodon_instances"
class MastodonAccount(Model):
id = fields.IntField(pk=True)
hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
"models.Hood", related_name="platforms_mastodon"
)
instance: fields.ForeignKeyRelation[MastodonInstance] = fields.ForeignKeyField(
"models.MastodonInstance", related_name="accounts"
)
access_token = fields.TextField()
username = fields.TextField(null=True)
enabled = fields.BooleanField()
class Meta:
table = "platforms_mastodon_accounts"

View file

@ -0,0 +1,185 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
#
# SPDX-License-Identifier: 0BSD
from asyncio import get_event_loop
from logging import getLogger
from fastapi import APIRouter, Depends, HTTPException, Response, status
from mastodon import Mastodon, MastodonNetworkError
from mastodon.errors import MastodonIllegalArgumentError
from pydantic import BaseModel, validate_email, validator
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara.config import config
from kibicara.model import Hood
from kibicara.platforms.mastodon.bot import spawner
from kibicara.platforms.mastodon.model import MastodonAccount, MastodonInstance
from kibicara.webapi.hoods import get_hood, get_hood_unauthorized
logger = getLogger(__name__)
class BodyMastodonPublic(BaseModel):
username: str
instance: str
class BodyMastodonAccount(BaseModel):
email: str
instance_url: str
password: str
@validator("email")
def validate_email(cls, value):
return validate_email(value)
async def get_mastodon(
mastodon_id: int, hood: Hood = Depends(get_hood)
) -> MastodonAccount:
try:
return await MastodonAccount.get(id=mastodon_id, hood=hood)
except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
async def get_mastodon_instance(instance_url: str) -> MastodonInstance:
"""Return a MastodonInstance ORM object with valid client_id and client_secret.
:param: instance_url: the API base URL of the mastodon server
:return the MastodonInstance ORM object
"""
try:
return await MastodonInstance.get(name=instance_url)
except DoesNotExist:
app_name = config.get("frontend_url")
client_id, client_secret = Mastodon.create_app(
app_name, api_base_url=instance_url
)
await MastodonInstance.create(
name=instance_url, client_id=client_id, client_secret=client_secret
)
return await MastodonInstance.get(name=instance_url)
router = APIRouter()
twitter_callback_router = APIRouter()
@router.get(
"/public",
# TODO response_model,
operation_id="get_mastodons_public",
)
async def mastodon_read_all_public(hood=Depends(get_hood_unauthorized)):
mbots = []
async for mbot in MastodonAccount.filter(hood=hood).prefetch_related("instance"):
if mbot.enabled == 1 and mbot.username:
mbots.append(
BodyMastodonPublic(username=mbot.username, instance=mbot.instance.name)
)
return mbots
@router.get(
"/",
# TODO response_model,
operation_id="get_mastodons",
)
async def mastodon_read_all(hood=Depends(get_hood)):
return await MastodonAccount.filter(hood=hood).all()
@router.delete(
"/{mastodon_id}",
status_code=status.HTTP_204_NO_CONTENT,
# TODO response_model
operation_id="delete_mastodon",
)
async def mastodon_delete(mastodon=Depends(get_mastodon)):
spawner.stop(mastodon)
await mastodon.fetch_related("instance")
object_with_instance = await MastodonAccount.filter(
instance=mastodon.instance
).all()
if len(object_with_instance) == 1 and object_with_instance[0] == mastodon:
await mastodon.instance.delete()
await mastodon.delete()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get(
"/{mastodon_id}/status",
status_code=status.HTTP_200_OK,
# TODO response_model
operation_id="status_mastodon",
)
async def mastodon_status(mastodon=Depends(get_mastodon)):
return {"status": spawner.get(mastodon).status.name}
@router.post(
"/{mastodon_id}/start",
status_code=status.HTTP_200_OK,
# TODO response_model
operation_id="start_mastodon",
)
async def mastodon_start(mastodon=Depends(get_mastodon)):
await mastodon.update(enabled=True)
spawner.get(mastodon).start()
return {}
@router.post(
"/{mastodon_id}/stop",
status_code=status.HTTP_200_OK,
# TODO response_model
operation_id="stop_mastodon",
)
async def mastodon_stop(mastodon=Depends(get_mastodon)):
await mastodon.update(enabled=False)
spawner.get(mastodon).stop()
return {}
@router.post(
"/",
status_code=status.HTTP_201_CREATED,
# TODO response_model
operation_id="create_mastodon",
)
async def mastodon_create(values: BodyMastodonAccount, hood=Depends(get_hood)):
"""Add a Mastodon Account to a Ticketfrei account.
open questions:
can the instance_url have different ways of writing?
:param: values: a BodyMastodonAccount object in json
:param: hood: the hood ORM object
"""
try:
instance = await get_mastodon_instance(values.instance_url)
except MastodonNetworkError:
raise HTTPException(422, "Invalid Mastodon Instance")
account = Mastodon(
instance.client_id, instance.client_secret, api_base_url=values.instance_url
)
try:
access_token = await get_event_loop().run_in_executor(
None, account.log_in, values.email, values.password
)
logger.debug(f"{access_token}")
mastodon = await MastodonAccount.create(
hood=hood, instance=instance, access_token=access_token, enabled=True
)
spawner.start(mastodon)
return mastodon
except MastodonIllegalArgumentError:
logger.warning("Login to Mastodon failed.", exc_info=True)
raise HTTPException(status_code=status.HTTP_422_INVALID_INPUT)
except IntegrityError:
logger.warning("Login to Mastodon failed.", exc_info=True)
raise HTTPException(status_code=status.HTTP_409_CONFLICT)

View file

@ -1,17 +1,17 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from asyncio import CancelledError, gather, sleep from asyncio import CancelledError, gather, sleep
from logging import getLogger from logging import getLogger
from sqlite3 import IntegrityError
from aiogram import Bot, Dispatcher, exceptions, types from aiogram import Bot, Dispatcher, exceptions, types
from ormantic.exceptions import NoMatch from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara.platformapi import Censor, Message, Spawner from kibicara.platformapi import Censor, Message, Spawner
from kibicara.platforms.telegram.model import Telegram, TelegramUser from kibicara.platforms.telegram.model import Telegram, TelegramSubscriber
logger = getLogger(__name__) logger = getLogger(__name__)
@ -25,16 +25,16 @@ class TelegramBot(Censor):
@classmethod @classmethod
async def destroy_hood(cls, hood): async def destroy_hood(cls, hood):
"""Removes all its database entries.""" """Removes all its database entries."""
for telegram in await Telegram.objects.filter(hood=hood).all(): for telegram in await Telegram.filter(hood=hood).all():
for user in await TelegramUser.objects.filter(bot=telegram).all(): for user in await TelegramSubscriber.filter(bot=telegram).all():
await user.delete() await user.delete()
await telegram.delete() await telegram.delete()
def _create_dispatcher(self): def _create_dispatcher(self):
dp = Dispatcher(self.bot) dp = Dispatcher(self.bot)
dp.register_message_handler(self._send_welcome, commands=['start']) dp.register_message_handler(self._send_welcome, commands=["start"])
dp.register_message_handler(self._remove_user, commands=['stop']) dp.register_message_handler(self._remove_user, commands=["stop"])
dp.register_message_handler(self._send_help, commands=['help']) dp.register_message_handler(self._send_help, commands=["help"])
dp.register_message_handler(self._receive_message) dp.register_message_handler(self._receive_message)
return dp return dp
@ -42,36 +42,34 @@ class TelegramBot(Censor):
try: try:
self.bot = Bot(token=self.telegram_model.api_token) self.bot = Bot(token=self.telegram_model.api_token)
self.dp = self._create_dispatcher() self.dp = self._create_dispatcher()
logger.debug('Bot {0} starting.'.format(self.telegram_model.hood.name)) logger.debug("Bot {0} starting.".format(self.telegram_model.hood.name))
user = await self.bot.get_me() user = await self.bot.get_me()
if user.username: if user.username:
await self.telegram_model.update(username=user.username) await self.telegram_model.update(username=user.username)
await gather(self.dp.start_polling(), self._push()) await gather(self.dp.start_polling(), self._push())
except CancelledError: except CancelledError:
logger.debug( logger.debug(
'Bot {0} received Cancellation.'.format(self.telegram_model.hood.name) "Bot {0} received Cancellation.".format(self.telegram_model.hood.name)
) )
self.dp = None self.dp = None
raise raise
except exceptions.ValidationError: except exceptions.ValidationError:
logger.debug( logger.debug(
'Bot {0} has invalid auth token.'.format(self.telegram_model.hood.name) "Bot {0} has invalid auth token.".format(self.telegram_model.hood.name)
) )
await self.telegram_model.update(enabled=False) await self.telegram_model.update(enabled=False)
finally: finally:
logger.debug('Bot {0} stopped.'.format(self.telegram_model.hood.name)) logger.debug("Bot {0} stopped.".format(self.telegram_model.hood.name))
async def _push(self): async def _push(self):
while True: while True:
message = await self.receive() message = await self.receive()
logger.debug( logger.debug(
'Received message from censor ({0}): {1}'.format( "Received message from censor ({0}): {1}".format(
self.telegram_model.hood.name, message.text self.telegram_model.hood.name, message.text
) )
) )
for user in await TelegramUser.objects.filter( for user in await TelegramSubscriber.filter(bot=self.telegram_model).all():
bot=self.telegram_model
).all():
await self._send_message(user.user_id, message.text) await self._send_message(user.user_id, message.text)
async def _send_message(self, user_id, message): async def _send_message(self, user_id, message):
@ -79,34 +77,34 @@ class TelegramBot(Censor):
await self.bot.send_message(user_id, message, disable_notification=False) await self.bot.send_message(user_id, message, disable_notification=False)
except exceptions.BotBlocked: except exceptions.BotBlocked:
logger.error( logger.error(
'Target [ID:{0}] ({1}): blocked by user'.format( "Target [ID:{0}] ({1}): blocked by user".format(
user_id, self.telegram_model.hood.name user_id, self.telegram_model.hood.name
) )
) )
except exceptions.ChatNotFound: except exceptions.ChatNotFound:
logger.error( logger.error(
'Target [ID:{0}] ({1}): invalid user ID'.format( "Target [ID:{0}] ({1}): invalid user ID".format(
user_id, self.telegram_model.hood.name user_id, self.telegram_model.hood.name
) )
) )
except exceptions.RetryAfter as e: except exceptions.RetryAfter as e:
logger.error( logger.error(
'Target [ID:{0}] ({1}): Flood limit is exceeded.'.format( "Target [ID:{0}] ({1}): Flood limit is exceeded.".format(
user_id, self.telegram_model.hood.name user_id, self.telegram_model.hood.name
) )
+ 'Sleep {0} seconds.'.format(e.timeout) + "Sleep {0} seconds.".format(e.timeout)
) )
await sleep(e.timeout) await sleep(e.timeout)
return await self._send_message(user_id, message) return await self._send_message(user_id, message)
except exceptions.UserDeactivated: except exceptions.UserDeactivated:
logger.error( logger.error(
'Target [ID:{0}] ({1}): user is deactivated'.format( "Target [ID:{0}] ({1}): user is deactivated".format(
user_id, self.telegram_model.hood.name user_id, self.telegram_model.hood.name
) )
) )
except exceptions.TelegramAPIError: except exceptions.TelegramAPIError:
logger.exception( logger.exception(
'Target [ID:{0}] ({1}): failed'.format( "Target [ID:{0}] ({1}): failed".format(
user_id, self.telegram_model.hood.name user_id, self.telegram_model.hood.name
) )
) )
@ -114,34 +112,34 @@ class TelegramBot(Censor):
async def _send_welcome(self, message: types.Message): async def _send_welcome(self, message: types.Message):
try: try:
if message.from_user.is_bot: if message.from_user.is_bot:
await message.reply('Error: Bots can not join here.') await message.reply("Error: Bots can not join here.")
return return
await TelegramUser.objects.create( await TelegramSubscriber.create(
user_id=message.from_user.id, bot=self.telegram_model user_id=message.from_user.id, bot=self.telegram_model
) )
await message.reply(self.telegram_model.welcome_message) await message.reply(self.telegram_model.welcome_message)
except IntegrityError: except IntegrityError:
await message.reply('Error: You are already registered.') await message.reply("Error: You are already registered.")
async def _remove_user(self, message: types.Message): async def _remove_user(self, message: types.Message):
try: try:
telegram_user = await TelegramUser.objects.get( telegram_user = await TelegramSubscriber.get(
user_id=message.from_user.id, bot=self.telegram_model user_id=message.from_user.id, bot=self.telegram_model
) )
await telegram_user.delete() await telegram_user.delete()
await message.reply('You were removed successfully from this bot.') await message.reply("You were removed successfully from this bot.")
except NoMatch: except DoesNotExist:
await message.reply('Error: You are not subscribed to this bot.') await message.reply("Error: You are not subscribed to this bot.")
async def _send_help(self, message: types.Message): async def _send_help(self, message: types.Message):
if message.from_user.is_bot: if message.from_user.is_bot:
await message.reply('Error: Bots can\'t be helped.') await message.reply("Error: Bots can't be helped.")
return return
await message.reply('Send messages here to broadcast them to your hood') await message.reply("Send messages here to broadcast them to your hood")
async def _receive_message(self, message: types.Message): async def _receive_message(self, message: types.Message):
if not message.text: if not message.text:
await message.reply('Error: Only text messages are allowed.') await message.reply("Error: Only text messages are allowed.")
return return
await self.publish(Message(message.text)) await self.publish(Message(message.text))

View file

@ -0,0 +1,36 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
#
# SPDX-License-Identifier: 0BSD
from tortoise import fields
from tortoise.models import Model
from kibicara.model import Hood
class Telegram(Model):
id = fields.IntField(pk=True)
hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
"models.Hood", related_name="platforms_telegram"
)
api_token = fields.CharField(64, unique=True)
welcome_message = fields.TextField()
username = fields.TextField(null=True)
enabled = fields.BooleanField(default=True)
subscribers: fields.ReverseRelation["TelegramSubscriber"]
class Meta:
table = "platforms_telegram"
class TelegramSubscriber(Model):
id = fields.IntField(pk=True)
bot: fields.ForeignKeyRelation[Telegram] = fields.ForeignKeyField(
"models.Telegram", related_name="subscribers"
)
user_id = fields.IntField()
class Meta:
table = "platforms_telegram_subscribers"

View file

@ -1,19 +1,19 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from logging import getLogger from logging import getLogger
from sqlite3 import IntegrityError
from aiogram import exceptions from aiogram import exceptions
from aiogram.bot.api import check_token from aiogram.bot.api import check_token
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from ormantic.exceptions import NoMatch
from pydantic import BaseModel, validator from pydantic import BaseModel, validator
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara.platforms.telegram.bot import spawner from kibicara.platforms.telegram.bot import spawner
from kibicara.platforms.telegram.model import Telegram, TelegramUser from kibicara.platforms.telegram.model import Telegram, TelegramSubscriber
from kibicara.webapi.hoods import get_hood, get_hood_unauthorized from kibicara.webapi.hoods import get_hood, get_hood_unauthorized
logger = getLogger(__name__) logger = getLogger(__name__)
@ -21,9 +21,9 @@ logger = getLogger(__name__)
class BodyTelegram(BaseModel): class BodyTelegram(BaseModel):
api_token: str api_token: str
welcome_message: str = 'Welcome!' welcome_message: str = "Welcome!"
@validator('api_token') @validator("api_token")
def valid_api_token(cls, value): def valid_api_token(cls, value):
try: try:
check_token(value) check_token(value)
@ -38,8 +38,8 @@ class BodyTelegramPublic(BaseModel):
async def get_telegram(telegram_id: int, hood=Depends(get_hood)): async def get_telegram(telegram_id: int, hood=Depends(get_hood)):
try: try:
return await Telegram.objects.get(id=telegram_id, hood=hood) return await Telegram.get(id=telegram_id, hood=hood)
except NoMatch: except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@ -48,12 +48,12 @@ telegram_callback_router = APIRouter()
@router.get( @router.get(
'/public', "/public",
# TODO response_model, # TODO response_model,
operation_id='get_telegrams_public', operation_id="get_telegrams_public",
) )
async def telegram_read_all_public(hood=Depends(get_hood_unauthorized)): async def telegram_read_all_public(hood=Depends(get_hood_unauthorized)):
telegrambots = await Telegram.objects.filter(hood=hood).all() telegrambots = await Telegram.filter(hood=hood).all()
return [ return [
BodyTelegramPublic(username=telegrambot.username) BodyTelegramPublic(username=telegrambot.username)
for telegrambot in telegrambots for telegrambot in telegrambots
@ -62,59 +62,59 @@ async def telegram_read_all_public(hood=Depends(get_hood_unauthorized)):
@router.get( @router.get(
'/', "/",
# TODO response_model, # TODO response_model,
operation_id='get_telegrams', operation_id="get_telegrams",
) )
async def telegram_read_all(hood=Depends(get_hood)): async def telegram_read_all(hood=Depends(get_hood)):
return await Telegram.objects.filter(hood=hood).all() return await Telegram.filter(hood=hood).all()
@router.get( @router.get(
'/{telegram_id}', "/{telegram_id}",
# TODO response_model, # TODO response_model,
operation_id='get_telegram', operation_id="get_telegram",
) )
async def telegram_read(telegram=Depends(get_telegram)): async def telegram_read(telegram=Depends(get_telegram)):
return telegram return telegram
@router.delete( @router.delete(
'/{telegram_id}', "/{telegram_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
operation_id='delete_telegram', operation_id="delete_telegram",
) )
async def telegram_delete(telegram=Depends(get_telegram)): async def telegram_delete(telegram=Depends(get_telegram)):
spawner.stop(telegram) spawner.stop(telegram)
for user in await TelegramUser.objects.filter(bot=telegram).all(): for user in await TelegramSubscriber.filter(bot=telegram).all():
await user.delete() await user.delete()
await telegram.delete() await telegram.delete()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post( @router.post(
'/', "/",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
# TODO response_model, # TODO response_model,
operation_id='create_telegram', operation_id="create_telegram",
) )
async def telegram_create( async def telegram_create(
response: Response, values: BodyTelegram, hood=Depends(get_hood) response: Response, values: BodyTelegram, hood=Depends(get_hood)
): ):
try: try:
telegram = await Telegram.objects.create(hood=hood, **values.__dict__) telegram = await Telegram.create(hood=hood, **values.__dict__)
spawner.start(telegram) spawner.start(telegram)
response.headers['Location'] = str(telegram.id) response.headers["Location"] = str(telegram.id)
return telegram return telegram
except IntegrityError: except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
@router.put( @router.put(
'/{telegram_id}', "/{telegram_id}",
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
# TODO response_model, # TODO response_model,
operation_id='update_telegram', operation_id="update_telegram",
) )
async def telegram_update(values: BodyTelegram, telegram=Depends(get_telegram)): async def telegram_update(values: BodyTelegram, telegram=Depends(get_telegram)):
try: try:
@ -127,20 +127,20 @@ async def telegram_update(values: BodyTelegram, telegram=Depends(get_telegram)):
@router.get( @router.get(
'/{telegram_id}/status', "/{telegram_id}/status",
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
# TODO response_model, # TODO response_model,
operation_id='status_telegram', operation_id="status_telegram",
) )
async def telegram_status(telegram=Depends(get_telegram)): async def telegram_status(telegram=Depends(get_telegram)):
return {'status': spawner.get(telegram).status.name} return {"status": spawner.get(telegram).status.name}
@router.post( @router.post(
'/{telegram_id}/start', "/{telegram_id}/start",
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
# TODO response_model, # TODO response_model,
operation_id='start_telegram', operation_id="start_telegram",
) )
async def telegram_start(telegram=Depends(get_telegram)): async def telegram_start(telegram=Depends(get_telegram)):
await telegram.update(enabled=True) await telegram.update(enabled=True)
@ -149,10 +149,10 @@ async def telegram_start(telegram=Depends(get_telegram)):
@router.post( @router.post(
'/{telegram_id}/stop', "/{telegram_id}/stop",
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
# TODO response_model, # TODO response_model,
operation_id='stop_telegram', operation_id="stop_telegram",
) )
async def telegram_stop(telegram=Depends(get_telegram)): async def telegram_stop(telegram=Depends(get_telegram)):
await telegram.update(enabled=False) await telegram.update(enabled=False)

View file

@ -1,17 +1,17 @@
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de> # Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from kibicara.platformapi import Censor, Spawner from kibicara.platformapi import Censor, Message, Spawner
from kibicara.platforms.test.model import Test from kibicara.platforms.test.model import Test
class TestBot(Censor): class TestBot(Censor):
def __init__(self, test): def __init__(self, test: Test):
super().__init__(test.hood) super().__init__(test.hood)
self.messages = [] self.messages: list[Message] = []
async def run(self): async def run(self):
while True: while True:

View file

@ -0,0 +1,19 @@
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
from tortoise import fields
from tortoise.models import Model
from kibicara.model import Hood
class Test(Model):
id = fields.IntField(pk=True)
hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
"models.Hood", related_name="platforms_test"
)
class Meta:
table = "platforms_test"

View file

@ -0,0 +1,67 @@
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara.model import Hood
from kibicara.platformapi import Message
from kibicara.platforms.test.bot import spawner
from kibicara.platforms.test.model import Test
from kibicara.webapi.hoods import get_hood
class BodyMessage(BaseModel):
text: str
async def get_test(test_id: int, hood: Hood = Depends(get_hood)) -> Test:
try:
return await Test.get(id=test_id, hood=hood)
except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
router = APIRouter()
@router.get("/")
async def test_read_all(hood: Hood = Depends(get_hood)):
return await Test.filter(hood=hood)
@router.post("/", status_code=status.HTTP_201_CREATED)
async def test_create(response: Response, hood: Hood = Depends(get_hood)):
try:
test = await Test.create(hood=hood)
spawner.start(test)
response.headers["Location"] = str(test.id)
return test
except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
@router.get("/{test_id}")
async def test_read(test: Test = Depends(get_test)):
return test
@router.delete("/{test_id}", status_code=status.HTTP_204_NO_CONTENT)
async def test_delete(test: Test = Depends(get_test)):
spawner.stop(test)
await test.delete()
@router.get("/{test_id}/messages/")
async def test_message_read_all(test: Test = Depends(get_test)):
return spawner.get(test).messages
@router.post("/{test_id}/messages/")
async def test_message_create(message: BodyMessage, test: Test = Depends(get_test)):
await spawner.get(test).publish(Message(message.text))
return {}

View file

@ -1,5 +1,6 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
@ -27,58 +28,58 @@ class TwitterBot(Censor):
@classmethod @classmethod
async def destroy_hood(cls, hood): async def destroy_hood(cls, hood):
"""Removes all its database entries.""" """Removes all its database entries."""
for twitter in await Twitter.objects.filter(hood=hood).all(): for twitter in await Twitter.filter(hood=hood).all():
await twitter.delete() await twitter.delete()
async def run(self): async def run(self):
try: try:
if not self.twitter_model.verified: if not self.twitter_model.verified:
raise ValueError('Oauth Handshake not completed') raise ValueError("Oauth Handshake not completed")
self.client = PeonyClient( self.client = PeonyClient(
consumer_key=config['twitter']['consumer_key'], consumer_key=config["twitter"]["consumer_key"],
consumer_secret=config['twitter']['consumer_secret'], consumer_secret=config["twitter"]["consumer_secret"],
access_token=self.twitter_model.access_token, access_token=self.twitter_model.access_token,
access_token_secret=self.twitter_model.access_token_secret, access_token_secret=self.twitter_model.access_token_secret,
) )
if self.twitter_model.mentions_since_id is None: if self.twitter_model.mentions_since_id is None:
logger.debug('since_id is None in model, fetch newest mention id') logger.debug("since_id is None in model, fetch newest mention id")
await self._poll_mentions() await self._poll_mentions()
if self.twitter_model.dms_since_id is None: if self.twitter_model.dms_since_id is None:
logger.debug('since_id is None in model, fetch newest dm id') logger.debug("since_id is None in model, fetch newest dm id")
await self._poll_direct_messages() await self._poll_direct_messages()
user = await self.client.user user = await self.client.user
if user.screen_name: if user.screen_name:
await self.twitter_model.update(username=user.screen_name) await self.twitter_model.update(username=user.screen_name)
logger.debug( logger.debug(
'Starting Twitter bot: {0}'.format(self.twitter_model.__dict__) "Starting Twitter bot: {0}".format(self.twitter_model.__dict__)
) )
await gather(self.poll(), self.push()) await gather(self.poll(), self.push())
except CancelledError: except CancelledError:
logger.debug( logger.debug(
'Bot {0} received Cancellation.'.format(self.twitter_model.hood.name) "Bot {0} received Cancellation.".format(self.twitter_model.hood.name)
) )
except exceptions.Unauthorized: except exceptions.Unauthorized:
logger.debug( logger.debug(
'Bot {0} has invalid auth token.'.format(self.twitter_model.hood.name) "Bot {0} has invalid auth token.".format(self.twitter_model.hood.name)
) )
await self.twitter_model.update(enabled=False) await self.twitter_model.update(enabled=False)
self.enabled = self.twitter_model.enabled self.enabled = self.twitter_model.enabled
except (KeyError, ValueError, exceptions.NotAuthenticated): except (KeyError, ValueError, exceptions.NotAuthenticated):
logger.warning('Missing consumer_keys for Twitter in your configuration.') logger.warning("Missing consumer_keys for Twitter in your configuration.")
await self.twitter_model.update(enabled=False) await self.twitter_model.update(enabled=False)
self.enabled = self.twitter_model.enabled self.enabled = self.twitter_model.enabled
finally: finally:
logger.debug('Bot {0} stopped.'.format(self.twitter_model.hood.name)) logger.debug("Bot {0} stopped.".format(self.twitter_model.hood.name))
async def poll(self): async def poll(self):
while True: while True:
dms = await self._poll_direct_messages() dms = await self._poll_direct_messages()
logger.debug( logger.debug(
'Polled dms ({0}): {1}'.format(self.twitter_model.hood.name, str(dms)) "Polled dms ({0}): {1}".format(self.twitter_model.hood.name, str(dms))
) )
mentions = await self._poll_mentions() mentions = await self._poll_mentions()
logger.debug( logger.debug(
'Polled mentions ({0}): {1}'.format( "Polled mentions ({0}): {1}".format(
self.twitter_model.hood.name, str(mentions) self.twitter_model.hood.name, str(mentions)
) )
) )
@ -135,7 +136,7 @@ class TwitterBot(Censor):
remove_indices.update(range(url.indices[0], url.indices[1] + 1)) remove_indices.update(range(url.indices[0], url.indices[1] + 1))
for symbol in entities.symbols: for symbol in entities.symbols:
remove_indices.update(range(symbol.indices[0], symbol.indices[1] + 1)) remove_indices.update(range(symbol.indices[0], symbol.indices[1] + 1))
filtered_text = '' filtered_text = ""
for index, character in enumerate(text): for index, character in enumerate(text):
if index not in remove_indices: if index not in remove_indices:
filtered_text += character filtered_text += character
@ -145,11 +146,11 @@ class TwitterBot(Censor):
while True: while True:
message = await self.receive() message = await self.receive()
logger.debug( logger.debug(
'Received message from censor ({0}): {1}'.format( "Received message from censor ({0}): {1}".format(
self.twitter_model.hood.name, message.text self.twitter_model.hood.name, message.text
) )
) )
if hasattr(message, 'twitter_mention_id'): if hasattr(message, "twitter_mention_id"):
await self._retweet(message.twitter_mention_id) await self._retweet(message.twitter_mention_id)
else: else:
await self._post_tweet(message.text) await self._post_tweet(message.text)

View file

@ -0,0 +1,27 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
#
# SPDX-License-Identifier: 0BSD
from tortoise import fields
from tortoise.models import Model
from kibicara.model import Hood
class Twitter(Model):
id = fields.IntField(pk=True)
hood: fields.ForeignKeyRelation[Hood] = fields.ForeignKeyField(
"models.Hood", related_name="platforms_twitter"
)
dms_since_id = fields.IntField()
mentions_since_id = fields.IntField()
access_token = fields.TextField()
access_token_secret = fields.TextField()
username = fields.TextField()
verified = fields.BooleanField(default=False)
enabled = fields.BooleanField(default=False)
class Meta:
table = "platforms_twitter"

View file

@ -1,16 +1,16 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
from logging import getLogger from logging import getLogger
from sqlite3 import IntegrityError
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from ormantic.exceptions import NoMatch
from peony.exceptions import NotAuthenticated from peony.exceptions import NotAuthenticated
from peony.oauth_dance import get_access_token, get_oauth_token from peony.oauth_dance import get_access_token, get_oauth_token
from pydantic import BaseModel from pydantic import BaseModel
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara.config import config from kibicara.config import config
from kibicara.platforms.twitter.bot import spawner from kibicara.platforms.twitter.bot import spawner
@ -26,8 +26,8 @@ class BodyTwitterPublic(BaseModel):
async def get_twitter(twitter_id: int, hood=Depends(get_hood)): async def get_twitter(twitter_id: int, hood=Depends(get_hood)):
try: try:
return await Twitter.objects.get(id=twitter_id, hood=hood) return await Twitter.get(id=twitter_id, hood=hood)
except NoMatch: except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@ -36,12 +36,12 @@ twitter_callback_router = APIRouter()
@router.get( @router.get(
'/public', "/public",
# TODO response_model, # TODO response_model,
operation_id='get_twitters_public', operation_id="get_twitters_public",
) )
async def twitter_read_all_public(hood=Depends(get_hood_unauthorized)): async def twitter_read_all_public(hood=Depends(get_hood_unauthorized)):
twitterbots = await Twitter.objects.filter(hood=hood).all() twitterbots = await Twitter.filter(hood=hood).all()
return [ return [
BodyTwitterPublic(username=twitterbot.username) BodyTwitterPublic(username=twitterbot.username)
for twitterbot in twitterbots for twitterbot in twitterbots
@ -50,28 +50,28 @@ async def twitter_read_all_public(hood=Depends(get_hood_unauthorized)):
@router.get( @router.get(
'/', "/",
# TODO response_model, # TODO response_model,
operation_id='get_twitters', operation_id="get_twitters",
) )
async def twitter_read_all(hood=Depends(get_hood)): async def twitter_read_all(hood=Depends(get_hood)):
return await Twitter.objects.filter(hood=hood).all() return await Twitter.filter(hood=hood).all()
@router.get( @router.get(
'/{twitter_id}', "/{twitter_id}",
# TODO response_model # TODO response_model
operation_id='get_twitter', operation_id="get_twitter",
) )
async def twitter_read(twitter=Depends(get_twitter)): async def twitter_read(twitter=Depends(get_twitter)):
return twitter return twitter
@router.delete( @router.delete(
'/{twitter_id}', "/{twitter_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
# TODO response_model # TODO response_model
operation_id='delete_twitter', operation_id="delete_twitter",
) )
async def twitter_delete(twitter=Depends(get_twitter)): async def twitter_delete(twitter=Depends(get_twitter)):
spawner.stop(twitter) spawner.stop(twitter)
@ -80,20 +80,20 @@ async def twitter_delete(twitter=Depends(get_twitter)):
@router.get( @router.get(
'/{twitter_id}/status', "/{twitter_id}/status",
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
# TODO response_model # TODO response_model
operation_id='status_twitter', operation_id="status_twitter",
) )
async def twitter_status(twitter=Depends(get_twitter)): async def twitter_status(twitter=Depends(get_twitter)):
return {'status': spawner.get(twitter).status.name} return {"status": spawner.get(twitter).status.name}
@router.post( @router.post(
'/{twitter_id}/start', "/{twitter_id}/start",
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
# TODO response_model # TODO response_model
operation_id='start_twitter', operation_id="start_twitter",
) )
async def twitter_start(twitter=Depends(get_twitter)): async def twitter_start(twitter=Depends(get_twitter)):
await twitter.update(enabled=True) await twitter.update(enabled=True)
@ -102,10 +102,10 @@ async def twitter_start(twitter=Depends(get_twitter)):
@router.post( @router.post(
'/{twitter_id}/stop', "/{twitter_id}/stop",
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
# TODO response_model # TODO response_model
operation_id='stop_twitter', operation_id="stop_twitter",
) )
async def twitter_stop(twitter=Depends(get_twitter)): async def twitter_stop(twitter=Depends(get_twitter)):
await twitter.update(enabled=False) await twitter.update(enabled=False)
@ -114,10 +114,10 @@ async def twitter_stop(twitter=Depends(get_twitter)):
@router.post( @router.post(
'/', "/",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
# TODO response_model # TODO response_model
operation_id='create_twitter', operation_id="create_twitter",
) )
async def twitter_create(response: Response, hood=Depends(get_hood)): async def twitter_create(response: Response, hood=Depends(get_hood)):
""" """
@ -125,24 +125,24 @@ async def twitter_create(response: Response, hood=Depends(get_hood)):
""" """
try: try:
# Purge Twitter corpses # Purge Twitter corpses
for corpse in await Twitter.objects.filter(hood=hood, verified=False).all(): for corpse in await Twitter.filter(hood=hood, verified=False).all():
await corpse.delete() await corpse.delete()
# Create Twitter # Create Twitter
request_token = await get_oauth_token( request_token = await get_oauth_token(
config['twitter']['consumer_key'], config["twitter"]["consumer_key"],
config['twitter']['consumer_secret'], config["twitter"]["consumer_secret"],
callback_uri='{0}/dashboard/twitter-callback?hood={1}'.format( callback_uri="{0}/dashboard/twitter-callback?hood={1}".format(
config['frontend_url'], hood.id config["frontend_url"], hood.id
), ),
) )
if request_token['oauth_callback_confirmed'] != 'true': if request_token["oauth_callback_confirmed"] != "true":
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE)
twitter = await Twitter.objects.create( twitter = await Twitter.create(
hood=hood, hood=hood,
access_token=request_token['oauth_token'], access_token=request_token["oauth_token"],
access_token_secret=request_token['oauth_token_secret'], access_token_secret=request_token["oauth_token_secret"],
) )
response.headers['Location'] = str(twitter.id) response.headers["Location"] = str(twitter.id)
return twitter return twitter
except IntegrityError: except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
@ -151,25 +151,25 @@ async def twitter_create(response: Response, hood=Depends(get_hood)):
@twitter_callback_router.get( @twitter_callback_router.get(
'/callback', "/callback",
# TODO response_model # TODO response_model
operation_id='callback_twitter', operation_id="callback_twitter",
) )
async def twitter_read_callback( async def twitter_read_callback(
oauth_token: str, oauth_verifier: str, hood=Depends(get_hood) oauth_token: str, oauth_verifier: str, hood=Depends(get_hood)
): ):
try: try:
twitter = await Twitter.objects.filter(access_token=oauth_token).get() twitter = await Twitter.filter(access_token=oauth_token).get()
access_token = await get_access_token( access_token = await get_access_token(
config['twitter']['consumer_key'], config["twitter"]["consumer_key"],
config['twitter']['consumer_secret'], config["twitter"]["consumer_secret"],
twitter.access_token, twitter.access_token,
twitter.access_token_secret, twitter.access_token_secret,
oauth_verifier, oauth_verifier,
) )
await twitter.update( await Twitter.filter(id=twitter).update(
access_token=access_token['oauth_token'], access_token=access_token["oauth_token"],
access_token_secret=access_token['oauth_token_secret'], access_token_secret=access_token["oauth_token_secret"],
verified=True, verified=True,
enabled=True, enabled=True,
) )
@ -177,7 +177,7 @@ async def twitter_read_callback(
return {} return {}
except IntegrityError: except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
except NoMatch: except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
except (KeyError, ValueError, NotAuthenticated): except (KeyError, ValueError, NotAuthenticated):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)

View file

@ -0,0 +1,50 @@
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
"""Routing definitions for the REST API.
A platform bot shall add its API router in this `__init__.py`
file to get included into the main application.
"""
from fastapi import APIRouter
from kibicara.platforms.email.webapi import router as email_router
from kibicara.platforms.telegram.webapi import router as telegram_router
from kibicara.platforms.test.webapi import router as test_router
from kibicara.platforms.mastodon.webapi import router as mastodon_router
from kibicara.platforms.twitter.webapi import router as twitter_router
from kibicara.platforms.twitter.webapi import twitter_callback_router
from kibicara.webapi.admin import router as admin_router
from kibicara.webapi.hoods import router as hoods_router
from kibicara.webapi.hoods.exclude_patterns import router as exclude_patterns_router
from kibicara.webapi.hoods.include_patterns import router as include_patterns_router
router = APIRouter()
router.include_router(admin_router, prefix="/admin", tags=["admin"])
hoods_router.include_router(
include_patterns_router,
prefix="/{hood_id}/triggers",
tags=["include_patterns"],
)
hoods_router.include_router(
exclude_patterns_router,
prefix="/{hood_id}/badwords",
tags=["exclude_patterns"],
)
hoods_router.include_router(test_router, prefix="/{hood_id}/test", tags=["test"])
hoods_router.include_router(
telegram_router, prefix="/{hood_id}/telegram", tags=["telegram"]
)
hoods_router.include_router(
twitter_router, prefix="/{hood_id}/twitter", tags=["twitter"]
)
hoods_router.include_router(
mastodon_router, prefix="/{hood_id}/mastodon", tags=["mastodon"]
)
router.include_router(twitter_callback_router, prefix="/twitter", tags=["twitter"])
hoods_router.include_router(email_router, prefix="/{hood_id}/email", tags=["email"])
router.include_router(hoods_router, prefix="/hoods")

View file

@ -1,4 +1,4 @@
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de> # Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de> # Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Christian Hagenest <c.hagenest@pm.me> # Copyright (C) 2020 by Christian Hagenest <c.hagenest@pm.me>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org> # Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
@ -11,20 +11,20 @@ from datetime import datetime, timedelta
from logging import getLogger from logging import getLogger
from pickle import dumps, loads from pickle import dumps, loads
from smtplib import SMTPException from smtplib import SMTPException
from sqlite3 import IntegrityError
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from nacl.encoding import URLSafeBase64Encoder from nacl.encoding import URLSafeBase64Encoder
from nacl.exceptions import CryptoError from nacl.exceptions import CryptoError
from nacl.secret import SecretBox from nacl.secret import SecretBox
from ormantic.exceptions import NoMatch from nacl.utils import random
from passlib.hash import argon2 from passlib.hash import argon2
from pydantic import BaseModel, validator from pydantic import BaseModel, validator
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara import email from kibicara import email
from kibicara.config import config from kibicara.config import config
from kibicara.model import Admin, AdminHoodRelation, Hood from kibicara.model import Admin, Hood
from kibicara.webapi.utils import delete_hood from kibicara.webapi.utils import delete_hood
logger = getLogger(__name__) logger = getLogger(__name__)
@ -37,10 +37,10 @@ class BodyEmail(BaseModel):
class BodyPassword(BaseModel): class BodyPassword(BaseModel):
password: str password: str
@validator('password') @validator("password")
def valid_password(cls, value): def valid_password(cls, value):
if len(value) < 8: if len(value) < 8:
raise ValueError('Password is too short') raise ValueError("Password is too short")
return value return value
@ -50,55 +50,56 @@ class BodyAdmin(BodyEmail, BodyPassword):
class BodyAccessToken(BaseModel): class BodyAccessToken(BaseModel):
access_token: str access_token: str
token_type: str = 'bearer' token_type: str = "bearer"
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/api/admin/login') oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/login")
secret_box = SecretBox(bytes.fromhex(config['secret'])) secret_box = SecretBox(
bytes.fromhex(str(config.get("secret", random(SecretBox.KEY_SIZE).hex())))
)
def to_token(**kwargs): def to_token(**kwargs) -> str:
return secret_box.encrypt(dumps(kwargs), encoder=URLSafeBase64Encoder).decode( return secret_box.encrypt(dumps(kwargs), encoder=URLSafeBase64Encoder).decode(
'ascii' "ascii"
) )
def from_token(token): def from_token(token: str) -> dict:
return loads( return loads(
secret_box.decrypt(token.encode('ascii'), encoder=URLSafeBase64Encoder) secret_box.decrypt(token.encode("ascii"), encoder=URLSafeBase64Encoder)
) )
async def get_auth(email, password): async def get_auth(email: str, password: str) -> Admin:
try: try:
admin = await Admin.objects.get(email=email) admin = await Admin.get(email=email)
if argon2.verify(password, admin.passhash): if argon2.verify(password, admin.passhash):
return admin return admin
raise ValueError raise ValueError
except NoMatch: except DoesNotExist:
raise ValueError raise ValueError
async def get_admin(access_token=Depends(oauth2_scheme)): async def get_admin(access_token: str = Depends(oauth2_scheme)) -> Admin:
try: try:
admin = await get_auth(**from_token(access_token)) return await get_auth(**from_token(access_token))
except (CryptoError, ValueError): except (CryptoError, ValueError):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid authentication credentials', detail="Invalid authentication credentials",
headers={'WWW-Authenticate': 'Bearer'}, headers={"WWW-Authenticate": "Bearer"},
) )
return admin
router = APIRouter() router = APIRouter()
@router.post( @router.post(
'/register/', "/register/",
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
response_model=BaseModel, response_model=BaseModel,
operation_id='register', operation_id="register",
) )
async def admin_register(values: BodyAdmin): async def admin_register(values: BodyAdmin):
"""Sends an email with a confirmation link. """Sends an email with a confirmation link.
@ -107,28 +108,28 @@ async def admin_register(values: BodyAdmin):
- **password**: Password of new hood admin - **password**: Password of new hood admin
""" """
register_token = to_token(**values.__dict__) register_token = to_token(**values.__dict__)
logger.debug('register_token={0}'.format(register_token)) logger.debug("register_token={0}".format(register_token))
try: try:
admin = await Admin.objects.filter(email=values.email).all() if await Admin.exists(email=values.email):
if admin:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
body = '{0}/confirm?token={1}'.format(config['frontend_url'], register_token) # link goes to frontend. this is not the confirm API endpoint below!
body = "{0}/confirm?token={1}".format(config["frontend_url"], register_token)
logger.debug(body) logger.debug(body)
email.send_email( email.send_email(
to=values.email, to=values.email,
subject='Confirm Account', subject="Confirm Account",
body=body, body=body,
) )
except (ConnectionRefusedError, SMTPException): except (ConnectionRefusedError, SMTPException):
logger.exception('Email sending failed') logger.exception("Email sending failed")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY) raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY)
return {} return {}
@router.post( @router.post(
'/confirm/{register_token}', "/confirm/{register_token}",
response_model=BodyAccessToken, response_model=BodyAccessToken,
operation_id='confirm', operation_id="confirm",
) )
async def admin_confirm(register_token: str): async def admin_confirm(register_token: str):
"""Registration confirmation and account creation. """Registration confirmation and account creation.
@ -137,17 +138,18 @@ async def admin_confirm(register_token: str):
""" """
try: try:
values = from_token(register_token) values = from_token(register_token)
passhash = argon2.hash(values['password']) passhash = argon2.hash(values["password"])
await Admin.objects.create(email=values['email'], passhash=passhash) await Admin.create(email=values["email"], passhash=passhash)
# XXX login and registration tokens are exchangeable. does this hurt?
return BodyAccessToken(access_token=register_token) return BodyAccessToken(access_token=register_token)
except IntegrityError: except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
@router.post( @router.post(
'/login/', "/login/",
response_model=BodyAccessToken, response_model=BodyAccessToken,
operation_id='login', operation_id="login",
) )
async def admin_login(form_data: OAuth2PasswordRequestForm = Depends()): async def admin_login(form_data: OAuth2PasswordRequestForm = Depends()):
"""Get an access token. """Get an access token.
@ -160,17 +162,17 @@ async def admin_login(form_data: OAuth2PasswordRequestForm = Depends()):
except ValueError: except ValueError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail='Incorrect email or password', detail="Incorrect email or password",
) )
token = to_token(email=form_data.username, password=form_data.password) token = to_token(email=form_data.username, password=form_data.password)
return BodyAccessToken(access_token=token) return BodyAccessToken(access_token=token)
@router.post( @router.post(
'/reset/', "/reset/",
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
response_model=BaseModel, response_model=BaseModel,
operation_id='reset', operation_id="reset",
) )
async def admin_reset_password(values: BodyEmail): async def admin_reset_password(values: BodyEmail):
"""Sends an email with a password reset link. """Sends an email with a password reset link.
@ -178,46 +180,45 @@ async def admin_reset_password(values: BodyEmail):
- **email**: E-Mail Address of new hood admin - **email**: E-Mail Address of new hood admin
- **password**: Password of new hood admin - **password**: Password of new hood admin
""" """
register_token = to_token(datetime=datetime.now().isoformat(), **values.__dict__) reset_token = to_token(datetime=datetime.now().isoformat(), **values.__dict__)
logger.debug('register_token={0}'.format(register_token)) logger.debug("reset_token={0}".format(reset_token))
try: try:
admin = await Admin.objects.filter(email=values.email).all() if not await Admin.exists(email=values.email):
if not admin:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
body = '{0}/password-reset?token={1}'.format( # link goes to frontend. this is not the reset API endpoint below!
config['frontend_url'], register_token body = "{0}/password-reset?token={1}".format(
config["frontend_url"], reset_token
) )
logger.debug(body) logger.debug(body)
email.send_email( email.send_email(
to=values.email, to=values.email,
subject='Reset your password', subject="Reset your password",
body=body, body=body,
) )
except (ConnectionRefusedError, SMTPException): except (ConnectionRefusedError, SMTPException):
logger.exception('Email sending failed') logger.exception("Email sending failed")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY) raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY)
return {} return {}
@router.post( @router.post(
'/reset/{reset_token}', "/reset/{reset_token}",
response_model=BodyAccessToken, response_model=BodyAccessToken,
operation_id='confirm_reset', operation_id="confirm_reset",
) )
async def admin_confirm_reset(reset_token: str, values: BodyPassword): async def admin_confirm_reset(reset_token: str, values: BodyPassword):
try: try:
token_values = from_token(reset_token) token_values = from_token(reset_token)
if ( if (
datetime.fromisoformat(token_values['datetime']) + timedelta(hours=3) datetime.fromisoformat(token_values["datetime"]) + timedelta(hours=3)
< datetime.now() < datetime.now()
): ):
raise HTTPException(status_code=status.HTTP_410_GONE) raise HTTPException(status_code=status.HTTP_410_GONE)
passhash = argon2.hash(values.password) passhash = argon2.hash(values.password)
admins = await Admin.objects.filter(email=token_values['email']).all() await Admin.filter(email=token_values["email"]).update(passhash=passhash)
if len(admins) != 1:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await admins[0].update(passhash=passhash)
return BodyAccessToken(access_token=reset_token) return BodyAccessToken(access_token=reset_token)
except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
except IntegrityError: except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
except CryptoError: except CryptoError:
@ -225,47 +226,33 @@ async def admin_confirm_reset(reset_token: str, values: BodyPassword):
@router.get( @router.get(
'/hoods/', "/hoods/",
# TODO response_model, # TODO response_model,
operation_id='get_hoods_admin', operation_id="get_hoods_admin",
) )
async def admin_hood_read_all(admin=Depends(get_admin)): async def admin_hood_read_all(admin: Admin = Depends(get_admin)):
"""Get a list of all hoods of a given admin.""" """Get a list of all hoods of a given admin."""
return ( return await Hood.filter(admins=admin)
await AdminHoodRelation.objects.select_related('hood').filter(admin=admin).all()
)
@router.get( @router.get(
'/', "/",
# TODO response_model, # TODO response_model,
operation_id='get_admin', operation_id="get_admin",
) )
async def admin_read(admin=Depends(get_admin)): async def admin_read(admin: Admin = Depends(get_admin)):
"""Get a list of all hoods of a given admin.""" return BodyEmail(email=admin.email)
admin = await Admin.objects.filter(email=admin.email).all()
if len(admin) != 1:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return BodyEmail(email=admin[0].email)
@router.delete( @router.delete(
'/', "/",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
operation_id='delete_admin', operation_id="delete_admin",
) )
async def admin_delete(admin=Depends(get_admin)): async def admin_delete(admin: Admin = Depends(get_admin)):
hood_relations = ( async for hood in Hood.filter(admins__contains=admin):
await AdminHoodRelation.objects.select_related('hood').filter(admin=admin).all() await hood.admins.remove(admin)
) await hood.fetch_related("admins")
for hood in hood_relations: if len(hood.admins) == 0:
admins = ( await delete_hood(hood)
await AdminHoodRelation.objects.select_related('admin')
.filter(hood=hood.id)
.all()
)
if len(admins) == 1 and admins[0].id == admin.id:
actual_hood = await Hood.objects.filter(id=hood.id).all()
await delete_hood(actual_hood[0])
await admin.delete() await admin.delete()
return Response(status_code=status.HTTP_204_NO_CONTENT)

View file

@ -0,0 +1,120 @@
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
"""REST API Endpoints for managing hoods."""
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara.model import Admin, Hood, IncludePattern
from kibicara.platforms.email.bot import spawner
from kibicara.webapi.admin import get_admin
from kibicara.webapi.utils import delete_hood
class BodyHood(BaseModel):
name: str
landingpage: str = "Default Landing Page"
async def get_hood_unauthorized(hood_id: int) -> Hood:
try:
return await Hood.get(id=hood_id)
except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
async def get_hood(
hood: Hood = Depends(get_hood_unauthorized), admin: Admin = Depends(get_admin)
) -> Hood:
await hood.fetch_related("admins")
if admin in hood.admins:
return hood
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
headers={"WWW-Authenticate": "Bearer"},
)
router = APIRouter()
@router.get(
"/",
# TODO response_model,
operation_id="get_hoods",
tags=["hoods"],
)
async def hood_read_all():
"""Get all existing hoods."""
return await Hood.all()
@router.post(
"/",
status_code=status.HTTP_201_CREATED,
# TODO response_model,
operation_id="create_hood",
tags=["hoods"],
)
async def hood_create(
values: BodyHood, response: Response, admin: Admin = Depends(get_admin)
):
"""Creates a hood.
- **name**: Name of the hood
- **landingpage**: Markdown formatted description of the hood
"""
try:
hood = await Hood.create(**values.__dict__)
await admin.hoods.add(hood)
spawner.start(hood)
# Initialize Triggers to match all
await IncludePattern.create(hood=hood, pattern=".")
response.headers["Location"] = str(hood.id)
return hood
except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
@router.get(
"/{hood_id}",
# TODO response_model,
operation_id="get_hood",
tags=["hoods"],
)
async def hood_read(hood: Hood = Depends(get_hood_unauthorized)):
"""Get hood with id **hood_id**."""
return hood
@router.put(
"/{hood_id}",
operation_id="update_hood",
tags=["hoods"],
)
async def hood_update(values: BodyHood, hood: Hood = Depends(get_hood)):
"""Updates hood with id **hood_id**.
- **name**: New name of the hood
- **landingpage**: New Markdown formatted description of the hood
"""
await Hood.filter(id=hood).update(**values.__dict__)
return hood
@router.delete(
"/{hood_id}",
status_code=status.HTTP_204_NO_CONTENT,
operation_id="delete_hood",
tags=["hoods"],
)
async def hood_delete(hood=Depends(get_hood)):
"""Deletes hood with id **hood_id**."""
await delete_hood(hood)

View file

@ -0,0 +1,116 @@
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
"""REST API endpoints for managing exclude_patterns.
Provides API endpoints for adding, removing and reading regular expressions that block a
received message to be dropped by a censor. This provides a message filter customizable
by the hood admins.
"""
from re import compile as regex_compile, error as RegexError
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara.model import ExcludePattern, Hood
from kibicara.webapi.hoods import get_hood
class BodyExcludePattern(BaseModel):
pattern: str
async def get_exclude_pattern(
exclude_pattern_id: int, hood: Hood = Depends(get_hood)
) -> ExcludePattern:
try:
return await ExcludePattern.get(id=exclude_pattern_id, hood=hood)
except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
router = APIRouter()
@router.get(
"/",
# TODO response_model,
operation_id="get_exclude_patterns",
)
async def exclude_pattern_read_all(hood: Hood = Depends(get_hood)):
"""Get all exclude_patterns of hood with id **hood_id**."""
return await ExcludePattern.filter(hood=hood)
@router.post(
"/",
status_code=status.HTTP_201_CREATED,
# TODO response_model,
operation_id="create_exclude_pattern",
)
async def exclude_pattern_create(
values: BodyExcludePattern, response: Response, hood: Hood = Depends(get_hood)
):
"""Creates a new exclude_pattern for hood with id **hood_id**.
- **pattern**: Regular expression which is used to match a exclude_pattern.
"""
try:
regex_compile(values.pattern)
exclude_pattern = await ExcludePattern.create(hood=hood, **values.__dict__)
response.headers["Location"] = str(exclude_pattern.id)
return exclude_pattern
except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
except RegexError:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
@router.get(
"/{exclude_pattern_id}",
# TODO response_model,
operation_id="get_exclude_pattern",
)
async def exclude_pattern_read(
exclude_pattern: ExcludePattern = Depends(get_exclude_pattern),
):
"""
Reads exclude_pattern with id **exclude_pattern_id** for hood with id **hood_id**.
"""
return exclude_pattern
@router.put(
"/{exclude_pattern_id}",
operation_id="update_exclude_pattern",
)
async def exclude_pattern_update(
values: BodyExcludePattern,
exclude_pattern: ExcludePattern = Depends(get_exclude_pattern),
):
"""
Updates exclude_pattern with id **exclude_pattern_id** for hood with id **hood_id**.
- **pattern**: Regular expression which is used to match a exclude_pattern
"""
await ExcludePattern.filter(id=exclude_pattern).update(**values.__dict__)
return exclude_pattern
@router.delete(
"/{exclude_pattern_id}",
status_code=status.HTTP_204_NO_CONTENT,
operation_id="delete_exclude_pattern",
)
async def exclude_pattern_delete(
exclude_pattern: ExcludePattern = Depends(get_exclude_pattern),
):
"""
Deletes exclude_pattern with id **exclude_pattern_id** for hood with id **hood_id**.
"""
await exclude_pattern.delete()

View file

@ -0,0 +1,115 @@
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
"""REST API endpoints for managing include_patterns.
Provides API endpoints for adding, removing and reading regular expressions that allow a
message to be passed through by a censor. A published message must match one of these
regular expressions otherwise it gets dropped by the censor. This provides a message
filter customizable by the hood admins.
"""
from re import compile as regex_compile, error as RegexError
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel
from tortoise.exceptions import DoesNotExist, IntegrityError
from kibicara.model import IncludePattern, Hood
from kibicara.webapi.hoods import get_hood
class BodyIncludePattern(BaseModel):
pattern: str
async def get_include_pattern(include_pattern_id: int, hood: Hood = Depends(get_hood)):
try:
return await IncludePattern.get(id=include_pattern_id, hood=hood)
except DoesNotExist:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
router = APIRouter()
@router.get(
"/",
# TODO response_model,
operation_id="get_include_patterns",
)
async def include_pattern_read_all(hood: Hood = Depends(get_hood)):
"""Get all include_patterns of hood with id **hood_id**."""
return await IncludePattern.filter(hood=hood)
@router.post(
"/",
status_code=status.HTTP_201_CREATED,
# TODO response_model,
operation_id="create_include_pattern",
)
async def include_pattern_create(
values: BodyIncludePattern, response: Response, hood: Hood = Depends(get_hood)
):
"""Creates a new include_pattern for hood with id **hood_id**.
- **pattern**: Regular expression which is used to match a include_pattern.
"""
try:
regex_compile(values.pattern)
include_pattern = await IncludePattern.create(hood=hood, **values.__dict__)
response.headers["Location"] = str(include_pattern.id)
return include_pattern
except IntegrityError:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
except RegexError:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
@router.get(
"/{include_pattern_id}",
# TODO response_model,
operation_id="get_include_pattern",
)
async def include_pattern_read(
include_pattern: IncludePattern = Depends(get_include_pattern),
):
"""
Reads include_pattern with id **include_pattern_id** for hood with id **hood_id**.
"""
return include_pattern
@router.put(
"/{include_pattern_id}",
operation_id="update_include_pattern",
)
async def include_pattern_update(
values: BodyIncludePattern,
include_pattern: IncludePattern = Depends(get_include_pattern),
):
"""
Updates include_pattern with id **include_pattern_id** for hood with id **hood_id**.
- **pattern**: Regular expression which is used to match a include_pattern
"""
await IncludePattern.filter(id=include_pattern).update(**values.__dict__)
return include_pattern
@router.delete(
"/{include_pattern_id}",
status_code=status.HTTP_204_NO_CONTENT,
operation_id="delete_include_pattern",
)
async def include_pattern_delete(
include_pattern: IncludePattern = Depends(get_include_pattern),
):
"""
Deletes include_pattern with id **include_pattern_id** for hood with id **hood_id**.
"""
await include_pattern.delete()

View file

@ -0,0 +1,14 @@
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
#
# SPDX-License-Identifier: 0BSD
from kibicara.model import ExcludePattern, Hood, IncludePattern
from kibicara.platformapi import Spawner
async def delete_hood(hood: Hood) -> None:
await Spawner.destroy_hood(hood)
await IncludePattern.filter(hood=hood).delete()
await ExcludePattern.filter(hood=hood).delete()
await hood.delete()

156
backend/tests/conftest.py Normal file
View file

@ -0,0 +1,156 @@
# Copyright (C) 2020, 2023 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Christian Hagenest <c.hagenest@pm.me>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
from urllib.parse import urlparse
from fastapi import FastAPI, status
from httpx import AsyncClient
import pytest
from tortoise import Tortoise
from kibicara import email
from kibicara.webapi import router
@pytest.fixture(scope="session")
def anyio_backend():
return "asyncio"
@pytest.fixture(scope="session")
@pytest.mark.anyio
async def client():
await Tortoise.init(
db_url="sqlite://:memory:",
modules={
"models": [
"kibicara.model",
"kibicara.platforms.email.model",
"kibicara.platforms.mastodon.model",
"kibicara.platforms.telegram.model",
"kibicara.platforms.test.model",
"kibicara.platforms.twitter.model",
]
},
)
await Tortoise.generate_schemas()
app = FastAPI()
app.include_router(router, prefix="/api")
yield AsyncClient(app=app, base_url="http://test")
await Tortoise.close_connections()
@pytest.fixture(scope="session")
def monkeymodule():
from _pytest.monkeypatch import MonkeyPatch
mpatch = MonkeyPatch()
yield mpatch
mpatch.undo()
@pytest.fixture(scope="session")
def receive_email(monkeymodule):
mailbox = []
def mock_send_email(to, subject, sender="kibicara", body=""):
mailbox.append(dict(to=to, subject=subject, sender=sender, body=body))
def mock_receive_email():
return mailbox.pop()
monkeymodule.setattr(email, "send_email", mock_send_email)
return mock_receive_email
@pytest.fixture(scope="session")
@pytest.mark.anyio
async def register_token(client, receive_email):
response = await client.post(
"/api/admin/register/", json={"email": "user", "password": "password"}
)
assert response.status_code == status.HTTP_202_ACCEPTED
return urlparse(receive_email()["body"]).query.split("=", 1)[1]
@pytest.fixture(scope="session")
@pytest.mark.anyio
async def register_confirmed(client, register_token):
response = await client.post("/api/admin/confirm/{0}".format(register_token))
assert response.status_code == status.HTTP_200_OK
@pytest.fixture(scope="session")
@pytest.mark.anyio
async def access_token(client, register_confirmed):
response = await client.post(
"/api/admin/login/", data={"username": "user", "password": "password"}
)
assert response.status_code == status.HTTP_200_OK
return response.json()["access_token"]
@pytest.fixture(scope="session")
def auth_header(access_token):
return {"Authorization": "Bearer {0}".format(access_token)}
@pytest.fixture(scope="function")
@pytest.mark.anyio
async def hood_id(client, auth_header):
response = await client.post(
"/api/hoods/", json={"name": "hood"}, headers=auth_header
)
assert response.status_code == status.HTTP_201_CREATED
hood_id = int(response.headers["Location"])
yield hood_id
await client.delete("/api/hoods/{0}".format(hood_id), headers=auth_header)
@pytest.fixture(scope="function")
@pytest.mark.anyio
async def trigger_id(client, hood_id, auth_header):
response = await client.post(
"/api/hoods/{0}/triggers/".format(hood_id),
json={"pattern": "test"},
headers=auth_header,
)
assert response.status_code == status.HTTP_201_CREATED
trigger_id = int(response.headers["Location"])
yield trigger_id
await client.delete(
"/api/hoods/{0}/triggers/{1}".format(hood_id, trigger_id), headers=auth_header
)
@pytest.fixture(scope="function")
@pytest.mark.anyio
async def badword_id(client, hood_id, auth_header):
response = await client.post(
"/api/hoods/{0}/badwords/".format(hood_id),
json={"pattern": ""},
headers=auth_header,
)
assert response.status_code == status.HTTP_201_CREATED
badword_id = int(response.headers["Location"])
yield badword_id
await client.delete(
"/api/hoods/{0}/badwords/{1}".format(hood_id, badword_id), headers=auth_header
)
@pytest.fixture(scope="function")
@pytest.mark.anyio
async def test_id(client, hood_id, auth_header):
response = await client.post(
"/api/hoods/{0}/test/".format(hood_id), json={}, headers=auth_header
)
assert response.status_code == status.HTTP_201_CREATED
test_id = int(response.headers["Location"])
yield test_id
await client.delete(
"/api/hoods/{0}/test/{1}".format(hood_id, test_id), headers=auth_header
)

View file

@ -0,0 +1,18 @@
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de>
#
# SPDX-License-Identifier: 0BSD
from fastapi import status
import pytest
@pytest.mark.anyio
async def test_hoods_unauthorized(client):
response = await client.get("/api/admin/hoods/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.anyio
async def test_hoods_success(client, auth_header):
response = await client.get("/api/admin/hoods/", headers=auth_header)
assert response.status_code == status.HTTP_200_OK

View file

@ -0,0 +1,109 @@
# Copyright (C) 2020 by Christian Hagenest <c.hagenest@pm.me>
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
import pytest
from fastapi import status
@pytest.mark.anyio
async def test_hood_read_all(client):
response = await client.get("/api/hoods/")
assert response.status_code == status.HTTP_200_OK
@pytest.mark.anyio
async def test_hood_create_unauthorized(client, hood_id):
response = await client.post("/api/hoods/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.anyio
async def test_hood_read(client, hood_id):
response = await client.get("/api/hoods/{0}".format(hood_id))
assert response.status_code == status.HTTP_200_OK
@pytest.mark.anyio
async def test_hood_update_unauthorized(client, hood_id):
response = await client.put("/api/hoods/{0}".format(hood_id))
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.anyio
async def test_hood_delete_unauthorized(client, hood_id):
response = await client.delete("/api/hoods/{0}".format(hood_id))
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.anyio
async def test_trigger_read_all_unauthorized(client, hood_id):
response = await client.get("/api/hoods/{0}/triggers/".format(hood_id))
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.anyio
async def test_trigger_create_unauthorized(client, hood_id):
response = await client.post("/api/hoods/{0}/triggers/".format(hood_id))
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.anyio
async def test_trigger_read_unauthorized(client, hood_id, trigger_id):
response = await client.get(
"/api/hoods/{0}/triggers/{1}".format(hood_id, trigger_id)
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.anyio
async def test_trigger_update_unauthorized(client, hood_id, trigger_id):
response = await client.put(
"/api/hoods/{0}/triggers/{1}".format(hood_id, trigger_id)
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.anyio
async def test_trigger_delete_unauthorized(client, hood_id, trigger_id):
response = await client.delete(
"/api/hoods/{0}/triggers/{1}".format(hood_id, trigger_id)
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.anyio
async def test_badword_read_all_unauthorized(client, hood_id):
response = await client.get("/api/hoods/{0}/badwords/".format(hood_id))
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.anyio
async def test_badword_create_unauthorized(client, hood_id):
response = await client.post("/api/hoods/{0}/badwords/".format(hood_id))
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.anyio
async def test_badword_read_unauthorized(client, hood_id, badword_id):
response = await client.get(
"/api/hoods/{0}/badwords/{1}".format(hood_id, badword_id)
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.anyio
async def test_badword_update_unauthorized(client, hood_id, badword_id):
response = await client.put(
"/api/hoods/{0}/badwords/{1}".format(hood_id, badword_id)
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.anyio
async def test_badword_delete_unauthorized(client, hood_id, badword_id):
response = await client.delete(
"/api/hoods/{0}/badwords/{1}".format(hood_id, badword_id)
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED

View file

@ -0,0 +1,25 @@
# Copyright (C) 2020 by Maike <maike@systemli.org>
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
from fastapi import status
import pytest
@pytest.fixture(scope="function")
@pytest.mark.anyio
async def email_row(client, hood_id, auth_header):
response = await client.post(
"/api/hoods/{0}/email/".format(hood_id),
json={"name": "kibicara-test"},
headers=auth_header,
)
assert response.status_code == status.HTTP_201_CREATED
email_id = int(response.headers["Location"])
yield response.json()
await client.delete(
"/api/hoods/{0}/email/{1}".format(hood_id, email_id), headers=auth_header
)

View file

@ -8,56 +8,61 @@ from re import findall
from urllib.parse import urlparse from urllib.parse import urlparse
from fastapi import status from fastapi import status
from pytest import skip import pytest
from kibicara.webapi.admin import to_token from kibicara.webapi.admin import to_token
def test_email_subscribe_unsubscribe(client, hood_id, receive_email): @pytest.mark.anyio
response = client.post( async def test_email_subscribe_unsubscribe(client, hood_id, receive_email):
'/api/hoods/{0}/email/subscribe/'.format(hood_id), response = await client.post(
json={'email': 'test@localhost'}, "/api/hoods/{0}/email/subscribe/".format(hood_id),
json={"email": "test@localhost"},
) )
assert response.status_code == status.HTTP_202_ACCEPTED assert response.status_code == status.HTTP_202_ACCEPTED
mail = receive_email() mail = receive_email()
body = mail['body'] body = mail["body"]
confirm_url = findall( confirm_url = findall(
r'http[s]?://' r"http[s]?://"
+ r'(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', + r"(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+",
body, body,
)[0] )[0]
start = len('token=') start = len("token=")
response = client.post( response = await client.post(
'/api/hoods/{0}/email/subscribe/confirm/{1}'.format( "/api/hoods/{0}/email/subscribe/confirm/{1}".format(
hood_id, urlparse(confirm_url).query[start:] hood_id, urlparse(confirm_url).query[start:]
) )
) )
assert response.status_code == status.HTTP_201_CREATED assert response.status_code == status.HTTP_201_CREATED
response = client.post( response = await client.post(
'/api/hoods/{0}/email/subscribe/confirm/{1}'.format( "/api/hoods/{0}/email/subscribe/confirm/{1}".format(
hood_id, urlparse(confirm_url).query[start:] hood_id, urlparse(confirm_url).query[start:]
) )
) )
assert response.status_code == status.HTTP_409_CONFLICT assert response.status_code == status.HTTP_409_CONFLICT
token = to_token(email=mail['to'], hood=hood_id) token = to_token(email=mail["to"], hood=hood_id)
response = client.delete( response = await client.delete(
'/api/hoods/{0}/email/unsubscribe/{1}'.format(hood_id, token) "/api/hoods/{0}/email/unsubscribe/{1}".format(hood_id, token)
) )
assert response.status_code == status.HTTP_204_NO_CONTENT assert response.status_code == status.HTTP_204_NO_CONTENT
def test_email_message(client, hood_id, trigger_id, email_row): @pytest.mark.anyio
async def test_email_message(client, hood_id, trigger_id, email_row):
body = { body = {
'text': 'test', "text": "test",
'author': 'test@localhost', "author": "test@localhost",
'secret': email_row['secret'], "secret": email_row["secret"],
} }
response = client.post('/api/hoods/{0}/email/messages/'.format(hood_id), json=body) response = await client.post(
"/api/hoods/{0}/email/messages/".format(hood_id), json=body
)
assert response.status_code == status.HTTP_201_CREATED assert response.status_code == status.HTTP_201_CREATED
def test_email_send_mda(trigger_id, email_row): @pytest.mark.anyio
skip('Only works if kibicara is listening on port 8000, and only sometimes') async def test_email_send_mda(trigger_id, email_row):
pytest.skip("Only works if kibicara is listening on port 8000, and only sometimes")
mail = """From test@example.com Tue Jun 16 15:33:19 2020 mail = """From test@example.com Tue Jun 16 15:33:19 2020
Return-path: <test@example.com> Return-path: <test@example.com>
Envelope-to: hood@localhost Envelope-to: hood@localhost
@ -85,6 +90,6 @@ test
--AqNPlAX243a8sip3B7kXv8UKD8wuti-- --AqNPlAX243a8sip3B7kXv8UKD8wuti--
""" """
proc = subprocess.run( proc = subprocess.run(
['kibicara_mda', 'hood'], stdout=subprocess.PIPE, input=mail, encoding='ascii' ["kibicara_mda", "hood"], stdout=subprocess.PIPE, input=mail, encoding="ascii"
) )
assert proc.returncode == 0 assert proc.returncode == 0

View file

@ -0,0 +1,31 @@
# Copyright (C) 2020 by Maike <maike@systemli.org>
# Copyright (C) 2020 by Thomas Lindner <tom@dl6tom.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
from fastapi import status
import pytest
@pytest.mark.anyio
async def test_email_create_unauthorized(client, hood_id):
response = await client.post("/api/hoods/{0}/email/".format(hood_id))
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.anyio
async def test_email_delete_unauthorized(client, hood_id, email_row):
response = await client.delete(
"/api/hoods/{0}/email/{1}".format(hood_id, email_row["id"])
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.anyio
async def test_email_message_unauthorized(client, hood_id, email_row):
body = {"text": "test", "author": "author", "secret": "wrong"}
response = await client.post(
"/api/hoods/{0}/email/messages/".format(hood_id), json=body
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED

View file

@ -0,0 +1,67 @@
# Copyright (C) 2020 by Maike <maike@systemli.org>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
from fastapi import status
from nacl.exceptions import CryptoError
import pytest
@pytest.mark.anyio
async def test_email_subscribe_empty(client, hood_id):
response = await client.post("/api/hoods/{0}/email/subscribe/".format(hood_id))
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.anyio
async def test_email_subscribe_confirm_wrong_token(client, hood_id):
try:
response = await client.post(
"/api/hoods/{0}/email/subscribe/confirm/".format(hood_id)
+ "asdfasdfasdfasdfasdfasdfasdfasdf"
)
assert response.status_code is not status.HTTP_201_CREATED
except CryptoError:
pass
@pytest.mark.anyio
async def test_email_subscribe_confirm_wrong_hood(client):
response = await client.delete(
"/api/hoods/99999/email/unsubscribe/asdfasdfasdfasdfasdfasdfasdfasdf"
)
assert response.json()["detail"] == "Not Found"
@pytest.mark.anyio
async def test_email_message_wrong(client, hood_id, email_row):
body = {
"text": "",
"author": "test@localhost",
"secret": email_row["secret"],
}
response = await client.post(
"/api/hoods/{0}/email/messages/".format(hood_id), json=body
)
assert response.status_code == status.HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS
@pytest.mark.anyio
async def test_email_unsubscribe_wrong_token(client, hood_id):
try:
await client.delete(
"/api/hoods/{0}/email/unsubscribe/asdfasdfasdfasdfasdfasdfasdfasdf".format(
hood_id
)
)
except CryptoError:
pass
@pytest.mark.anyio
async def test_email_unsubscribe_wrong_hood(client):
response = await client.delete(
"/api/hoods/99999/email/unsubscribe/asdfasdfasdfasdfasdfasdfasdfasdf"
)
assert response.json()["detail"] == "Not Found"

View file

@ -0,0 +1,33 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
#
# SPDX-License-Identifier: 0BSD
import pytest
from kibicara.model import Hood
from kibicara.platforms.mastodon.model import MastodonAccount, MastodonInstance
@pytest.fixture(scope="function")
@pytest.mark.anyio
async def mastodon_instance():
return await MastodonInstance.create(
name="inst4nce",
client_id="cl13nt_id",
client_secret="cl13nt_s3cr3t",
)
@pytest.fixture(scope="function")
@pytest.mark.anyio
async def mastodon_account(hood_id, mastodon_instance):
hood = await Hood.get(id=hood_id)
return await MastodonAccount.create(
hood=hood,
instance=mastodon_instance,
access_token="t0k3n",
enabled=True,
username="us3r",
)

View file

@ -0,0 +1,96 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
#
# SPDX-License-Identifier: 0BSD
from fastapi import status
import pytest
from mastodon.Mastodon import Mastodon
from kibicara.platforms import mastodon
from kibicara.platforms.mastodon.model import MastodonAccount
@pytest.fixture(scope="function")
def disable_spawner(monkeypatch):
class DoNothing:
def start(self, bot):
assert bot is not None
monkeypatch.setattr(mastodon.webapi, "spawner", DoNothing())
@pytest.mark.parametrize(
"body",
[
{
"instance_url": "botsin.space",
"email": "test@example.org",
"password": "string",
}
],
)
@pytest.mark.anyio
async def test_mastodon_create_bot(
client,
disable_spawner,
hood_id,
auth_header,
monkeypatch,
body,
):
def log_in_mock(self, username, password):
return "acc3ss_t0ken"
monkeypatch.setattr(Mastodon, "log_in", log_in_mock)
response = await client.post(
"/api/hoods/{0}/mastodon/".format(hood_id),
json=body,
headers=auth_header,
)
print(response.json())
assert response.status_code == status.HTTP_201_CREATED
bot_id = response.json()["id"]
mastodon_obj = await MastodonAccount.get(id=bot_id)
assert response.json()["access_token"] == mastodon_obj.access_token
assert mastodon_obj.enabled
@pytest.mark.parametrize(
"body",
[
{"instance_url": "botsin.space", "email": "notanemail", "password": "asdf1234"},
{"instance_url": "wrong", "email": "asdf@example.org", "password": "asdf1234"},
],
)
@pytest.mark.anyio
async def test_mastodon_invalid_input(
client,
disable_spawner,
hood_id,
auth_header,
monkeypatch,
body,
):
response = await client.post(
"/api/hoods/{0}/mastodon/".format(hood_id),
json=body,
headers=auth_header,
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.anyio
async def test_mastodon_create_mastodon_invalid_id(client, auth_header):
response = await client.post("/api/hoods/1337/mastodon/", headers=auth_header)
assert response.status_code == status.HTTP_404_NOT_FOUND
response = await client.post("/api/hoods/wrong/mastodon/", headers=auth_header)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.anyio
async def test_mastodon_create_unauthorized(client, hood_id):
response = await client.post("/api/hoods/{hood_id}/mastodon/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED

View file

@ -0,0 +1,50 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
#
# SPDX-License-Identifier: 0BSD
from fastapi import status
import pytest
from tortoise.exceptions import DoesNotExist
from kibicara.platforms.mastodon.model import MastodonAccount
@pytest.mark.anyio
async def test_mastodon_delete_bot(client, mastodon_account, auth_header):
response = await client.delete(
"/api/hoods/{0}/mastodon/{1}".format(
mastodon_account.hood.id, mastodon_account.id
),
headers=auth_header,
)
assert response.status_code == status.HTTP_204_NO_CONTENT
with pytest.raises(DoesNotExist):
await MastodonAccount.get(id=mastodon_account.id)
@pytest.mark.anyio
async def test_mastodon_delete_bot_invalid_id(client, auth_header, hood_id):
response = await client.delete("/api/hoods/1337/mastodon/123", headers=auth_header)
assert response.status_code == status.HTTP_404_NOT_FOUND
response = await client.delete("/api/hoods/wrong/mastodon/123", headers=auth_header)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
response = await client.delete(
"/api/hoods/{0}/mastodon/7331".format(hood_id), headers=auth_header
)
assert response.status_code == status.HTTP_404_NOT_FOUND
response = await client.delete(
"/api/hoods/{0}/mastodon/wrong".format(hood_id), headers=auth_header
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.anyio
async def test_mastodon_delete_bot_unauthorized(client, mastodon_account):
response = await client.delete(
"/api/hoods/{0}/mastodon/{1}".format(
mastodon_account.hood.id, mastodon_account.id
)
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED

View file

@ -0,0 +1,55 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
#
# SPDX-License-Identifier: 0BSD
from fastapi import status
import pytest
from kibicara.platforms.mastodon.model import MastodonAccount
@pytest.mark.anyio
async def test_mastodon_get_bots(
client, auth_header, hood_id, mastodon_account, mastodon_instance
):
mastodon2 = await MastodonAccount.create(
hood=mastodon_account.hood,
instance=mastodon_instance,
access_token="4cc3ss",
enabled=True,
username="us4r",
)
response = await client.get(
"/api/hoods/{0}/mastodon/".format(mastodon_account.hood.id), headers=auth_header
)
print(response.headers)
assert response.status_code == status.HTTP_200_OK
assert response.json()[0]["id"] == mastodon_account.id
assert response.json()[0]["access_token"] == mastodon_account.access_token
assert response.json()[1]["id"] == mastodon2.id
assert response.json()[1]["access_token"] == mastodon2.access_token
@pytest.mark.anyio
async def test_mastodon_get_bots_invalid_id(client, auth_header, hood_id):
response = await client.get("/api/hoods/1337/mastodon/", headers=auth_header)
assert response.status_code == status.HTTP_404_NOT_FOUND
response = await client.get("/api/hoods/wrong/mastodon/", headers=auth_header)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.anyio
async def test_mastodon_get_bots_unauthorized(client, hood_id):
response = await client.get("/api/hoods/{0}/mastodon/".format(hood_id))
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.anyio
async def test_mastodon_public(client, mastodon_account, mastodon_instance):
response = await client.get(
"/api/hoods/{0}/mastodon/public".format(mastodon_account.hood.id)
)
assert response.json()[0]["username"] == mastodon_account.username
assert response.json()[0]["instance"] == mastodon_instance.name

View file

@ -0,0 +1,21 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
#
# SPDX-License-Identifier: 0BSD
import pytest
from kibicara.model import Hood
from kibicara.platforms.telegram.model import Telegram
@pytest.fixture(scope="function")
@pytest.mark.anyio
async def telegram(hood_id, bot):
hood = await Hood.get(id=hood_id)
return await Telegram.create(
hood=hood,
api_token=bot["api_token"],
welcome_message=bot["welcome_message"],
)

View file

@ -0,0 +1,84 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
#
# SPDX-License-Identifier: 0BSD
from fastapi import status
import pytest
from kibicara.platforms import telegram
from kibicara.platforms.telegram.model import Telegram
@pytest.fixture(scope="function")
def disable_spawner(monkeypatch):
class DoNothing:
def start(self, bot):
assert bot is not None
monkeypatch.setattr(telegram.webapi, "spawner", DoNothing())
@pytest.mark.parametrize("body", [{"api_token": "string", "welcome_message": "string"}])
@pytest.mark.anyio
async def test_telegram_create_bot(
client,
disable_spawner,
hood_id,
auth_header,
monkeypatch,
body,
):
def check_token_mock(token):
return True
monkeypatch.setattr(telegram.webapi, "check_token", check_token_mock)
response = await client.post(
"/api/hoods/{0}/telegram/".format(hood_id),
json=body,
headers=auth_header,
)
assert response.status_code == status.HTTP_201_CREATED
bot_id = response.json()["id"]
telegram_obj = await Telegram.get(id=bot_id)
assert response.json()["api_token"] == body["api_token"] == telegram_obj.api_token
assert (
response.json()["welcome_message"]
== body["welcome_message"]
== telegram_obj.welcome_message
)
assert telegram_obj.enabled
@pytest.mark.parametrize("body", [{"api_token": "string", "welcome_message": "string"}])
@pytest.mark.anyio
async def test_telegram_invalid_api_token(
client,
disable_spawner,
hood_id,
auth_header,
monkeypatch,
body,
):
response = await client.post(
"/api/hoods/{0}/telegram/".format(hood_id),
json=body,
headers=auth_header,
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.anyio
async def test_telegram_create_telegram_invalid_id(client, auth_header):
response = await client.post("/api/hoods/1337/telegram/", headers=auth_header)
assert response.status_code == status.HTTP_404_NOT_FOUND
response = await client.post("/api/hoods/wrong/telegram/", headers=auth_header)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.anyio
async def test_telegram_create_unauthorized(client, hood_id):
response = await client.post("/api/hoods/{hood_id}/telegram/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED

View file

@ -0,0 +1,56 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
#
# SPDX-License-Identifier: 0BSD
from fastapi import status
import pytest
from tortoise.exceptions import DoesNotExist
from kibicara.platforms.telegram.model import Telegram, TelegramSubscriber
@pytest.mark.parametrize(
"bot", [{"api_token": "apitoken123", "welcome_message": "msg"}]
)
@pytest.mark.anyio
async def test_telegram_delete_bot(client, bot, telegram, auth_header):
await TelegramSubscriber.create(user_id=1234, bot=telegram)
await TelegramSubscriber.create(user_id=5678, bot=telegram)
response = await client.delete(
"/api/hoods/{0}/telegram/{1}".format(telegram.hood.id, telegram.id),
headers=auth_header,
)
assert response.status_code == status.HTTP_204_NO_CONTENT
with pytest.raises(DoesNotExist):
await Telegram.get(id=telegram.id)
with pytest.raises(DoesNotExist):
await TelegramSubscriber.get(id=telegram.id)
@pytest.mark.anyio
async def test_telegram_delete_bot_invalid_id(client, auth_header, hood_id):
response = await client.delete("/api/hoods/1337/telegram/123", headers=auth_header)
assert response.status_code == status.HTTP_404_NOT_FOUND
response = await client.delete("/api/hoods/wrong/telegram/123", headers=auth_header)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
response = await client.delete(
"/api/hoods/{0}/telegram/7331".format(hood_id), headers=auth_header
)
assert response.status_code == status.HTTP_404_NOT_FOUND
response = await client.delete(
"/api/hoods/{0}/telegram/wrong".format(hood_id), headers=auth_header
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.parametrize(
"bot", [{"api_token": "apitoken123", "welcome_message": "msg"}]
)
@pytest.mark.anyio
async def test_telegram_delete_bot_unauthorized(client, bot, telegram):
response = await client.delete(
"/api/hoods/{0}/telegram/{1}".format(telegram.hood.id, telegram.id)
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED

View file

@ -0,0 +1,49 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
#
# SPDX-License-Identifier: 0BSD
from fastapi import status
import pytest
@pytest.mark.parametrize(
"bot", [{"api_token": "apitoken123", "welcome_message": "msg"}]
)
@pytest.mark.anyio
async def test_telegram_get_bot(client, auth_header, bot, telegram):
response = await client.get(
"/api/hoods/{0}/telegram/{1}".format(telegram.hood.id, telegram.id),
headers=auth_header,
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["id"] == telegram.id
assert response.json()["api_token"] == telegram.api_token
assert response.json()["welcome_message"] == telegram.welcome_message
@pytest.mark.anyio
async def test_telegram_get_bot_invalid_id(client, auth_header, hood_id):
response = await client.get("/api/hoods/1337/telegram/123", headers=auth_header)
assert response.status_code == status.HTTP_404_NOT_FOUND
response = await client.get("/api/hoods/wrong/telegram/123", headers=auth_header)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
response = await client.get(
"/api/hoods/{0}/telegram/7331".format(hood_id), headers=auth_header
)
assert response.status_code == status.HTTP_404_NOT_FOUND
response = await client.get(
"/api/hoods/{0}/telegram/wrong".format(hood_id), headers=auth_header
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.parametrize(
"bot", [{"api_token": "apitoken456", "welcome_message": "msg"}]
)
@pytest.mark.anyio
async def test_telegram_get_bot_unauthorized(client, bot, telegram):
response = await client.get(
"/api/hoods/{0}/telegram/{1}".format(telegram.hood.id, telegram.id)
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED

View file

@ -0,0 +1,48 @@
# Copyright (C) 2020 by Cathy Hu <cathy.hu@fau.de>
# Copyright (C) 2020 by Martin Rey <martin.rey@mailbox.org>
# Copyright (C) 2023 by Thomas Lindner <tom@dl6tom.de>
#
# SPDX-License-Identifier: 0BSD
from fastapi import status
import pytest
from kibicara.model import Hood
from kibicara.platforms.telegram.model import Telegram
@pytest.mark.anyio
async def test_telegram_get_bots(client, auth_header, hood_id):
hood = await Hood.get(id=hood_id)
telegram0 = await Telegram.create(
hood=hood,
api_token="api_token123",
welcome_message="welcome_message123",
)
telegram1 = await Telegram.create(
hood=hood,
api_token="api_token456",
welcome_message="welcome_message123",
)
response = await client.get(
"/api/hoods/{0}/telegram/".format(telegram0.hood.id), headers=auth_header
)
assert response.status_code == status.HTTP_200_OK
assert response.json()[0]["id"] == telegram0.id
assert response.json()[0]["api_token"] == telegram0.api_token
assert response.json()[1]["id"] == telegram1.id
assert response.json()[1]["api_token"] == telegram1.api_token
@pytest.mark.anyio
async def test_telegram_get_bots_invalid_id(client, auth_header, hood_id):
response = await client.get("/api/hoods/1337/telegram/", headers=auth_header)
assert response.status_code == status.HTTP_404_NOT_FOUND
response = await client.get("/api/hoods/wrong/telegram/", headers=auth_header)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.mark.anyio
async def test_telegram_get_bots_unauthorized(client, hood_id):
response = await client.get("/api/hoods/{0}/telegram/".format(hood_id))
assert response.status_code == status.HTTP_401_UNAUTHORIZED

View file

@ -32,6 +32,7 @@ speed-measure-plugin*.json
.history/* .history/*
# misc # misc
/.angular/cache
/.sass-cache /.sass-cache
/connect.lock /connect.lock
/coverage /coverage

53
frontend/README.md Normal file
View file

@ -0,0 +1,53 @@
# Kibicara Frontend
## Maintenance
### Compatibility List
** The current compatible nodejs version is nodejs18 **
To check which nodejs version is required for an angular version, see [this stackoverflow post](https://stackoverflow.com/questions/60248452/is-there-a-compatibility-list-for-angular-angular-cli-and-node-js).
### Updating Angular to the newest version
To update Angular to a newer version, please refer to the [official Angular update page](https://update.angular.io/).
tldr of the update process:
0. Check which Angular version this project is currently using by looking at the version of @angular/core in the [package.json](./package.json) file.
1. Decide to which version you want to bump (e.g. 9.2 to 15.2). This depends which node version is running on the servers and which one is compatible with the angular version (see stackoverflow post above).
2. Add all existing dependencies listed on the update page e.g. `npm run-script ng add @angular/localize`
3. Bump the versions: You need to bump to every major version, so from 9.2 to 15.2 you will need to repeat these steps for 9 -> 10 -> 11 -> 12 -> 13 -> 14 -> 15
1. Version bump to the next qangular/core and @angular/cli version (e.g. here we do it from version 9 to version 10): `npx @angular/cli@10 update @angular/core@10 @angular/cli@10`
2. Version bump material: `npx @angular/cli@10 update @angular/material@10`
3. Update ngx-markdown to the next version: `npm install ngx-markdown@10 --save-dev`
4. Test if the frontend still works and fix all errors: `npm run-script ng s -o`
4. Check the official angular update page for any breaking changes and google how to fix them: `ng generate @angular/material:mdc-migration`
## Development
### Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
### Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
### Updating the openapi frontend part
The frontend uses openapi-generator to generate the calls to the backend. To regenerate this after an API change, please run: `npm run openapi-generator`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).

View file

@ -22,7 +22,6 @@
"main": "src/main.ts", "main": "src/main.ts",
"polyfills": "src/polyfills.ts", "polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"aot": true,
"assets": ["src/favicon.ico", "src/assets"], "assets": ["src/favicon.ico", "src/assets"],
"styles": [ "styles": [
"./node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css", "./node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css",
@ -31,7 +30,13 @@
"stylePreprocessorOptions": { "stylePreprocessorOptions": {
"includePaths": ["src/sass"] "includePaths": ["src/sass"]
}, },
"scripts": [] "scripts": [],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
}, },
"configurations": { "configurations": {
"production": { "production": {
@ -44,7 +49,6 @@
"optimization": true, "optimization": true,
"outputHashing": "all", "outputHashing": "all",
"sourceMap": false, "sourceMap": false,
"extractCss": true,
"namedChunks": false, "namedChunks": false,
"extractLicenses": true, "extractLicenses": true,
"vendorChunk": false, "vendorChunk": false,
@ -62,7 +66,8 @@
} }
] ]
} }
} },
"defaultConfiguration": ""
}, },
"serve": { "serve": {
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular-devkit/build-angular:dev-server",
@ -99,17 +104,6 @@
"scripts": [] "scripts": []
} }
}, },
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"tsconfig.app.json",
"tsconfig.spec.json",
"e2e/tsconfig.json"
],
"exclude": ["**/node_modules/**", "src/app/core/api/**"]
}
},
"e2e": { "e2e": {
"builder": "@angular-devkit/build-angular:protractor", "builder": "@angular-devkit/build-angular:protractor",
"options": { "options": {
@ -124,6 +118,5 @@
} }
} }
} }
}, }
"defaultProject": "kibicara-frontend"
} }

View file

@ -3,7 +3,7 @@
"compilerOptions": { "compilerOptions": {
"outDir": "../out-tsc/e2e", "outDir": "../out-tsc/e2e",
"module": "commonjs", "module": "commonjs",
"target": "es5", "target": "es2018",
"types": [ "types": [
"jasmine", "jasmine",
"jasminewd2", "jasminewd2",

80
frontend/favicon.svg Normal file
View file

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
sodipodi:docname="favicon.svg"
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
id="svg964"
version="1.1"
viewBox="0 0 12.7 12.7"
height="48"
width="48">
<defs
id="defs958" />
<sodipodi:namedview
inkscape:window-maximized="1"
inkscape:window-y="0"
inkscape:window-x="0"
inkscape:window-height="726"
inkscape:window-width="1364"
units="px"
showgrid="true"
inkscape:document-rotation="0"
inkscape:current-layer="layer1"
inkscape:document-units="mm"
inkscape:cy="21.735033"
inkscape:cx="23.387597"
inkscape:zoom="10.229167"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
borderopacity="1.0"
bordercolor="#666666"
pagecolor="#ffffff"
id="base">
<inkscape:grid
empspacing="8"
spacingy="0.26458333"
spacingx="0.26458333"
id="grid1527"
type="xygrid" />
</sodipodi:namedview>
<metadata
id="metadata961">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1"
inkscape:groupmode="layer"
inkscape:label="Layer 1">
<circle
r="6.0854168"
cy="6.3499999"
cx="6.3499999"
id="path1529"
style="fill:#673ab7;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;fill-opacity:1" />
<text
id="text1533"
y="10.337475"
x="6.1533628"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:10.5833px;line-height:1.25;font-family:Roboto;-inkscape-font-specification:Roboto;text-align:center;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#ffffff;fill-opacity:1;stroke-width:0.264583"
xml:space="preserve"><tspan
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Roboto;-inkscape-font-specification:Roboto;fill:#ffffff;fill-opacity:1;stroke-width:0.264583"
y="10.337475"
x="6.1533628"
id="tspan1531"
sodipodi:role="line">k</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

26775
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

54
frontend/package.json Normal file
View file

@ -0,0 +1,54 @@
{
"name": "kibicara-frontend",
"version": "0.0.0",
"scripts": {
"openapi-generator": "wget http://localhost:8000/api/openapi.json && openapi-generator generate -g typescript-angular --additional-properties=providedInRoot=true -o src/app/core/api -i openapi.json && rm openapi.json",
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
"private": true,
"dependencies": {
"@angular/animations": "^15.2.3",
"@angular/cdk": "^15.2.3",
"@angular/common": "^15.2.3",
"@angular/compiler": "^15.2.3",
"@angular/core": "^15.2.3",
"@angular/forms": "^15.2.3",
"@angular/localize": "^15.2.3",
"@angular/material": "^15.2.3",
"@angular/platform-browser": "^15.2.3",
"@angular/platform-browser-dynamic": "^15.2.3",
"@angular/router": "^15.2.3",
"ng2-search-filter": "^0.5.1",
"rxjs": "~6.5.4",
"tslib": "^2.0.0",
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "^15.2.4",
"@angular/cli": "^15.2.4",
"@angular/compiler-cli": "^15.2.3",
"@angular/language-service": "^15.2.3",
"@openapitools/openapi-generator-cli": "^1.0.18-5.0.0-beta2",
"@types/jasmine": "~3.6.0",
"@types/jasminewd2": "~2.0.3",
"@types/node": "^12.12.64",
"codelyzer": "^6.0.0",
"jasmine-core": "~3.6.0",
"jasmine-spec-reporter": "~5.0.0",
"karma": "~6.4.1",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~3.0.2",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"ngx-markdown": "^15.1.2",
"protractor": "~7.0.0",
"ts-node": "~8.3.0",
"tslint": "~6.1.0",
"typescript": "~4.9.5"
}
}

View file

@ -28,7 +28,7 @@ const routes: Routes = [
]; ];
@NgModule({ @NgModule({
imports: [RouterModule.forRoot(routes), SharedModule], imports: [RouterModule.forRoot(routes, {}), SharedModule],
exports: [RouterModule], exports: [RouterModule],
}) })
export class AppRoutingModule {} export class AppRoutingModule {}

View file

@ -1,9 +1,9 @@
import { TestBed, async } from '@angular/core/testing'; import { TestBed, waitForAsync } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
describe('AppComponent', () => { describe('AppComponent', () => {
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
RouterTestingModule RouterTestingModule

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ConfirmComponent } from './confirm.component'; import { ConfirmComponent } from './confirm.component';
@ -6,7 +6,7 @@ describe('ConfirmComponent', () => {
let component: ConfirmComponent; let component: ConfirmComponent;
let fixture: ComponentFixture<ConfirmComponent>; let fixture: ComponentFixture<ConfirmComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ ConfirmComponent ] declarations: [ ConfirmComponent ]
}) })

View file

@ -1,5 +1,5 @@
<div class="container"> <div class="container">
<mat-card class="login-form"> <mat-card appearance="outlined" class="login-form">
<mat-card-header> <mat-card-header>
<h2>Log in as hood admin!</h2> <h2>Log in as hood admin!</h2>
</mat-card-header> </mat-card-header>

View file

@ -7,7 +7,7 @@
margin-top: 3%; margin-top: 3%;
} }
.mat-card:not([class*="mat-elevation-z"]) { .mat-mdc-card:not([class*="mat-elevation-z"]) {
box-shadow: none; box-shadow: none;
} }

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { LoginComponent } from './login.component'; import { LoginComponent } from './login.component';
@ -6,7 +6,7 @@ describe('LoginComponent', () => {
let component: LoginComponent; let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>; let fixture: ComponentFixture<LoginComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ LoginComponent ] declarations: [ LoginComponent ]
}) })

View file

@ -1,7 +1,7 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { LoginService } from '../../core/auth/login.service'; import { LoginService } from '../../core/auth/login.service';
import { Router, ActivatedRoute } from '@angular/router'; import { Router, ActivatedRoute } from '@angular/router';
import { Validators, FormGroup, FormBuilder } from '@angular/forms'; import { Validators, UntypedFormGroup, UntypedFormBuilder } from '@angular/forms';
import { first } from 'rxjs/operators'; import { first } from 'rxjs/operators';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
@ -11,7 +11,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
styleUrls: ['./login.component.scss'], styleUrls: ['./login.component.scss'],
}) })
export class LoginComponent implements OnInit { export class LoginComponent implements OnInit {
loginForm: FormGroup; loginForm: UntypedFormGroup;
returnUrl: string; returnUrl: string;
loading = false; loading = false;
submitted = false; submitted = false;
@ -21,7 +21,7 @@ export class LoginComponent implements OnInit {
private loginService: LoginService, private loginService: LoginService,
private router: Router, private router: Router,
private route: ActivatedRoute, private route: ActivatedRoute,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private snackBar: MatSnackBar private snackBar: MatSnackBar
) { ) {
if (this.loginService.currentHoodAdminValue) { if (this.loginService.currentHoodAdminValue) {

View file

@ -1,5 +1,5 @@
<div class="container"> <div class="container">
<mat-card class="login-form"> <mat-card appearance="outlined" class="login-form">
<mat-card-header> <mat-card-header>
<h2>Reset password</h2> <h2>Reset password</h2>
</mat-card-header> </mat-card-header>

View file

@ -6,7 +6,7 @@
margin-top: 3%; margin-top: 3%;
} }
.mat-card:not([class*="mat-elevation-z"]) { .mat-mdc-card:not([class*="mat-elevation-z"]) {
box-shadow: none; box-shadow: none;
} }

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { PasswordResetComponent } from './password-reset.component'; import { PasswordResetComponent } from './password-reset.component';
@ -6,7 +6,7 @@ describe('PasswordResetComponent', () => {
let component: PasswordResetComponent; let component: PasswordResetComponent;
let fixture: ComponentFixture<PasswordResetComponent>; let fixture: ComponentFixture<PasswordResetComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [PasswordResetComponent], declarations: [PasswordResetComponent],
}).compileComponents(); }).compileComponents();

View file

@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { first } from 'rxjs/operators'; import { first } from 'rxjs/operators';
@ -12,7 +12,7 @@ import { LoginService } from 'src/app/core/auth/login.service';
styleUrls: ['./password-reset.component.scss'], styleUrls: ['./password-reset.component.scss'],
}) })
export class PasswordResetComponent implements OnInit { export class PasswordResetComponent implements OnInit {
resetForm: FormGroup; resetForm: UntypedFormGroup;
returnUrl: string; returnUrl: string;
loading = false; loading = false;
submitted = false; submitted = false;
@ -23,7 +23,7 @@ export class PasswordResetComponent implements OnInit {
private loginService: LoginService, private loginService: LoginService,
private router: Router, private router: Router,
private route: ActivatedRoute, private route: ActivatedRoute,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private snackBar: MatSnackBar private snackBar: MatSnackBar
) { ) {
if (this.loginService.currentHoodAdminValue) { if (this.loginService.currentHoodAdminValue) {

View file

@ -1,5 +1,5 @@
<div class="container"> <div class="container">
<mat-card class="login-form"> <mat-card appearance="outlined" class="login-form">
<mat-card-header> <mat-card-header>
<h2>Enter your new password</h2> <h2>Enter your new password</h2>
</mat-card-header> </mat-card-header>

View file

@ -6,7 +6,7 @@
margin-top: 3%; margin-top: 3%;
} }
.mat-card:not([class*="mat-elevation-z"]) { .mat-mdc-card:not([class*="mat-elevation-z"]) {
box-shadow: none; box-shadow: none;
} }

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { SetPasswordComponent } from './set-password.component'; import { SetPasswordComponent } from './set-password.component';
@ -6,7 +6,7 @@ describe('SetPasswordComponent', () => {
let component: SetPasswordComponent; let component: SetPasswordComponent;
let fixture: ComponentFixture<SetPasswordComponent>; let fixture: ComponentFixture<SetPasswordComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [SetPasswordComponent], declarations: [SetPasswordComponent],
}).compileComponents(); }).compileComponents();

View file

@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { first } from 'rxjs/operators'; import { first } from 'rxjs/operators';
@ -12,7 +12,7 @@ import { LoginService } from 'src/app/core/auth/login.service';
styleUrls: ['./set-password.component.scss'], styleUrls: ['./set-password.component.scss'],
}) })
export class SetPasswordComponent implements OnInit { export class SetPasswordComponent implements OnInit {
resetForm: FormGroup; resetForm: UntypedFormGroup;
returnUrl: string; returnUrl: string;
loading = false; loading = false;
submitted = false; submitted = false;
@ -24,7 +24,7 @@ export class SetPasswordComponent implements OnInit {
private loginService: LoginService, private loginService: LoginService,
private router: Router, private router: Router,
private route: ActivatedRoute, private route: ActivatedRoute,
private formBuilder: FormBuilder, private formBuilder: UntypedFormBuilder,
private snackBar: MatSnackBar private snackBar: MatSnackBar
) { ) {
this.token = this.route.snapshot.queryParams.token; this.token = this.route.snapshot.queryParams.token;

View file

@ -1,6 +1,6 @@
<div class="container"> <div class="container">
<div class="banner"></div> <div class="banner"></div>
<mat-card class="register-form"> <mat-card appearance="outlined" class="register-form">
<mat-card-header> <mat-card-header>
<h2>Register as a hood admin!</h2> <h2>Register as a hood admin!</h2>
</mat-card-header> </mat-card-header>

View file

@ -7,7 +7,7 @@
margin-top: 3%; margin-top: 3%;
} }
.mat-card:not([class*="mat-elevation-z"]) { .mat-mdc-card:not([class*="mat-elevation-z"]) {
box-shadow: none; box-shadow: none;
} }

View file

@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { RegisterComponent } from './register.component'; import { RegisterComponent } from './register.component';
@ -6,7 +6,7 @@ describe('RegisterComponent', () => {
let component: RegisterComponent; let component: RegisterComponent;
let fixture: ComponentFixture<RegisterComponent>; let fixture: ComponentFixture<RegisterComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ RegisterComponent ] declarations: [ RegisterComponent ]
}) })

Some files were not shown because too many files have changed in this diff Show more