diff --git a/backend/archives.py b/backend/archives.py index 369a6717..f2919a74 100644 --- a/backend/archives.py +++ b/backend/archives.py @@ -301,7 +301,7 @@ def init_archives_api(app, mdb, users, email, user_dep: User): aid=str(archive.id), created=datetime.utcnow(), role=invite.role ) - other_user = await users.db.get_by_email(invite.email) + other_user = await users.user_db.get_by_email(invite.email) if not other_user: @@ -325,7 +325,7 @@ def init_archives_api(app, mdb, users, email, user_dep: User): other_user.invites[invite_code] = invite_pending - await users.db.update(other_user) + await users.user_db.update(other_user) return { "invited": "existing_user", @@ -338,7 +338,7 @@ def init_archives_api(app, mdb, users, email, user_dep: User): user: User = Depends(user_dep), ): - other_user = await users.db.get_by_email(update.email) + other_user = await users.user_db.get_by_email(update.email) if not other_user: raise HTTPException( status_code=400, detail="No user found for specified e-mail" @@ -359,7 +359,7 @@ def init_archives_api(app, mdb, users, email, user_dep: User): raise HTTPException(status_code=400, detail="Invalid Invite Code") await ops.add_user_by_invite(invite, user) - await users.db.update(user) + await users.user_db.update(user) return {"added": True} return ops diff --git a/backend/emailsender.py b/backend/emailsender.py index 9c3cd1ec..b55afc82 100644 --- a/backend/emailsender.py +++ b/backend/emailsender.py @@ -14,14 +14,14 @@ class EmailSender: self.password = os.environ.get("EMAIL_PASSWORD") self.smtp_server = os.environ.get("EMAIL_SMTP_HOST") - self.host = "http://localhost:8000/" + self.host = os.environ.get("APP_ORIGIN") def _send_encrypted(self, receiver, message): """Send Encrypted SMTP Message""" - print(message) + print(message, flush=True) if not self.smtp_server: - print("Email: No SMTP Server, not sending") + print("Email: No SMTP Server, not sending", flush=True) return context = ssl.create_default_context() @@ -54,3 +54,12 @@ You can join by clicking here: {self.host}/app/join/{token} The invite token is: {token}""" self._send_encrypted(receiver_email, message) + + def send_user_forgot_password(self, receiver_email, token): + """Send password reset email with token""" + message = f""" +We received your password reset request. Please click here: {self.host}/reset-password?token={token} +to create a new password + """ + + self._send_encrypted(receiver_email, message) diff --git a/backend/main.py b/backend/main.py index 8e4c1bfd..dd01fc1c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,12 +5,12 @@ supports docker and kubernetes based deployments of multiple browsertrix-crawler import os -from fastapi import FastAPI, Request, HTTPException +from fastapi import FastAPI from db import init_db from emailsender import EmailSender -from users import init_users_api, UserDB +from users import init_users_api, init_user_manager from archives import init_archives_api from storages import init_storages_api @@ -22,115 +22,69 @@ app = FastAPI() # ============================================================================ -class BrowsertrixAPI: - """ - Main class for BrowsertrixAPI - """ +def main(): + """ init browsertrix cloud api """ - # pylint: disable=too-many-instance-attributes - def __init__(self, _app): - self.app = _app + email = EmailSender() + crawl_manager = None - self.email = EmailSender() - self.crawl_manager = None + mdb = init_db() - self.mdb = init_db() + user_manager = init_user_manager(mdb, email) - self.fastapi_users = init_users_api( - self.app, - self.mdb, - self.on_after_register, - self.on_after_forgot_password, - self.on_after_verification_request, - ) + fastapi_users = init_users_api(app, user_manager) - current_active_user = self.fastapi_users.current_user(active=True) + current_active_user = fastapi_users.current_user(active=True) - self.archive_ops = init_archives_api( - self.app, self.mdb, self.fastapi_users, self.email, current_active_user - ) + archive_ops = init_archives_api( + app, mdb, user_manager, email, current_active_user + ) - # pylint: disable=import-outside-toplevel - if os.environ.get("KUBERNETES_SERVICE_HOST"): - from k8sman import K8SManager + user_manager.set_archive_ops(archive_ops) - self.crawl_manager = K8SManager() - else: - from dockerman import DockerManager + # pylint: disable=import-outside-toplevel + if os.environ.get("KUBERNETES_SERVICE_HOST"): + from k8sman import K8SManager - self.crawl_manager = DockerManager(self.archive_ops) + crawl_manager = K8SManager() + else: + from dockerman import DockerManager - init_storages_api(self.archive_ops, self.crawl_manager, current_active_user) + crawl_manager = DockerManager(archive_ops) - self.crawl_config_ops = init_crawl_config_api( - self.mdb, - current_active_user, - self.archive_ops, - self.crawl_manager, - ) + init_storages_api(archive_ops, crawl_manager, current_active_user) - self.crawls = init_crawls_api( - self.app, - self.mdb, - os.environ.get("REDIS_URL"), - self.crawl_manager, - self.crawl_config_ops, - self.archive_ops, - ) + crawl_config_ops = init_crawl_config_api( + mdb, + current_active_user, + archive_ops, + crawl_manager, + ) - self.coll_ops = init_collections_api( - self.mdb, self.crawls, self.archive_ops, self.crawl_manager - ) + crawls = init_crawls_api( + app, + mdb, + os.environ.get("REDIS_URL"), + crawl_manager, + crawl_config_ops, + archive_ops, + ) - self.crawl_config_ops.set_coll_ops(self.coll_ops) + coll_ops = init_collections_api( + mdb, crawls, archive_ops, crawl_manager + ) - self.app.include_router(self.archive_ops.router) + crawl_config_ops.set_coll_ops(coll_ops) - @app.get("/healthz") - async def healthz(): - return {} + app.include_router(archive_ops.router) - # pylint: disable=no-self-use, unused-argument - async def on_after_register(self, user: UserDB, request: Request): - """callback after registeration""" - - print(f"User {user.id} has registered.") - - req_data = await request.json() - - if req_data.get("newArchive"): - print(f"Creating new archive for {user.id}") - - archive_name = req_data.get("name") or f"{user.email} Archive" - - await self.archive_ops.create_new_archive_for_user( - archive_name=archive_name, - storage_name="default", - user=user, - ) - - if req_data.get("inviteToken"): - try: - await self.archive_ops.handle_new_user_invite( - req_data.get("inviteToken"), user - ) - except HTTPException as exc: - print(exc) - - # pylint: disable=no-self-use, unused-argument - def on_after_forgot_password(self, user: UserDB, token: str, request: Request): - """callback after password forgot""" - print(f"User {user.id} has forgot their password. Reset token: {token}") - - # pylint: disable=no-self-use, unused-argument - def on_after_verification_request(self, user: UserDB, token: str, request: Request): - """callback after verification request""" - - self.email.send_user_validation(token, user.email) + @app.get("/healthz") + async def healthz(): + return {} # ============================================================================ @app.on_event("startup") async def startup(): """init on startup""" - BrowsertrixAPI(app) + main() diff --git a/backend/requirements.txt b/backend/requirements.txt index aae38cef..8367914a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,5 @@ uvicorn -fastapi-users[mongodb]==6.0.0 +fastapi-users[mongodb]==8.1.2 loguru aiofiles kubernetes-asyncio diff --git a/backend/users.py b/backend/users.py index 620a88b2..39a085c2 100644 --- a/backend/users.py +++ b/backend/users.py @@ -4,6 +4,7 @@ FastAPI user handling (via fastapi-users) import os import uuid +import asyncio from datetime import datetime @@ -13,10 +14,14 @@ from enum import IntEnum from pydantic import BaseModel -from fastapi_users import FastAPIUsers, models +from fastapi import Request, HTTPException + +from fastapi_users import FastAPIUsers, models, BaseUserManager from fastapi_users.authentication import JWTAuthentication from fastapi_users.db import MongoDBUserDatabase + +# ============================================================================ PASSWORD_SECRET = os.environ.get("PASSWORD_SECRET", uuid.uuid4().hex) @@ -72,18 +77,75 @@ class UserDB(User, models.BaseUserDB): # ============================================================================ +# pylint: disable=too-few-public-methods class UserDBOps(MongoDBUserDatabase): """ User DB Operations wrapper """ # ============================================================================ -def init_users_api( - app, - mdb, - on_after_register=None, - on_after_forgot_password=None, - after_verification_request=None, -): +class UserManager(BaseUserManager[UserCreate, UserDB]): + """ Browsertrix UserManager """ + user_db_model = UserDB + reset_password_token_secret = PASSWORD_SECRET + verification_token_secret = PASSWORD_SECRET + + def __init__(self, user_db, email): + super().__init__(user_db) + self.email = email + self.archive_ops = None + + def set_archive_ops(self, ops): + """ set archive ops """ + self.archive_ops = ops + + # pylint: disable=no-self-use, unused-argument + async def on_after_register(self, user: UserDB, request: Optional[Request] = None): + """callback after registeration""" + + print(f"User {user.id} has registered.") + + req_data = await request.json() + + if req_data.get("newArchive"): + print(f"Creating new archive for {user.id}") + + archive_name = req_data.get("name") or f"{user.email} Archive" + + await self.archive_ops.create_new_archive_for_user( + archive_name=archive_name, + storage_name="default", + user=user, + ) + + if req_data.get("inviteToken"): + try: + await self.archive_ops.handle_new_user_invite( + req_data.get("inviteToken"), user + ) + except HTTPException as exc: + print(exc) + + asyncio.create_task(self.request_verify(user, request)) + + # pylint: disable=no-self-use, unused-argument + async def on_after_forgot_password( + self, user: UserDB, token: str, request: Optional[Request] = None + ): + """callback after password forgot""" + print(f"User {user.id} has forgot their password. Reset token: {token}") + self.email.send_user_forgot_password(user.email, token) + + # pylint: disable=no-self-use, unused-argument + async def on_after_request_verify( + self, user: UserDB, token: str, request: Optional[Request] = None + ): + """callback after verification request""" + + self.email.send_user_validation(user.email, token) + + +# ============================================================================ +def init_user_manager(mdb, emailsender): """ Load users table and init /users routes """ @@ -92,12 +154,18 @@ def init_users_api( user_db = UserDBOps(UserDB, user_collection) + return UserManager(user_db, emailsender) + + +# ============================================================================ +def init_users_api(app, user_manager): + """ init fastapi_users """ jwt_authentication = JWTAuthentication( secret=PASSWORD_SECRET, lifetime_seconds=3600, tokenUrl="/auth/jwt/login" ) fastapi_users = FastAPIUsers( - user_db, + lambda: user_manager, [jwt_authentication], User, UserCreate, @@ -111,21 +179,17 @@ def init_users_api( tags=["auth"], ) app.include_router( - fastapi_users.get_register_router(on_after_register), + fastapi_users.get_register_router(), prefix="/auth", tags=["auth"], ) app.include_router( - fastapi_users.get_reset_password_router( - PASSWORD_SECRET, after_forgot_password=on_after_forgot_password - ), + fastapi_users.get_reset_password_router(), prefix="/auth", tags=["auth"], ) app.include_router( - fastapi_users.get_verify_router( - PASSWORD_SECRET, after_verification_request=after_verification_request - ), + fastapi_users.get_verify_router(), prefix="/auth", tags=["auth"], ) diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 35730f74..d48556af 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -8,6 +8,8 @@ metadata: data: MONGO_HOST: {{ .Values.mongo_host }} + APP_ORIGIN: {{.Values.ingress.scheme }}://{{ .Values.ingress.host | default "localhost:9870" }} + CRAWLER_NAMESPACE: {{ .Values.crawler_namespace }} CRAWLER_IMAGE: {{ .Values.crawler_image }} CRAWLER_PULL_POLICY: {{ .Values.crawler_pull_policy }} diff --git a/chart/templates/secrets.yaml b/chart/templates/secrets.yaml index 12ba59dc..29cd0314 100644 --- a/chart/templates/secrets.yaml +++ b/chart/templates/secrets.yaml @@ -17,7 +17,11 @@ stringData: MC_HOST: "{{ $.Values.minio_scheme }}://{{ .access_key }}:{{ .secret_key }}@{{ $.Values.minio_host }}" {{- end }} {{- end }} - + + EMAIL_SMTP_PORT: "{{ .Values.email.smtp_port }}" + EMAIL_SMTP_HOST: "{{ .Values.email.smtp_host }}" + EMAIL_SENDER: "{{ .Values.email.sender_email }}" + EMAIL_PASSWORD: "{{ .Values.email.password }}" {{- range $storage := .Values.storages }} --- diff --git a/chart/values.yaml b/chart/values.yaml index d3607c1a..4c5076ee 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -96,6 +96,17 @@ storages: endpoint_url: "http://local-minio.default:9000/" +# Email Options +# ========================================= +email: + # email sending is enabled when 'smtp_host' is set to non-empty value + #ex: smtp_host: smtp.gmail.com + smtp_host: "" + smtp_port: 587 + sender_email: example@example.com + password: password + + # Deployment options # =========================================