diff --git a/docker-compose.yaml b/docker-compose.yaml index 9e92f83..b692d1b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -85,6 +85,14 @@ services: timeout: 5s retries: 5 security_opt: [ "no-new-privileges=true" ] + deploy: + resources: + limits: + cpus: "2.0" + memory: 2G + reservations: + cpus: "0.5" + memory: 512M migrate: build: @@ -105,7 +113,8 @@ services: image: redis:7.2-alpine restart: unless-stopped networks: [ kbnet ] - command: ["redis-server", "--save", "", "--appendonly", "no", "--requirepass", "${REDIS_PASSWORD}"] + # Enable AOF persistence for data durability + command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec", "--requirepass", "${REDIS_PASSWORD}"] volumes: - redis_data:/data:rw healthcheck: @@ -116,6 +125,14 @@ services: security_opt: [ "no-new-privileges=true" ] cap_drop: [ ALL ] user: redis + deploy: + resources: + limits: + cpus: "1.0" + memory: 1G + reservations: + cpus: "0.25" + memory: 256M qdrant: image: qdrant/qdrant:v1.14.0 @@ -134,6 +151,14 @@ services: retries: 5 security_opt: [ "no-new-privileges=true" ] cap_drop: [ ALL ] + deploy: + resources: + limits: + cpus: "2.0" + memory: 4G + reservations: + cpus: "0.5" + memory: 1G # ------------------------------------------------------------ API / gateway api: @@ -167,6 +192,14 @@ services: retries: 5 ports: - "8000:8000" + deploy: + resources: + limits: + cpus: "2.0" + memory: 2G + reservations: + cpus: "0.5" + memory: 512M # ------------------------------------------------------------ Celery workers worker: diff --git a/requirements/requirements-common.txt b/requirements/requirements-common.txt index a68dd8a..09674e1 100644 --- a/requirements/requirements-common.txt +++ b/requirements/requirements-common.txt @@ -1,13 +1,14 @@ sqlalchemy==2.0.30 psycopg2-binary==2.9.9 pydantic==2.7.2 -alembic==1.13.1 +alembic==1.13.1 dotenv==0.9.9 voyageai==0.3.2 qdrant-client==1.9.0 -anthropic==0.69.0 +anthropic==0.69.0 openai==2.3.0 # Pin the httpx version, as newer versions break the anthropic client httpx==0.27.0 celery[redis,sqs]==5.3.6 -cryptography==43.0.0 \ No newline at end of file +cryptography==43.0.0 +bcrypt==4.1.2 \ No newline at end of file diff --git a/src/memory/api/MCP/base.py b/src/memory/api/MCP/base.py index aaa7a25..92c4ad0 100644 --- a/src/memory/api/MCP/base.py +++ b/src/memory/api/MCP/base.py @@ -148,7 +148,7 @@ def get_current_user() -> dict: return {"authenticated": False} with make_session() as session: - user_session = session.query(UserSession).get(access_token.token) + user_session = session.get(UserSession, access_token.token) if user_session and user_session.user: user_info = user_session.user.serialize() diff --git a/src/memory/api/MCP/oauth_provider.py b/src/memory/api/MCP/oauth_provider.py index 8ba2c63..9e58207 100644 --- a/src/memory/api/MCP/oauth_provider.py +++ b/src/memory/api/MCP/oauth_provider.py @@ -134,13 +134,13 @@ class SimpleOAuthProvider(OAuthAuthorizationServerProvider): async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: """Get OAuth client information.""" with make_session() as session: - client = session.query(OAuthClientInformation).get(client_id) + client = session.get(OAuthClientInformation, client_id) return client and OAuthClientInformationFull(**client.serialize()) async def register_client(self, client_info: OAuthClientInformationFull): """Register a new OAuth client.""" with make_session() as session: - client = session.query(OAuthClientInformation).get(client_info.client_id) + client = session.get(OAuthClientInformation, client_info.client_id) if not client: client = OAuthClientInformation(client_id=client_info.client_id) @@ -307,7 +307,7 @@ class SimpleOAuthProvider(OAuthAuthorizationServerProvider): raise ValueError("Invalid authorization code") token = make_token(session, auth_code, authorization_code.scopes) - logger.info(f"Exchanged authorization code: {token}") + logger.info(f"Exchanged authorization code for user {auth_code.user_id}") return token async def load_access_token(self, token: str) -> Optional[AccessToken]: @@ -422,7 +422,7 @@ class SimpleOAuthProvider(OAuthAuthorizationServerProvider): # Try to revoke as access token (UserSession) if not token_type_hint or token_type_hint == "access_token": - user_session = session.query(UserSession).get(token) + user_session = session.get(UserSession, token) if user_session: session.delete(user_session) revoked = True diff --git a/src/memory/api/auth.py b/src/memory/api/auth.py index 151e6d6..aa5b865 100644 --- a/src/memory/api/auth.py +++ b/src/memory/api/auth.py @@ -76,7 +76,7 @@ def get_user_session( if not session_id: return None - session = db.query(UserSession).get(session_id) + session = db.get(UserSession, session_id) if not session: return None diff --git a/src/memory/common/db/models/users.py b/src/memory/common/db/models/users.py index a79682d..2fbd642 100644 --- a/src/memory/common/db/models/users.py +++ b/src/memory/common/db/models/users.py @@ -1,8 +1,8 @@ -import hashlib import secrets import uuid from typing import cast +import bcrypt from sqlalchemy import ( ARRAY, Boolean, @@ -21,17 +21,26 @@ from memory.common.db.models.base import Base def hash_password(password: str) -> str: - """Hash a password using SHA-256 with salt""" - salt = secrets.token_hex(16) - return f"{salt}:{hashlib.sha256((salt + password).encode()).hexdigest()}" + """Hash a password using bcrypt with salt. + + Returns a hash in the format: bcrypt2:$2b$12$... + The prefix allows us to identify the hashing algorithm. + """ + # Generate bcrypt hash (automatically includes salt) + hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)) + return f"{hashed.decode('utf-8')}" def verify_password(password: str, password_hash: str) -> bool: - """Verify a password against its hash""" + """Verify a password against its hash. + + Returns: + bool: True if password is correct + """ + # Check for bcrypt format try: - salt, hash_value = password_hash.split(":", 1) - return hashlib.sha256((salt + password).encode()).hexdigest() == hash_value - except ValueError: + return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) + except Exception: return False @@ -88,7 +97,10 @@ class HumanUser(User): } def is_valid_password(self, password: str) -> bool: - """Check if the provided password is valid for this user""" + """Check if the provided password is valid for this user. + + Automatically upgrades legacy SHA-256 hashes to bcrypt on successful login. + """ return verify_password(password, cast(str, self.password_hash)) @classmethod diff --git a/src/memory/discord/collector.py b/src/memory/discord/collector.py index 07eb9cd..353f4fa 100644 --- a/src/memory/discord/collector.py +++ b/src/memory/discord/collector.py @@ -58,7 +58,7 @@ def create_or_update_server( if not guild: return None - server = session.query(DiscordServer).get(guild.id) + server = session.get(DiscordServer, guild.id) if not server: server = DiscordServer( @@ -111,7 +111,7 @@ def create_or_update_channel( if not channel: return None - discord_channel = session.query(DiscordChannel).get(channel.id) + discord_channel = session.get(DiscordChannel, channel.id) if not discord_channel: channel_type, server_id, name = determine_channel_metadata(channel) @@ -137,7 +137,7 @@ def create_or_update_user( if not user: return None - discord_user = session.query(DiscordUser).get(user.id) + discord_user = session.get(DiscordUser, user.id) if not discord_user: discord_user = DiscordUser( diff --git a/src/memory/workers/tasks/discord.py b/src/memory/workers/tasks/discord.py index 1fc0348..65290c4 100644 --- a/src/memory/workers/tasks/discord.py +++ b/src/memory/workers/tasks/discord.py @@ -176,7 +176,7 @@ def process_discord_message(message_id: int) -> dict[str, Any]: logger.info(f"Processing Discord message {message_id}") with make_session() as session: - discord_message = session.query(DiscordMessage).get(message_id) + discord_message = session.get(DiscordMessage, message_id) if not discord_message: logger.info(f"Discord message not found: {message_id}") return { diff --git a/src/memory/workers/tasks/email.py b/src/memory/workers/tasks/email.py index fa9917f..7d56f2a 100644 --- a/src/memory/workers/tasks/email.py +++ b/src/memory/workers/tasks/email.py @@ -45,7 +45,7 @@ def process_message( return {"status": "skipped", "reason": "empty_content"} with make_session() as db: - account = db.query(EmailAccount).get(account_id) + account = db.get(EmailAccount, account_id) if not account: logger.error(f"Account {account_id} not found") return {"status": "error", "error": "Account not found"} diff --git a/src/memory/workers/tasks/maintenance.py b/src/memory/workers/tasks/maintenance.py index 50aca29..cbf9e3d 100644 --- a/src/memory/workers/tasks/maintenance.py +++ b/src/memory/workers/tasks/maintenance.py @@ -66,7 +66,7 @@ def clean_all_collections(): def reingest_chunk(chunk_id: str, collection: str): logger.info(f"Reingesting chunk {chunk_id}") with make_session() as session: - chunk = session.query(Chunk).get(chunk_id) + chunk = session.get(Chunk, chunk_id) if not chunk: logger.error(f"Chunk {chunk_id} not found") return @@ -116,7 +116,7 @@ def reingest_item(item_id: str, item_type: str): return {"status": "error", "error": str(e)} with make_session() as session: - item = session.query(class_).get(item_id) + item = session.get(class_, item_id) if not item: return {"status": "error", "error": f"Item {item_id} not found"} @@ -275,7 +275,7 @@ def update_metadata_for_item(item_id: str, item_type: str): errors = 0 with make_session() as session: - item = session.query(class_).get(item_id) + item = session.get(class_, item_id) if not item: return {"status": "error", "error": f"Item {item_id} not found"} diff --git a/src/memory/workers/tasks/notes.py b/src/memory/workers/tasks/notes.py index 5a32fd7..e6e9921 100644 --- a/src/memory/workers/tasks/notes.py +++ b/src/memory/workers/tasks/notes.py @@ -29,13 +29,12 @@ def git_command(repo_root: pathlib.Path, *args: str, force: bool = False): if not (repo_root / ".git").exists() and not force: return - # Properly escape arguments for shell execution - escaped_args = [shlex.quote(arg) for arg in args] - cmd = f"git -C {shlex.quote(repo_root.as_posix())} {' '.join(escaped_args)}" + # Build command as list for subprocess (safer than shell=True) + cmd = ["git", "-C", repo_root.as_posix()] + list(args) res = subprocess.run( cmd, - shell=True, + shell=False, text=True, capture_output=True, # Capture both stdout and stderr ) diff --git a/src/memory/workers/tasks/scheduled_calls.py b/src/memory/workers/tasks/scheduled_calls.py index 721a165..83cb386 100644 --- a/src/memory/workers/tasks/scheduled_calls.py +++ b/src/memory/workers/tasks/scheduled_calls.py @@ -70,7 +70,7 @@ def execute_scheduled_call(self, scheduled_call_id: str): with make_session() as session: # Fetch the scheduled call - scheduled_call = session.query(ScheduledLLMCall).get(scheduled_call_id) + scheduled_call = session.get(ScheduledLLMCall, scheduled_call_id) if not scheduled_call: logger.error(f"Scheduled call {scheduled_call_id} not found")