diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index eb3c95f..7533458 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -3,6 +3,8 @@ name: Build, Push and Update Image in Manifest on: push: branches: [ "main" ] + paths: + - 'backend/**' workflow_dispatch: jobs: @@ -26,9 +28,9 @@ jobs: id: build uses: docker/build-push-action@v5 with: - context: ./cd-test + context: ./backend push: true - tags: ${{ secrets.DOCKER_USER }}/cd-test:latest + tags: ${{ secrets.DOCKER_USER }}/cc-app-demo:latest - name: Get image digest run: echo "IMAGE_DIGEST=${{ steps.build.outputs.digest }}" >> $GITHUB_ENV @@ -36,9 +38,9 @@ jobs: - name: Update manifest with new image digest uses: OpsVerseIO/image-updater-action@0.1.0 with: - valueFile: 'cd-test/guestbook-ui-deployment.yaml' + valueFile: 'deployment/guestbook-ui-deployment.yaml' propertyPath: 'spec.template.spec.containers[0].image' - value: ${{ secrets.DOCKER_USER }}/cd-test@${{ env.IMAGE_DIGEST }} + value: ${{ secrets.DOCKER_USER }}/cc-app-demo@${{ env.IMAGE_DIGEST }} commitChange: true message: "${{ github.event.head_commit.message }}" createPR: 'false' diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..fd3049a --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8000 +CMD ["uvicorn", "app.app:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/backend/app/app.py b/backend/app/app.py index 0cabc6b..eda7e7f 100644 --- a/backend/app/app.py +++ b/backend/app/app.py @@ -1,11 +1,24 @@ from fastapi import Depends, FastAPI +from fastapi.middleware.cors import CORSMiddleware from .db import User, create_db_and_tables from .schemas import UserCreate, UserRead, UserUpdate -from .users import auth_backend, current_active_user, fastapi_users +from .users import auth_backend, current_active_verified_user, fastapi_users app = FastAPI() +# CORS for frontend dev server +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:5173", + "http://127.0.0.1:5173", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + app.include_router( fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"] ) @@ -32,7 +45,7 @@ app.include_router( @app.get("/authenticated-route") -async def authenticated_route(user: User = Depends(current_active_user)): +async def authenticated_route(user: User = Depends(current_active_verified_user)): return {"message": f"Hello {user.email}!"} diff --git a/backend/app/queue.py b/backend/app/queue.py new file mode 100644 index 0000000..1aa13f3 --- /dev/null +++ b/backend/app/queue.py @@ -0,0 +1,47 @@ +import json +import os +from typing import Any, Dict + +import asyncio + +RABBITMQ_URL = os.getenv("RABBITMQ_URL") or ( + f"amqp://{os.getenv('RABBITMQ_USERNAME', 'user')}:" + f"{os.getenv('RABBITMQ_PASSWORD', 'bitnami123')}@" + f"{os.getenv('RABBITMQ_HOST', 'localhost')}:" + f"{os.getenv('RABBITMQ_PORT', '5672')}" +) +QUEUE_NAME = os.getenv("MAIL_QUEUE", "mail_queue") + + +async def _publish_async(message: Dict[str, Any]) -> None: + # Import locally to avoid hard dependency at import-time + import aio_pika + + connection = await aio_pika.connect_robust(RABBITMQ_URL) + try: + channel = await connection.channel() + await channel.declare_queue(QUEUE_NAME, durable=True) + body = json.dumps(message).encode("utf-8") + await channel.default_exchange.publish( + aio_pika.Message(body=body, delivery_mode=aio_pika.DeliveryMode.PERSISTENT), + routing_key=QUEUE_NAME, + ) + finally: + await connection.close() + + +def enqueue_email(to: str, subject: str, body: str) -> None: + """ + Enqueue an email to RabbitMQ. If RabbitMQ or aio_pika is not available, + this function will raise ImportError/ConnectionError. The caller may + implement fallback (e.g., direct send). + """ + message = {"type": "email", "to": to, "subject": subject, "body": body} + + try: + loop = asyncio.get_running_loop() + # Fire-and-forget task so we don't block the request path + loop.create_task(_publish_async(message)) + except RuntimeError: + # No running loop (e.g., called from sync context) – run a short loop + asyncio.run(_publish_async(message)) diff --git a/backend/app/users.py b/backend/app/users.py index edc4527..9f35f06 100644 --- a/backend/app/users.py +++ b/backend/app/users.py @@ -1,3 +1,4 @@ +import os import uuid from typing import Optional @@ -12,7 +13,9 @@ from fastapi_users.db import SQLAlchemyUserDatabase from .db import User, get_user_db -SECRET = "SECRET" +SECRET = os.getenv("SECRET", "CHANGE_ME_SECRET") +FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173") +BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000") class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): @@ -20,7 +23,8 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): verification_token_secret = SECRET async def on_after_register(self, user: User, request: Optional[Request] = None): - print(f"User {user.id} has registered.") + # Ask FastAPI Users to generate a verification token and trigger the hook below + await self.request_verify(user, request) async def on_after_forgot_password( self, user: User, token: str, request: Optional[Request] = None @@ -30,7 +34,26 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): async def on_after_request_verify( self, user: User, token: str, request: Optional[Request] = None ): - print(f"Verification requested for user {user.id}. Verification token: {token}") + # Build verification email and send through RabbitMQ (with direct SMTP fallback) + verify_frontend_link = f"{FRONTEND_URL}/verify?token={token}" + verify_backend_link = f"{BACKEND_URL}/auth/verify?token={token}" + subject = "Ověření účtu" + body = ( + "Ahoj,\n\n" + "děkujeme za registraci. Prosíme, ověř svůj účet kliknutím na tento odkaz:\n" + f"{verify_frontend_link}\n\n" + "Pokud by odkaz nefungoval, můžeš použít i přímý odkaz na backend:\n" + f"{verify_backend_link}\n\n" + "Pokud jsi registraci neprováděl(a), tento email ignoruj.\n" + ) + try: + from .queue import enqueue_email + enqueue_email(to=user.email, subject=subject, body=body) + except Exception: + # Fallback: if queue is unavailable, log the email content (dev fallback) + print("[Email Fallback] To:", user.email) + print("[Email Fallback] Subject:", subject) + print("[Email Fallback] Body:\n", body) async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)): @@ -53,3 +76,4 @@ auth_backend = AuthenticationBackend( fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend]) current_active_user = fastapi_users.current_user(active=True) +current_active_verified_user = fastapi_users.current_user(active=True, verified=True) diff --git a/requirements.txt b/backend/requirements.txt similarity index 86% rename from requirements.txt rename to backend/requirements.txt index 28a9ce5..31b4d2d 100644 --- a/requirements.txt +++ b/backend/requirements.txt @@ -1,3 +1,6 @@ +aio-pika==9.5.6 +aiormq==6.8.1 +aiosqlite==0.21.0 annotated-types==0.7.0 anyio==4.11.0 argon2-cffi==23.1.0 @@ -18,6 +21,9 @@ h11==0.16.0 httptools==0.6.4 idna==3.10 makefun==1.16.0 +multidict==6.6.4 +pamqp==3.3.0 +propcache==0.3.2 pwdlib==0.2.1 pycparser==2.23 pydantic==2.11.9 @@ -35,3 +41,4 @@ uvicorn==0.37.0 uvloop==0.21.0 watchfiles==1.1.0 websockets==15.0.1 +yarl==1.20.1 diff --git a/backend/worker/__init__.py b/backend/worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/worker/email_worker.py b/backend/worker/email_worker.py new file mode 100644 index 0000000..6d3f63c --- /dev/null +++ b/backend/worker/email_worker.py @@ -0,0 +1,57 @@ +import asyncio +import json +import os +from typing import Any, Dict + + +RABBITMQ_URL = os.getenv("RABBITMQ_URL") or ( + f"amqp://{os.getenv('RABBITMQ_USERNAME', 'user')}:" + f"{os.getenv('RABBITMQ_PASSWORD', 'bitnami123')}@" + f"{os.getenv('RABBITMQ_HOST', 'localhost')}:" + f"{os.getenv('RABBITMQ_PORT', '5672')}" +) +QUEUE_NAME = os.getenv("MAIL_QUEUE", "mail_queue") + + +async def handle_message(message_body: bytes) -> None: + try: + data: Dict[str, Any] = json.loads(message_body.decode("utf-8")) + except Exception as e: + print(f"[email_worker] Failed to decode message: {e}") + return + + if data.get("type") != "email": + print(f"[email_worker] Unknown message type: {data}") + return + + to = data.get("to") + subject = data.get("subject") + body = data.get("body") + if not (to and subject and body): + print(f"[email_worker] Incomplete email message: {data}") + return + + try: + await send_email(to=to, subject=subject, body=body) + print(f"[email_worker] Sent email to {to}") + except Exception as e: + print(f"[email_worker] Error sending email to {to}: {e}") + + +async def main() -> None: + import aio_pika + + print(f"[email_worker] Connecting to RabbitMQ at {RABBITMQ_URL}") + connection = await aio_pika.connect_robust(RABBITMQ_URL) + channel = await connection.channel() + queue = await channel.declare_queue(QUEUE_NAME, durable=True) + print(f"[email_worker] Waiting for messages in queue '{QUEUE_NAME}' ...") + + async with queue.iterator() as queue_iter: + async for message in queue_iter: + async with message.process(requeue=False): + await handle_message(message.body) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/cd-test/Dockerfile b/cd-test/Dockerfile deleted file mode 100644 index 71527f6..0000000 --- a/cd-test/Dockerfile +++ /dev/null @@ -1,2 +0,0 @@ -FROM httpd -RUN echo "

Current Skibidi Date and Skibidi Time

$(date)

" > /usr/local/apache2/htdocs/index.html diff --git a/deployment/app-demo-database-grant.yaml b/deployment/app-demo-database-grant.yaml new file mode 100644 index 0000000..e539182 --- /dev/null +++ b/deployment/app-demo-database-grant.yaml @@ -0,0 +1,19 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: Grant +metadata: + name: grant +spec: + mariaDbRef: + name: mariadb + privileges: + - "ALL PRIVILEGES" + database: "app-demo-database" + table: "*" + username: "app-demo-user" + grantOption: true + host: "%" + # Delete the resource in the database whenever the CR gets deleted. + # Alternatively, you can specify Skip in order to omit deletion. + cleanupPolicy: Skip + requeueInterval: 10h + retryInterval: 30s \ No newline at end of file diff --git a/deployment/app-demo-database-secret.yaml b/deployment/app-demo-database-secret.yaml new file mode 100644 index 0000000..d338dca --- /dev/null +++ b/deployment/app-demo-database-secret.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Secret +metadata: + name: app-demo-database-secret +type: kubernetes.io/basic-auth +stringData: + password: "strongpassword" \ No newline at end of file diff --git a/deployment/app-demo-database-user.yaml b/deployment/app-demo-database-user.yaml new file mode 100644 index 0000000..e1534ce --- /dev/null +++ b/deployment/app-demo-database-user.yaml @@ -0,0 +1,19 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: User +metadata: + name: app-demo-user +spec: + # If you want the user to be created with a different name than the resource name + # name: user-custom + mariaDbRef: + name: mariadb + passwordSecretKeyRef: + name: app-demo-database-secret + key: password + maxUserConnections: 20 + host: "%" + # Delete the resource in the database whenever the CR gets deleted. + # Alternatively, you can specify Skip in order to omit deletion. + cleanupPolicy: Skip + requeueInterval: 10h + retryInterval: 30s \ No newline at end of file diff --git a/deployment/app-demo-database.yaml b/deployment/app-demo-database.yaml new file mode 100644 index 0000000..ad49030 --- /dev/null +++ b/deployment/app-demo-database.yaml @@ -0,0 +1,14 @@ +apiVersion: k8s.mariadb.com/v1alpha1 +kind: Database +metadata: + name: app-demo-database +spec: + mariaDbRef: + name: mariadb + characterSet: utf8 + collate: utf8_general_ci + # Delete the resource in the database whenever the CR gets deleted. + # Alternatively, you can specify Skip in order to omit deletion. + cleanupPolicy: Skip + requeueInterval: 10h + retryInterval: 30s \ No newline at end of file diff --git a/cd-test/guestbook-ui-deployment.yaml b/deployment/app-demo-deployment.yaml similarity index 56% rename from cd-test/guestbook-ui-deployment.yaml rename to deployment/app-demo-deployment.yaml index a3a6686..fa4332c 100644 --- a/cd-test/guestbook-ui-deployment.yaml +++ b/deployment/app-demo-deployment.yaml @@ -1,34 +1,37 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: guestbook-ui + name: app-demo spec: replicas: 2 revisionHistoryLimit: 3 selector: matchLabels: - app: guestbook-ui + app: app-demo template: metadata: labels: - app: guestbook-ui + app: app-demo spec: containers: - - image: lukastrkan/cd-test@sha256:10590db789cef5f1a58bb603cce0b502ce2b4054af956d9b71d60e3f02045894 - name: guestbook-ui + - image: lukastrkan/cc-app-demo@sha256:10590db789cef5f1a58bb603cce0b502ce2b4054af956d9b71d60e3f02045894 + name: app-demo ports: - - containerPort: 80 + - containerPort: 8000 livenessProbe: httpGet: path: / - port: 80 + port: 8000 initialDelaySeconds: 60 periodSeconds: 10 failureThreshold: 3 readinessProbe: httpGet: path: / - port: 80 + port: 8000 initialDelaySeconds: 60 periodSeconds: 10 failureThreshold: 3 + - image: lukastrkan/cc-app-demo + name: app-demo-worker + command: [ "python3", "worker/email_worker.py" ] diff --git a/cd-test/guestbook-ui-svc.yaml b/deployment/app-demo-svc.yaml similarity index 56% rename from cd-test/guestbook-ui-svc.yaml rename to deployment/app-demo-svc.yaml index e8a4a27..127ffb0 100644 --- a/cd-test/guestbook-ui-svc.yaml +++ b/deployment/app-demo-svc.yaml @@ -1,10 +1,10 @@ apiVersion: v1 kind: Service metadata: - name: guestbook-ui + name: app-demo spec: ports: - port: 80 - targetPort: 80 + targetPort: 8000 selector: - app: guestbook-ui + app: app-demo diff --git a/cd-test/tunnel.yaml b/deployment/tunnel.yaml similarity index 72% rename from cd-test/tunnel.yaml rename to deployment/tunnel.yaml index 782c180..b0ee35e 100644 --- a/cd-test/tunnel.yaml +++ b/deployment/tunnel.yaml @@ -6,8 +6,8 @@ metadata: subjects: - name: app-server spec: - target: http://guestbook-ui.group-project.svc.cluster.local - fqdn: guestbook.ltrk.cz + target: http://app-demo.group-project.svc.cluster.local + fqdn: demo.ltrk.cz noTlsVerify: true tunnelRef: kind: ClusterTunnel