commit defbd86671628a8828fe121762d10b0d390364cd Author: florian Date: Tue Sep 30 15:37:53 2025 +0200 First draft with no security xd diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0dbf2f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,170 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..13b954e --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# backend-api + diff --git a/app_database.sql b/app_database.sql new file mode 100644 index 0000000..d17dd56 --- /dev/null +++ b/app_database.sql @@ -0,0 +1,33 @@ +CREATE TABLE users ( + user_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash CHAR(64) DEFAULT NULL, -- SHA256 hash or similar + api_key CHAR(64) UNIQUE NOT NULL, -- for API authentication + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP NULL DEFAULT NULL, + status ENUM('active','inactive','banned') NOT NULL DEFAULT 'active' +); + +CREATE TABLE device_tokens ( + token_id CHAR(36) NOT NULL PRIMARY KEY, -- UUID + user_id BIGINT NOT NULL, -- Foreign key + platform ENUM('ios','android','web') NOT NULL, + token VARBINARY(512) NOT NULL, -- encrypted token + status ENUM('active','invalid','expired') NOT NULL DEFAULT 'active', + app_version VARCHAR(50), + locale VARCHAR(10), + topics VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen_at TIMESTAMP NULL DEFAULT NULL, + last_successful_delivery TIMESTAMP NULL DEFAULT NULL, + last_failed_delivery TIMESTAMP NULL DEFAULT NULL, + + CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(user_id) + ON DELETE CASCADE +); + +-- Indexes for performance +CREATE INDEX idx_user_id ON device_tokens(user_id); +CREATE INDEX idx_platform ON device_tokens(platform); +CREATE INDEX idx_topics ON device_tokens(topics); \ No newline at end of file diff --git a/db.py b/db.py new file mode 100644 index 0000000..672f529 --- /dev/null +++ b/db.py @@ -0,0 +1,42 @@ +import mysql.connector +from mysql.connector import pooling +import threading + +MYSQL_CONFIG = { + "host": "localhost", + "user": "florian", + "password": "password123++", + "database": "app" +} + +# Lock to ensure thread-safe pool creation +_pool_lock = threading.Lock() +_connection_pool = None + +def get_connection_pool(): + global _connection_pool + with _pool_lock: + if _connection_pool is None: + _connection_pool = mysql.connector.pooling.MySQLConnectionPool( + pool_name="mypool", + pool_size=5, + pool_reset_session=True, + **MYSQL_CONFIG + ) + return _connection_pool + +# Dependency for FastAPI +def get_db(): + pool = get_connection_pool() + conn = pool.get_connection() + try: + yield conn + finally: + conn.close() + +if __name__ == "__main__": + # Manual test + for conn in get_db(): + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT NOW() AS ts") + print(cursor.fetchone()) diff --git a/hvac_handler.py b/hvac_handler.py new file mode 100644 index 0000000..e2f3deb --- /dev/null +++ b/hvac_handler.py @@ -0,0 +1,35 @@ +import base64 +import hvac + + +client = hvac.Client( + url='http://127.0.0.1:8200', + token='root' +) + +def encrypt_token(token: str) -> str: + response = client.secrets.transit.encrypt_data( + name='push-tokens', + plaintext=base64.b64encode(token.encode()).decode() + ) + return response['data']['ciphertext'] + + +# Decrypt a device token (for worker use) +def decrypt_token(ciphertext: str) -> str: + response = client.secrets.transit.decrypt_data( + name='push-tokens', + ciphertext=ciphertext + ) + plaintext_b64 = response['data']['plaintext'] + return base64.b64decode(plaintext_b64).decode() + + +if __name__ == "__main__": + #token = "fcm_or_apns_token_here" + token = "honk" + encrypted = encrypt_token(token) + print("Encrypted:", encrypted) + + decrypted = decrypt_token(encrypted) + print("Decrypted:", decrypted) diff --git a/main.py b/main.py new file mode 100644 index 0000000..164ede2 --- /dev/null +++ b/main.py @@ -0,0 +1,117 @@ +from fastapi import FastAPI, Query, Depends, HTTPException, Header +from fastapi.security.api_key import APIKeyHeader +from typing import Optional,List,Dict +from pydantic import BaseModel +from validator import is_valid_platform,is_valid_token,verify_api_key +from hvac_handler import encrypt_token +from db import get_db +import logging +import uuid +from rabbitmq_handler import send_message_to_rmq + + +logger = logging.getLogger(__name__) + +api_key_header = APIKeyHeader(name="X-API-Key") +api_key_header_internal = APIKeyHeader(name="X-API-Key-Internal") + +class TokenRequest(BaseModel): + user_id : int + token : str + platform : str + app_ver : str + locale : Optional[str] = None + topics : Optional[List[str]] = None + +class Notification(BaseModel): + user_id : int + message : Dict + +api = FastAPI( + title="Device Token Management", + description="API for requesting tokens", + version="1.0.0" +) + +def verify_api_key_dependency_internal(): + return True + +def verify_api_key_dependency(db=Depends(get_db), api_key: str = Depends(api_key_header)) -> int: + cursor = db.cursor() + cursor.execute("SELECT user_id, api_key FROM users WHERE status = 'active'") + for user_id, hashed_key in cursor.fetchall(): + if verify_api_key(api_key=api_key, hashed=hashed_key): + return user_id + raise HTTPException(status_code=403, detail="Unauthorized here") + + +@api.post("/register_token") +def register_token( + request_data: TokenRequest, + db = Depends(get_db), + user_id: int = Depends(verify_api_key_dependency) +): + logger.info(f"Registering token for user_id={user_id}, platform={request_data.platform}") + if not is_valid_platform(request_data.platform) or not is_valid_token(request_data.token): + raise HTTPException(status_code=403,detail="Unathorized") + + secure_token = encrypt_token(request_data.token) + try: + cursor = db.cursor() + cursor.execute("SELECT * FROM device_tokens WHERE token = %s", (secure_token,)) + existing = cursor.fetchone() + + if existing: + cursor.execute(""" + UPDATE device_tokens + SET user_id=%s, platform=%s, app_ver=%s, locale=%s, topics=%s, last_seen_at=NOW() + WHERE token=%s + """, (user_id, request_data.platform, request_data.app_ver, + request_data.locale, request_data.topics, secure_token)) + else: + token_id = str(uuid.uuid4()) + logger.info(f"Creating new entry user_id={user_id}, token_id={token_id}") + cursor.execute(""" + INSERT INTO device_tokens (token_id,user_id, platform, token, status, app_ver, locale, topics, created_at) + VALUES (%s,%s, %s, %s, %s, %s, %s, %s, NOW()) + """, (token_id,user_id, request_data.platform, secure_token,'active', + request_data.app_ver, request_data.locale, request_data.topics)) + + db.commit() + logger.info(f"Success: Registering token for user_id={user_id}, platform={request_data.platform}") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + return {"status":"registered"} + + +@api.post("/unregister-token") +def unregister_token( + request_data: TokenRequest, + db = Depends(get_db), + user_id: int = Depends(verify_api_key_dependency) +): + logger.info(f"Unregistering token for user_id={user_id}, platform={request_data.platform}") + secure_token = encrypt_token(request_data.token) + try: + cursor = db.cursor() + cursor.execute(""" + UPDATE device_tokens + SET status=%s, last_seen_at=NOW() + WHERE token=%s + """, ('expired', secure_token)) + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + logger.info(f"Success: Unregistering token for user_id={user_id}, platform={request_data.platform}") + return {"status":"unregistered"} + + +@api.post("/internal/receive-notifications") +def receive_notifications( + notification_data: Notification, + db = Depends(get_db), + is_allowed: bool = Depends(verify_api_key_dependency_internal) +): + send_message_to_rmq(notification_data.user_id,notification_data.message) + return {"status": "queued"} \ No newline at end of file diff --git a/rabbitmq.md b/rabbitmq.md new file mode 100644 index 0000000..df91c04 --- /dev/null +++ b/rabbitmq.md @@ -0,0 +1,29 @@ +rabbitmqctl add_vhost app_notifications +rabbitmqctl add_user notifier strongpassword +rabbitmqctl set_user_tags notifier management +rabbitmqctl set_permissions -p app_notifications notifier ".*" ".*" ".*" +rabbitmqadmin --username "admin" --password "admin" declare exchange --vhost "app_notifications" --name "app_notifications" --type "topic" --durable "true" +rabbitmqadmin --username "admin" --password "admin" declare queue --vhost "app_notifications" --name "notifications_retry" + --durable "true" + rabbitmqadmin --username "admin" --password "admin" declare queue --vhost "app_notifications" --name "notifications_dlq" + --durable "true" +rabbitmqadmin --username "admin" --password "admin" declare queue --vhost "app_notifications" --name "notifications" + --durable "true" + rabbitmqadmin --username "admin" --password "admin" declare binding --vhost "app_notifications" --source "app_notifications" --destination "notifications" --destination-type "queue" --routing-key "notify.*" + + # Retry policy: messages stay for 30s before going back to main queue +rabbitmqctl set_policy \ + --vhost app_notifications \ + retry_policy "^notifications_retry$" \ + '{"dead-letter-exchange":"app_notifications", + "dead-letter-routing-key":"notify.retry", + "message-ttl":30000}' \ + --apply-to queues + +# DLQ policy: permanent dead letter storage +rabbitmqctl set_policy \ + --vhost app_notifications \ + dlq_policy "^notifications$" \ + '{"dead-letter-exchange":"app_notifications", + "dead-letter-routing-key":"notify.dlq"}' \ + --apply-to queues \ No newline at end of file diff --git a/rabbitmq_handler.py b/rabbitmq_handler.py new file mode 100644 index 0000000..d617614 --- /dev/null +++ b/rabbitmq_handler.py @@ -0,0 +1,21 @@ +import pika +from typing import Dict +import json + +def send_message_to_rmq(user_id: int, message: Dict): + credentials = pika.credentials.PlainCredentials(username="notifier",password="strongpassword") + conn_params = pika.ConnectionParameters(host="localhost", + credentials=credentials,virtual_host="app_notifications") + connection = pika.BlockingConnection(conn_params) + #connection = pika.BlockingConnection(pika.ConnectionParameters('localhost')) + channel = connection.channel() + channel.confirm_delivery() + + channel.basic_publish(exchange='app_notifications', + routing_key=f"notify.user.{user_id}", + body=json.dumps(message), + properties=pika.BasicProperties( + content_type="application/json", + delivery_mode=2 + )) + diff --git a/validator.py b/validator.py new file mode 100644 index 0000000..1a3afdd --- /dev/null +++ b/validator.py @@ -0,0 +1,33 @@ +from argon2 import PasswordHasher + +def is_valid_platform(platform): + if platform not in ["ios","android","web"]: + return False + return True + +def is_valid_token(token): #Later check for specific Firebase tokens + """ + Correct length + No malicious characters + Freshness? + """ + return True + +ph = PasswordHasher() + +def hash_api_key(api_key: str) -> str: + return ph.hash(api_key) + +def verify_api_key(api_key: str, hashed: str) -> bool: + try: + return ph.verify(hashed, api_key) + except Exception: + return False + +if __name__=="__main__": + plain_key = "super-secret-api-key" + #hashed_key = hash_api_key(plain_key) + hashed_key = '$argon2id$v=19$m=65536,t=3,p=4$vqU+MRafVW1b8AtF+zHb0w$p1J4Gyb0jhlVtKgYyjTITxfU97YaayeS3s3qFFP5sVM' + + print("Hashed API Key:", hashed_key) + print("Verification:", verify_api_key(plain_key, hashed_key))