mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Add multi-service Docker setup, Postgres migrations, and messaging API cleanup
This commit is contained in:
@@ -8,7 +8,6 @@ Perform a comprehensive security audit of the FromChat application codebase.
|
|||||||
|
|
||||||
- React/TypeScript frontend
|
- React/TypeScript frontend
|
||||||
- Python FastAPI backend
|
- Python FastAPI backend
|
||||||
- End-to-end encryption for DMs and calls
|
|
||||||
- Caddy reverse proxy with security headers
|
- Caddy reverse proxy with security headers
|
||||||
- WebSocket support for real-time features
|
- WebSocket support for real-time features
|
||||||
- Electron support for desktop app
|
- Electron support for desktop app
|
||||||
@@ -122,7 +121,7 @@ Provide a **clean, concise report** with:
|
|||||||
- CORS configuration in backend/app.py
|
- CORS configuration in backend/app.py
|
||||||
- Password validation in backend/validation.py
|
- Password validation in backend/validation.py
|
||||||
- JWT token generation and validation
|
- JWT token generation and validation
|
||||||
- E2E encryption implementation (NaCl, AES-GCM)
|
- Encryption implementation (NaCl, AES-GCM)
|
||||||
- File upload sanitization
|
- File upload sanitization
|
||||||
- Authorization checks on sensitive endpoints
|
- Authorization checks on sensitive endpoints
|
||||||
- Rate limiting configuration
|
- Rate limiting configuration
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ When working with this project, follow these rules:
|
|||||||
## File Operations
|
## File Operations
|
||||||
- If possible, try to update files in a single edit when making multiple changes.
|
- If possible, try to update files in a single edit when making multiple changes.
|
||||||
- Do NOT "cd" to the project directory.
|
- Do NOT "cd" to the project directory.
|
||||||
|
- NEVER edit/delete/regenerate .env files without explicit permission.
|
||||||
|
|
||||||
## Testing & Validation
|
## Testing & Validation
|
||||||
- Do NOT "test the implementation" when you are done. The only exception is when you
|
- Do NOT "test the implementation" when you are done. The only exception is when you
|
||||||
|
|||||||
@@ -581,3 +581,5 @@ backend/alembic/**
|
|||||||
tmp
|
tmp
|
||||||
compliance_keypair.txt
|
compliance_keypair.txt
|
||||||
backend/files
|
backend/files
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ PyJWT>=2.8.0
|
|||||||
fastapi[standard]>=0.116.1
|
fastapi[standard]>=0.116.1
|
||||||
pydantic>=2.11.7
|
pydantic>=2.11.7
|
||||||
sqlalchemy>=2.0.43
|
sqlalchemy>=2.0.43
|
||||||
|
psycopg2-binary>=2.9.9
|
||||||
bcrypt>=4.3.0
|
bcrypt>=4.3.0
|
||||||
websockets>=15.0.1
|
websockets>=15.0.1
|
||||||
Pillow>=10.0.0
|
Pillow>=10.0.0
|
||||||
@@ -15,3 +16,4 @@ httpx>=0.27.2
|
|||||||
rich>=13.9.4
|
rich>=13.9.4
|
||||||
slowapi>=0.1.9
|
slowapi>=0.1.9
|
||||||
firebase_admin>=7.1.0
|
firebase_admin>=7.1.0
|
||||||
|
PyNaCl>=1.5.0
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
# Database is always in backend/data/ relative to project root
|
# Database URL from environment (Docker) or fallback to SQLite (development)
|
||||||
DATABASE_URL = "sqlite:///" + os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "data", "database.db")
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///" + os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "data", "database.db"))
|
||||||
JWT_ALGORITHM = "HS256"
|
JWT_ALGORITHM = "HS256"
|
||||||
# Token inactivity expiration - token expires if not used for this duration
|
# Token inactivity expiration - token expires if not used for this duration
|
||||||
TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity
|
TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
from sqlalchemy.orm.exc import DetachedInstanceError
|
||||||
|
|
||||||
# Import from same directory
|
# Import from same directory
|
||||||
from .routes import account, messaging, profile, push, webrtc, devices, moderation, download, keys, envelope_messaging
|
from .routes import account, messaging, profile, push, webrtc, devices, moderation, download, keys, envelope_messaging
|
||||||
@@ -166,9 +167,46 @@ if not _running_in_docker():
|
|||||||
logger.warning(f"Failed to mount internal services for development: {e}")
|
logger.warning(f"Failed to mount internal services for development: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_username_for_log(user) -> str | None:
|
||||||
|
"""
|
||||||
|
Safely extract username for access logs.
|
||||||
|
|
||||||
|
If the ORM instance is detached, we transparently open a short-lived session,
|
||||||
|
reload the user by ID and read the username from that fresh instance.
|
||||||
|
Logging must never break request handling.
|
||||||
|
"""
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Fast path: instance is still bound to a session.
|
||||||
|
try:
|
||||||
|
return getattr(user, "username", None)
|
||||||
|
except DetachedInstanceError:
|
||||||
|
# Session is gone; try to reload user by primary key.
|
||||||
|
try:
|
||||||
|
user_id = getattr(user, "id", None)
|
||||||
|
except Exception:
|
||||||
|
user_id = None
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
fresh = db.query(User).filter(User.id == user_id).first()
|
||||||
|
return getattr(fresh, "username", None) if fresh is not None else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
# Fall back to no user information if anything else goes wrong.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def access_logging_middleware(request: Request, call_next):
|
async def access_logging_middleware(request: Request, call_next):
|
||||||
# Log incoming request and Authorization header presence for debugging auth issues
|
# Log incoming request and Authorization header presence for debugging auth issues
|
||||||
|
# Skip logging for health check requests
|
||||||
|
if request.url.path != "/health":
|
||||||
try:
|
try:
|
||||||
auth_header = request.headers.get("authorization")
|
auth_header = request.headers.get("authorization")
|
||||||
if auth_header:
|
if auth_header:
|
||||||
@@ -189,7 +227,7 @@ async def access_logging_middleware(request: Request, call_next):
|
|||||||
method=request.method,
|
method=request.method,
|
||||||
path=request.url.path,
|
path=request.url.path,
|
||||||
status="error",
|
status="error",
|
||||||
user=getattr(user, "username", None),
|
user=_get_username_for_log(user),
|
||||||
ip=get_client_ip(request),
|
ip=get_client_ip(request),
|
||||||
duration=f"{duration:.3f}s",
|
duration=f"{duration:.3f}s",
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
@@ -203,7 +241,7 @@ async def access_logging_middleware(request: Request, call_next):
|
|||||||
method=request.method,
|
method=request.method,
|
||||||
path=request.url.path,
|
path=request.url.path,
|
||||||
status=response.status_code,
|
status=response.status_code,
|
||||||
user=getattr(user, "username", None),
|
user=_get_username_for_log(user),
|
||||||
ip=get_client_ip(request),
|
ip=get_client_ip(request),
|
||||||
duration=f"{duration:.3f}s",
|
duration=f"{duration:.3f}s",
|
||||||
)
|
)
|
||||||
@@ -252,6 +290,12 @@ app.include_router(download.router)
|
|||||||
app.include_router(keys.router)
|
app.include_router(keys.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Health check endpoint for Docker health checks."""
|
||||||
|
return {"status": "healthy", "service": "main"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/key/public")
|
@app.get("/key/public")
|
||||||
async def key_public_proxy():
|
async def key_public_proxy():
|
||||||
"""
|
"""
|
||||||
@@ -261,9 +305,7 @@ async def key_public_proxy():
|
|||||||
return await keys.get_public_key()
|
return await keys.get_public_key()
|
||||||
|
|
||||||
|
|
||||||
@app.post("/key/invalidate")
|
if __name__ == "__main__":
|
||||||
async def key_invalidate_proxy():
|
import uvicorn
|
||||||
"""
|
port = int(os.getenv("PORT", "8300"))
|
||||||
Proxy endpoint to invalidate messaging ephemeral key.
|
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||||
"""
|
|
||||||
return await keys.invalidate_key()
|
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ import logging
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_all_model_tables():
|
||||||
|
"""Create any model tables that do not exist (e.g. dm_edit_history added after migrations)."""
|
||||||
|
engine = _create_engine_with_retry()
|
||||||
|
Base = _load_models_base()
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
logger.info("Ensured all model tables exist.")
|
||||||
|
|
||||||
|
|
||||||
def run_migrations():
|
def run_migrations():
|
||||||
"""
|
"""
|
||||||
Run database migrations using Alembic.
|
Run database migrations using Alembic.
|
||||||
@@ -128,6 +137,13 @@ def run_migrations():
|
|||||||
engine = _create_engine_with_retry()
|
engine = _create_engine_with_retry()
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
# Check for PostgreSQL or SQLite
|
||||||
|
if 'postgresql' in DATABASE_URL.lower():
|
||||||
|
result = connection.execute(text("""
|
||||||
|
SELECT tablename as name FROM pg_tables
|
||||||
|
WHERE schemaname = 'public' AND tablename != 'alembic_version'
|
||||||
|
"""))
|
||||||
|
else:
|
||||||
result = connection.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name != 'alembic_version'"))
|
result = connection.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name != 'alembic_version'"))
|
||||||
existing_tables = result.fetchall()
|
existing_tables = result.fetchall()
|
||||||
|
|
||||||
@@ -204,6 +220,7 @@ def run_migrations():
|
|||||||
try:
|
try:
|
||||||
command.upgrade(alembic_cfg, "head")
|
command.upgrade(alembic_cfg, "head")
|
||||||
logger.info("Database migrations completed successfully.")
|
logger.info("Database migrations completed successfully.")
|
||||||
|
_ensure_all_model_tables()
|
||||||
except Exception as upgrade_error:
|
except Exception as upgrade_error:
|
||||||
if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error):
|
if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error):
|
||||||
logger.info("Found 'direct_creation' revision - resetting migration state...")
|
logger.info("Found 'direct_creation' revision - resetting migration state...")
|
||||||
@@ -237,6 +254,7 @@ def run_migrations():
|
|||||||
# Try upgrade again
|
# Try upgrade again
|
||||||
command.upgrade(alembic_cfg, "head")
|
command.upgrade(alembic_cfg, "head")
|
||||||
logger.info("Database migrations completed successfully after reset.")
|
logger.info("Database migrations completed successfully after reset.")
|
||||||
|
_ensure_all_model_tables()
|
||||||
elif "no such table" in str(upgrade_error).lower():
|
elif "no such table" in str(upgrade_error).lower():
|
||||||
logger.info("Database tables missing - resetting migration state...")
|
logger.info("Database tables missing - resetting migration state...")
|
||||||
# Clear the alembic_version table and start fresh
|
# Clear the alembic_version table and start fresh
|
||||||
@@ -249,6 +267,7 @@ def run_migrations():
|
|||||||
# Try upgrade again
|
# Try upgrade again
|
||||||
command.upgrade(alembic_cfg, "head")
|
command.upgrade(alembic_cfg, "head")
|
||||||
logger.info("Database migrations completed successfully after reset.")
|
logger.info("Database migrations completed successfully after reset.")
|
||||||
|
_ensure_all_model_tables()
|
||||||
else:
|
else:
|
||||||
raise upgrade_error
|
raise upgrade_error
|
||||||
|
|
||||||
@@ -286,6 +305,7 @@ def run_migrations():
|
|||||||
|
|
||||||
# Try upgrade again
|
# Try upgrade again
|
||||||
command.upgrade(alembic_cfg, "head")
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
_ensure_all_model_tables()
|
||||||
logger.info("Automated recovery completed successfully.")
|
logger.info("Automated recovery completed successfully.")
|
||||||
else:
|
else:
|
||||||
# No migration files, create fresh ones
|
# No migration files, create fresh ones
|
||||||
@@ -294,6 +314,7 @@ def run_migrations():
|
|||||||
|
|
||||||
# Run the migration
|
# Run the migration
|
||||||
command.upgrade(alembic_cfg, "head")
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
_ensure_all_model_tables()
|
||||||
logger.info("Automated recovery completed successfully.")
|
logger.info("Automated recovery completed successfully.")
|
||||||
|
|
||||||
except Exception as recovery_error:
|
except Exception as recovery_error:
|
||||||
@@ -600,6 +621,8 @@ def _create_database_directly():
|
|||||||
else:
|
else:
|
||||||
# Table doesn't exist, create it
|
# Table doesn't exist, create it
|
||||||
logger.info(f"Creating table {table_name}")
|
logger.info(f"Creating table {table_name}")
|
||||||
|
from sqlalchemy.schema import CreateTable
|
||||||
|
connection.execute(CreateTable(Base.metadata.tables[table_name]))
|
||||||
|
|
||||||
# Create alembic_version table manually
|
# Create alembic_version table manually
|
||||||
connection.execute(text("""
|
connection.execute(text("""
|
||||||
@@ -623,9 +646,18 @@ def _create_database_directly():
|
|||||||
revision_match = re.search(r"revision: str = '([^']+)'", content)
|
revision_match = re.search(r"revision: str = '([^']+)'", content)
|
||||||
if revision_match:
|
if revision_match:
|
||||||
revision_id = revision_match.group(1)
|
revision_id = revision_match.group(1)
|
||||||
|
if 'postgresql' in DATABASE_URL.lower():
|
||||||
|
connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}') ON CONFLICT DO NOTHING"))
|
||||||
|
else:
|
||||||
connection.execute(text(f"INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('{revision_id}')"))
|
connection.execute(text(f"INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('{revision_id}')"))
|
||||||
|
else:
|
||||||
|
if 'postgresql' in DATABASE_URL.lower():
|
||||||
|
connection.execute(text("INSERT INTO alembic_version (version_num) VALUES ('direct_creation') ON CONFLICT DO NOTHING"))
|
||||||
else:
|
else:
|
||||||
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
||||||
|
else:
|
||||||
|
if 'postgresql' in DATABASE_URL.lower():
|
||||||
|
connection.execute(text("INSERT INTO alembic_version (version_num) VALUES ('direct_creation') ON CONFLICT DO NOTHING"))
|
||||||
else:
|
else:
|
||||||
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
||||||
|
|
||||||
@@ -634,18 +666,25 @@ def _create_database_directly():
|
|||||||
|
|
||||||
def _get_sql_type(column):
|
def _get_sql_type(column):
|
||||||
"""Get SQL type for direct SQL execution."""
|
"""Get SQL type for direct SQL execution."""
|
||||||
type_name = column.type.__class__.__name__
|
from sqlalchemy import String, Integer, Text, Boolean, DateTime
|
||||||
|
import os
|
||||||
|
|
||||||
if type_name == 'String':
|
# Check if we're using PostgreSQL
|
||||||
|
is_postgres = 'postgresql' in os.getenv('DATABASE_URL', '').lower()
|
||||||
|
|
||||||
|
if isinstance(column.type, String):
|
||||||
|
if column.type.length:
|
||||||
return f"VARCHAR({column.type.length})"
|
return f"VARCHAR({column.type.length})"
|
||||||
elif type_name == 'Integer':
|
else:
|
||||||
return "INTEGER"
|
|
||||||
elif type_name == 'Text':
|
|
||||||
return "TEXT"
|
return "TEXT"
|
||||||
elif type_name == 'Boolean':
|
elif isinstance(column.type, Integer):
|
||||||
|
return "INTEGER"
|
||||||
|
elif isinstance(column.type, Text):
|
||||||
|
return "TEXT"
|
||||||
|
elif isinstance(column.type, Boolean):
|
||||||
return "BOOLEAN"
|
return "BOOLEAN"
|
||||||
elif type_name == 'DateTime':
|
elif isinstance(column.type, DateTime):
|
||||||
return "DATETIME"
|
return "TIMESTAMP" if is_postgres else "DATETIME"
|
||||||
else:
|
else:
|
||||||
return "TEXT" # fallback
|
return "TEXT" # fallback
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class DMEnvelope(Base):
|
|||||||
timestamp = Column(DateTime, default=datetime.now)
|
timestamp = Column(DateTime, default=datetime.now)
|
||||||
is_edited = Column(Boolean, default=False)
|
is_edited = Column(Boolean, default=False)
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
deleted_at = Column(DateTime, nullable=True) # Soft delete timestamp
|
||||||
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
||||||
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
|
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
|
||||||
|
|
||||||
@@ -241,8 +242,6 @@ class DMEditHistoryResponse(BaseModel):
|
|||||||
dm_envelope_id: int
|
dm_envelope_id: int
|
||||||
previous_ciphertext_b64: str
|
previous_ciphertext_b64: str
|
||||||
previous_iv_b64: str
|
previous_iv_b64: str
|
||||||
previous_sender_wrapped_mek_b64: str
|
|
||||||
previous_recipient_wrapped_mek_b64: str
|
|
||||||
previous_compliance_wrapped_mek_b64: str
|
previous_compliance_wrapped_mek_b64: str
|
||||||
edited_at: str
|
edited_at: str
|
||||||
edited_by_username: str
|
edited_by_username: str
|
||||||
@@ -374,8 +373,6 @@ class DMEditHistory(Base):
|
|||||||
dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False) # Match existing DB schema
|
dm_envelope_id = Column(Integer, ForeignKey("dm_envelope.id"), nullable=False) # Match existing DB schema
|
||||||
previous_ciphertext_b64 = Column(Text, nullable=False) # Encrypted content before this edit
|
previous_ciphertext_b64 = Column(Text, nullable=False) # Encrypted content before this edit
|
||||||
previous_iv_b64 = Column(Text, nullable=False) # IV for previous content
|
previous_iv_b64 = Column(Text, nullable=False) # IV for previous content
|
||||||
previous_sender_wrapped_mek_b64 = Column(Text, nullable=False) # MEK wrapped for sender before edit
|
|
||||||
previous_recipient_wrapped_mek_b64 = Column(Text, nullable=False) # MEK wrapped for recipient before edit
|
|
||||||
previous_compliance_wrapped_mek_b64 = Column(Text, nullable=False) # MEK wrapped for compliance before edit
|
previous_compliance_wrapped_mek_b64 = Column(Text, nullable=False) # MEK wrapped for compliance before edit
|
||||||
edited_at = Column(DateTime, default=datetime.now, nullable=False, index=True)
|
edited_at = Column(DateTime, default=datetime.now, nullable=False, index=True)
|
||||||
edited_by = Column(Integer, ForeignKey("user.id"), nullable=False) # Match existing DB schema
|
edited_by = Column(Integer, ForeignKey("user.id"), nullable=False) # Match existing DB schema
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ async def store_encrypted_file(
|
|||||||
encrypted_file_data_b64: str,
|
encrypted_file_data_b64: str,
|
||||||
filename: str,
|
filename: str,
|
||||||
content_type: str = "application/octet-stream",
|
content_type: str = "application/octet-stream",
|
||||||
|
sender_id: int = None,
|
||||||
|
recipient_id: int = None,
|
||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -205,10 +207,17 @@ async def store_encrypted_file(
|
|||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
import httpx
|
import httpx
|
||||||
|
allowed_user_ids = []
|
||||||
|
if sender_id is not None:
|
||||||
|
allowed_user_ids.append(sender_id)
|
||||||
|
if recipient_id is not None:
|
||||||
|
allowed_user_ids.append(recipient_id)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"data_b64": encrypted_file_data_b64,
|
"data_b64": encrypted_file_data_b64,
|
||||||
"content_type": content_type,
|
"content_type": content_type,
|
||||||
|
"allowed_user_ids": allowed_user_ids,
|
||||||
}
|
}
|
||||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
r = await client.post(url, json=payload)
|
r = await client.post(url, json=payload)
|
||||||
@@ -216,10 +225,17 @@ async def store_encrypted_file(
|
|||||||
return r.json()
|
return r.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
from urllib import request
|
from urllib import request
|
||||||
|
allowed_user_ids = []
|
||||||
|
if sender_id is not None:
|
||||||
|
allowed_user_ids.append(sender_id)
|
||||||
|
if recipient_id is not None:
|
||||||
|
allowed_user_ids.append(recipient_id)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"data_b64": encrypted_file_data_b64,
|
"data_b64": encrypted_file_data_b64,
|
||||||
"content_type": content_type,
|
"content_type": content_type,
|
||||||
|
"allowed_user_ids": allowed_user_ids,
|
||||||
}
|
}
|
||||||
req = request.Request(url, method="POST")
|
req = request.Request(url, method="POST")
|
||||||
req.data = json.dumps(payload).encode("utf-8")
|
req.data = json.dumps(payload).encode("utf-8")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on message deletion, and configurable retention policies for cryptographic keys.
|
|||||||
|
|
||||||
Key Features:
|
Key Features:
|
||||||
- Automatic compliance key destruction (default: 6 months)
|
- Automatic compliance key destruction (default: 6 months)
|
||||||
- Selective key destruction on message deletion
|
- Selective key destruction on message deletion (default: 6 months)
|
||||||
- Configurable retention policies
|
- Configurable retention policies
|
||||||
- Background cleanup jobs for expired keys
|
- Background cleanup jobs for expired keys
|
||||||
"""
|
"""
|
||||||
@@ -24,7 +24,7 @@ logger = logging.getLogger("uvicorn.error")
|
|||||||
|
|
||||||
# Default retention periods (in days)
|
# Default retention periods (in days)
|
||||||
DEFAULT_COMPLIANCE_KEY_RETENTION_DAYS = 180 # 6 months
|
DEFAULT_COMPLIANCE_KEY_RETENTION_DAYS = 180 # 6 months
|
||||||
DEFAULT_MESSAGE_KEY_RETENTION_DAYS = 30 # 30 days for deleted messages
|
DEFAULT_MESSAGE_KEY_RETENTION_DAYS = 180 # 6 months for deleted messages
|
||||||
|
|
||||||
# Environment variable overrides
|
# Environment variable overrides
|
||||||
COMPLIANCE_KEY_RETENTION_DAYS = int(os.getenv("COMPLIANCE_KEY_RETENTION_DAYS", DEFAULT_COMPLIANCE_KEY_RETENTION_DAYS))
|
COMPLIANCE_KEY_RETENTION_DAYS = int(os.getenv("COMPLIANCE_KEY_RETENTION_DAYS", DEFAULT_COMPLIANCE_KEY_RETENTION_DAYS))
|
||||||
@@ -147,7 +147,8 @@ def cleanup_expired_message_keys(db: Session) -> int:
|
|||||||
Clean up message keys for deleted messages after retention period.
|
Clean up message keys for deleted messages after retention period.
|
||||||
|
|
||||||
This removes sender and recipient wrapped keys from DM envelopes that have been
|
This removes sender and recipient wrapped keys from DM envelopes that have been
|
||||||
deleted and are past the retention period, making them completely inaccessible.
|
soft-deleted and are past the retention period, making them completely inaccessible
|
||||||
|
except through compliance access (which preserves the compliance key).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
@@ -156,16 +157,45 @@ def cleanup_expired_message_keys(db: Session) -> int:
|
|||||||
Number of keys destroyed
|
Number of keys destroyed
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Note: We don't have a direct "deleted" flag on DMEnvelope, so this would need
|
from datetime import datetime, timedelta
|
||||||
# to be implemented when message deletion is added. For now, this is a placeholder.
|
from ..main.models import DMEnvelope
|
||||||
|
|
||||||
# This would typically work with a deletion timestamp or flag on the envelope
|
# Calculate cutoff date for expired messages
|
||||||
# For now, return 0 as we don't have deleted message tracking yet
|
cutoff_date = datetime.now() - get_message_key_retention_period()
|
||||||
logger.info("Message key cleanup: No deleted messages to process")
|
|
||||||
|
# Find soft-deleted messages past retention period
|
||||||
|
expired_messages = db.query(DMEnvelope).filter(
|
||||||
|
DMEnvelope.deleted_at.is_not(None),
|
||||||
|
DMEnvelope.deleted_at < cutoff_date
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not expired_messages:
|
||||||
|
logger.info("Message key cleanup: No expired deleted messages to process")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
keys_destroyed = 0
|
||||||
|
|
||||||
|
for message in expired_messages:
|
||||||
|
# Destroy sender and recipient keys (compliance key remains for legal access)
|
||||||
|
message.sender_wrapped_mek_b64 = ""
|
||||||
|
message.recipient_wrapped_mek_b64 = ""
|
||||||
|
keys_destroyed += 2
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Destroyed keys for soft-deleted message id=%s (deleted %s)",
|
||||||
|
message.id,
|
||||||
|
message.deleted_at.isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info("Message key cleanup: Destroyed %d keys across %d messages",
|
||||||
|
keys_destroyed, len(expired_messages))
|
||||||
|
|
||||||
|
return keys_destroyed
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to cleanup expired message keys: {e}")
|
logger.error(f"Failed to cleanup expired message keys: {e}")
|
||||||
|
db.rollback()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -386,7 +386,6 @@ async def process_message_with_files_http(request: ProcessMessageWithFilesReques
|
|||||||
logger.exception("Failed to process message with files: %s", e)
|
logger.exception("Failed to process message with files: %s", e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
port = int(os.getenv("PORT", "8301"))
|
port = int(os.getenv("PORT", "8301"))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Provides:
|
|||||||
- Request size limiting (max 5GB)
|
- Request size limiting (max 5GB)
|
||||||
- Input validation and sanitization
|
- Input validation and sanitization
|
||||||
- Comprehensive audit logging
|
- Comprehensive audit logging
|
||||||
|
- Health check access log filtering
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -15,6 +16,16 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
access_logger = logging.getLogger("uvicorn.access")
|
||||||
|
|
||||||
|
|
||||||
|
class HealthCheckFilter(logging.Filter):
|
||||||
|
"""Filter to suppress access logs for health check requests."""
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
"""Return False to suppress logs containing health check requests."""
|
||||||
|
message = record.getMessage()
|
||||||
|
return "GET /health HTTP/" not in message
|
||||||
|
|
||||||
# Maximum request size: 5GB
|
# Maximum request size: 5GB
|
||||||
MAX_REQUEST_SIZE = 5 * 1024 * 1024 * 1024 # 5GB in bytes
|
MAX_REQUEST_SIZE = 5 * 1024 * 1024 * 1024 # 5GB in bytes
|
||||||
@@ -48,50 +59,9 @@ class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
class AuditLoggingMiddleware(BaseHTTPMiddleware):
|
# Apply health check filter to access logger
|
||||||
"""Middleware for comprehensive audit logging of all requests."""
|
if not any(isinstance(f, HealthCheckFilter) for f in access_logger.filters):
|
||||||
|
access_logger.addFilter(HealthCheckFilter())
|
||||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
||||||
"""Log request and response details."""
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
# Log request
|
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
|
||||||
method = request.method
|
|
||||||
path = request.url.path
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"REQUEST: %s %s from %s",
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
client_ip,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await call_next(request)
|
|
||||||
|
|
||||||
# Log response
|
|
||||||
duration = time.time() - start_time
|
|
||||||
logger.info(
|
|
||||||
"RESPONSE: %s %s -> %d in %.2fms",
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
response.status_code,
|
|
||||||
duration * 1000,
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
duration = time.time() - start_time
|
|
||||||
logger.exception(
|
|
||||||
"ERROR: %s %s failed after %.2fms: %s",
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
duration * 1000,
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def add_security_middleware(app: FastAPI):
|
def add_security_middleware(app: FastAPI):
|
||||||
@@ -103,6 +73,3 @@ def add_security_middleware(app: FastAPI):
|
|||||||
"""
|
"""
|
||||||
# Request size limiting (inner, checked first)
|
# Request size limiting (inner, checked first)
|
||||||
app.add_middleware(RequestSizeLimitMiddleware)
|
app.add_middleware(RequestSizeLimitMiddleware)
|
||||||
|
|
||||||
# Audit logging (outer, logs everything)
|
|
||||||
app.add_middleware(AuditLoggingMiddleware)
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# COMPLIANCE ARCHITECTURE - Unified Dockerfile
|
||||||
|
# ============================================================================
|
||||||
|
# Base stage with common dependencies for all services
|
||||||
|
|
||||||
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
|
# Create common directories
|
||||||
|
RUN mkdir -p /app && \
|
||||||
|
useradd -u 1000 -m app && \
|
||||||
|
useradd -u 1001 -m messaging && \
|
||||||
|
useradd -u 1002 -m -s /bin/false filestorage
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy health check script
|
||||||
|
COPY --chown=app:app deployment/healthcheck.py /usr/local/bin/healthcheck.py
|
||||||
|
RUN chmod +x /usr/local/bin/healthcheck.py
|
||||||
|
|
||||||
|
# Copy and install Python dependencies with pip cache
|
||||||
|
COPY --chown=app:app backend/requirements.txt .
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
|
pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# MAIN SERVICE - User-facing operations
|
||||||
|
# ============================================================================
|
||||||
|
FROM base AS main
|
||||||
|
|
||||||
|
# Copy main service code
|
||||||
|
COPY --chown=app:app backend/services/main/ ./services/main/
|
||||||
|
COPY --chown=app:app backend/services/shared/ ./services/shared/
|
||||||
|
COPY --chown=app:app backend/alembic/ ./alembic/
|
||||||
|
COPY --chown=app:app backend/alembic.ini ./
|
||||||
|
|
||||||
|
# Create data directories for main service
|
||||||
|
RUN mkdir -p /app/data /app/logs /app/alembic/versions && \
|
||||||
|
chown -R app:app /app/data /app/logs /app/alembic
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER app
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD python3 /usr/local/bin/healthcheck.py
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE ${PORT:-8300}
|
||||||
|
|
||||||
|
# Run main service
|
||||||
|
CMD ["python", "-m", "services.main.main"]
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# MESSAGING SERVICE - Secure cryptographic processing
|
||||||
|
# ============================================================================
|
||||||
|
FROM base AS messaging
|
||||||
|
|
||||||
|
# Copy messaging service code
|
||||||
|
COPY --chown=messaging:messaging backend/services/messaging/ ./services/messaging/
|
||||||
|
COPY --chown=messaging:messaging backend/services/shared/ ./services/shared/
|
||||||
|
|
||||||
|
# Create directories with restricted permissions
|
||||||
|
RUN mkdir -p /app/logs && \
|
||||||
|
chown -R messaging:messaging /app && \
|
||||||
|
chmod 700 /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER messaging
|
||||||
|
|
||||||
|
# Health check - only accessible internally
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD python3 /usr/local/bin/healthcheck.py
|
||||||
|
|
||||||
|
# Expose port (internal only)
|
||||||
|
EXPOSE ${PORT:-8301}
|
||||||
|
|
||||||
|
# Run messaging service
|
||||||
|
CMD ["python", "-m", "services.messaging.main"]
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# FILE STORAGE SERVICE - Secure file storage with execution prevention
|
||||||
|
# ============================================================================
|
||||||
|
FROM base AS file_storage
|
||||||
|
|
||||||
|
# Copy file storage service code
|
||||||
|
COPY --chown=filestorage:filestorage backend/services/file_storage/ ./services/file_storage/
|
||||||
|
COPY --chown=filestorage:filestorage backend/services/shared/ ./services/shared/
|
||||||
|
COPY --chown=filestorage:filestorage backend/services/main/db.py ./services/main/
|
||||||
|
COPY --chown=filestorage:filestorage backend/services/main/dependencies.py ./services/main/
|
||||||
|
COPY --chown=filestorage:filestorage backend/services/main/models.py ./services/main/
|
||||||
|
COPY --chown=filestorage:filestorage backend/services/main/constants.py ./services/main/
|
||||||
|
COPY --chown=filestorage:filestorage backend/services/main/utils.py ./services/main/
|
||||||
|
|
||||||
|
# Create secure file storage directories
|
||||||
|
RUN mkdir -p /app/files /app/logs && \
|
||||||
|
chown -R filestorage:filestorage /app && \
|
||||||
|
chmod 700 /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER filestorage
|
||||||
|
|
||||||
|
# Health check - only accessible internally
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD python3 /usr/local/bin/healthcheck.py
|
||||||
|
|
||||||
|
# Expose port (internal only)
|
||||||
|
EXPOSE ${PORT:-8302}
|
||||||
|
|
||||||
|
# Run file storage service with permission fix
|
||||||
|
CMD ["sh", "-c", "chown -R filestorage:filestorage /app/files /app/logs 2>/dev/null || true && exec python -m services.file_storage.main"]
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# FromChat Compliance Architecture - Docker Deployment
|
||||||
|
|
||||||
|
This directory contains the Docker configuration for the 3-service compliance architecture.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Clients │────│ Main Service │────│ Messaging │
|
||||||
|
│ │ │ (Port 8300) │ │ Service │
|
||||||
|
│ Web/Apps │ │ │ │ (Port 8301) │
|
||||||
|
│ │ │ • User auth │ │ • Encryption │
|
||||||
|
└─────────────┘ │ • WebSocket │ │ • Compliance │
|
||||||
|
│ • API proxy │ │ • No ext access │
|
||||||
|
└─────────────────┘ └─────────────────┘
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ File Storage │ │ PostgreSQL │
|
||||||
|
│ Service │ │ Database │
|
||||||
|
│ (Port 8302) │ │ • Main schema │
|
||||||
|
│ • Secure files │ │ • Messaging │
|
||||||
|
│ • No ext access │ │ • File schema │
|
||||||
|
└─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Build Optimization
|
||||||
|
|
||||||
|
- **Unified Dockerfile**: Single Dockerfile with multi-stage builds for all services
|
||||||
|
- **Shared Base**: Common Python dependencies cached in base stage
|
||||||
|
- **Zero System Dependencies**: No gcc, curl, or system packages - pure Python
|
||||||
|
- **Python Health Checks**: Built-in health monitoring using urllib
|
||||||
|
- **Aggressive Caching**: Pip cache and layer optimization
|
||||||
|
- **Security**: Non-root users, restricted permissions per service
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
- **Network Isolation**: Messaging and file storage services have NO external network access
|
||||||
|
- **Database Separation**: Each service has its own schema with minimal required permissions
|
||||||
|
- **Secure File Storage**: File storage uses restricted permissions and user isolation
|
||||||
|
- **Ephemeral Keys**: Messaging service generates temporary keys (never persisted)
|
||||||
|
|
||||||
|
## Environment Variables Required
|
||||||
|
|
||||||
|
Create a `.env` file in this directory with the following variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database
|
||||||
|
POSTGRES_PASSWORD=your_secure_postgres_password
|
||||||
|
MAIN_DB_PASSWORD=separate_password_for_main_service
|
||||||
|
MESSAGING_DB_PASSWORD=separate_password_for_messaging
|
||||||
|
FILE_STORAGE_DB_PASSWORD=separate_password_for_file_storage
|
||||||
|
|
||||||
|
# Security
|
||||||
|
JWT_SECRET=your_jwt_secret_key
|
||||||
|
VAPID_PUBLIC_KEY=generated_vapid_public_key
|
||||||
|
VAPID_PRIVATE_KEY=generated_vapid_private_key
|
||||||
|
FIREBASE_CERT='{"type":"service_account",...}'
|
||||||
|
|
||||||
|
# Compliance (public key only - private key stays offline)
|
||||||
|
COMPLIANCE_PUBLIC_KEY=base64_encoded_public_key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Rebuild and restart
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Mode
|
||||||
|
|
||||||
|
For local development, set `SERVICE_MODE=development` to run all services in a single Python process instead of containers.
|
||||||
|
|
||||||
|
## Network Architecture
|
||||||
|
|
||||||
|
- **public**: External client access (main service, frontend, reverse proxy)
|
||||||
|
- **services**: Internal service communication only (database, messaging, file storage)
|
||||||
|
- Messaging and file storage services have NO external network access
|
||||||
|
- All inter-service communication is HTTP-based with proper authentication
|
||||||
|
|
||||||
|
## Database Schema Separation
|
||||||
|
|
||||||
|
- `fromchat_main`: User data, authentication, profiles
|
||||||
|
- `fromchat_messaging`: Encrypted messages, keys, compliance data
|
||||||
|
- `fromchat_files`: File metadata, storage references
|
||||||
|
|
||||||
|
Each service has minimal required database permissions for security isolation.
|
||||||
@@ -196,7 +196,6 @@ services:
|
|||||||
- db:/var/lib/postgresql/data
|
- db:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- services
|
- services
|
||||||
- public
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ RUN npm run build
|
|||||||
# 3. Put it all together
|
# 3. Put it all together
|
||||||
FROM node:24-slim
|
FROM node:24-slim
|
||||||
|
|
||||||
# 3.1. Non-root user
|
# 3.1. Install curl for health checks
|
||||||
|
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 3.2. Non-root user
|
||||||
RUN useradd -u 1001 app && \
|
RUN useradd -u 1001 app && \
|
||||||
mkdir -p /app && \
|
mkdir -p /app && \
|
||||||
chown -R app /app && \
|
chown -R app /app && \
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { resolve } from 'path';
|
|||||||
const app = express();
|
const app = express();
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
|
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
|
||||||
|
const fileStorageHost = process.env.FILE_STORAGE_HOST || "http://localhost:8302";
|
||||||
const filePath = process.env.STATIC_FILE_PATH || ".";
|
const filePath = process.env.STATIC_FILE_PATH || ".";
|
||||||
|
|
||||||
// API proxy middleware
|
// API proxy middleware
|
||||||
@@ -15,6 +16,12 @@ app.use('/api', createProxyMiddleware({
|
|||||||
ws: true
|
ws: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// File serving proxy middleware
|
||||||
|
app.use('/uploads/files', createProxyMiddleware({
|
||||||
|
target: fileStorageHost,
|
||||||
|
changeOrigin: true
|
||||||
|
}));
|
||||||
|
|
||||||
// Serve static files
|
// Serve static files
|
||||||
app.use(express.static(resolve(filePath)));
|
app.use(express.static(resolve(filePath)));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Simple health check script using built-in urllib
|
||||||
|
Replaces curl dependency in Docker health checks
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import os
|
||||||
|
|
||||||
|
def main():
|
||||||
|
port = os.getenv('PORT', '8300')
|
||||||
|
url = f'http://localhost:{port}/health'
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=10) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
print("OK")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print(f"HTTP {response.status}")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAILED: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -40,19 +40,9 @@ export async function decrypt(envelope: DmEnvelope, userId?: number): Promise<st
|
|||||||
const wrappedMekB64 = envelope.wrapped_mek_b64;
|
const wrappedMekB64 = envelope.wrapped_mek_b64;
|
||||||
if (!wrappedMekB64) throw new Error("No wrapped MEK available for decryption");
|
if (!wrappedMekB64) throw new Error("No wrapped MEK available for decryption");
|
||||||
|
|
||||||
console.log("🔐 Decrypting DM envelope:", {
|
|
||||||
id: envelope.id,
|
|
||||||
senderId: envelope.senderId,
|
|
||||||
recipientId: envelope.recipientId,
|
|
||||||
hasWrappedMek: !!wrappedMekB64,
|
|
||||||
wrappedMekLength: wrappedMekB64?.length
|
|
||||||
});
|
|
||||||
|
|
||||||
// Unwrap the MEK using shared logic
|
// Unwrap the MEK using shared logic
|
||||||
const mek = await unwrapMek(wrappedMekB64, envelope, userId);
|
const mek = await unwrapMek(wrappedMekB64, envelope, userId);
|
||||||
|
|
||||||
console.log("🔓 MEK unwrapped successfully, length:", mek.length);
|
|
||||||
|
|
||||||
// Decrypt the message using the unwrapped MEK
|
// Decrypt the message using the unwrapped MEK
|
||||||
// Server encrypts with AES-GCM, so client decrypts with AES-GCM
|
// Server encrypts with AES-GCM, so client decrypts with AES-GCM
|
||||||
// envelope.iv_b64 and envelope.ciphertext_b64 are base64-encoded separately
|
// envelope.iv_b64 and envelope.ciphertext_b64 are base64-encoded separately
|
||||||
@@ -60,15 +50,9 @@ export async function decrypt(envelope: DmEnvelope, userId?: number): Promise<st
|
|||||||
const messageNonce = ub64(envelope.iv_b64 || "");
|
const messageNonce = ub64(envelope.iv_b64 || "");
|
||||||
const messageCiphertext = ub64(envelope.ciphertext_b64);
|
const messageCiphertext = ub64(envelope.ciphertext_b64);
|
||||||
|
|
||||||
console.log("💬 Message decryption with AES-GCM:", {
|
|
||||||
ivLength: messageNonce.length,
|
|
||||||
ciphertextLength: messageCiphertext.length
|
|
||||||
});
|
|
||||||
|
|
||||||
const plaintext = await aesGcmDecrypt(messageKey, messageNonce, messageCiphertext);
|
const plaintext = await aesGcmDecrypt(messageKey, messageNonce, messageCiphertext);
|
||||||
const result = new TextDecoder().decode(plaintext);
|
const result = new TextDecoder().decode(plaintext);
|
||||||
|
|
||||||
console.log("✅ Decryption successful:", result);
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Failed to decrypt DM envelope:", error);
|
console.error("❌ Failed to decrypt DM envelope:", error);
|
||||||
|
|||||||
Vendored
+3
-5
@@ -319,11 +319,9 @@ export interface DMEditPayload {
|
|||||||
id: number;
|
id: number;
|
||||||
senderId: number;
|
senderId: number;
|
||||||
recipientId: number;
|
recipientId: number;
|
||||||
iv: string;
|
iv_b64: string;
|
||||||
ciphertext: string;
|
ciphertext_b64: string;
|
||||||
iv2: string;
|
wrapped_mek_b64: string;
|
||||||
wrappedMk: string;
|
|
||||||
salt: string;
|
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,12 +55,6 @@ export class DMPanel extends MessagePanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
|
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
|
||||||
console.log("🔔 DMPanel parsing message:", {
|
|
||||||
envelopeId: env.id,
|
|
||||||
currentUserId: this.currentUser.currentUser?.id,
|
|
||||||
envelopeRecipientId: env.recipientId,
|
|
||||||
envelopeSenderId: env.senderId
|
|
||||||
});
|
|
||||||
const plaintext = await api.chats.dm.decrypt(env, this.currentUser.currentUser?.id);
|
const plaintext = await api.chats.dm.decrypt(env, this.currentUser.currentUser?.id);
|
||||||
const username = formatDMUsername(
|
const username = formatDMUsername(
|
||||||
env.senderId,
|
env.senderId,
|
||||||
@@ -258,18 +252,20 @@ export class DMPanel extends MessagePanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (response.type === "dmEdited" && this.dmData) {
|
if (response.type === "dmEdited" && this.dmData) {
|
||||||
const { id, iv, ciphertext, wrappedMk } = response.data;
|
const { id, senderId, recipientId, iv_b64, ciphertext_b64, wrapped_mek_b64, timestamp } = response.data;
|
||||||
|
if (!wrapped_mek_b64) {
|
||||||
|
this.updateMessage(id, { is_edited: true });
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
// Decrypt new content in-place
|
|
||||||
const plaintext = await api.chats.dm.decrypt(
|
const plaintext = await api.chats.dm.decrypt(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
senderId: 0,
|
senderId: senderId ?? 0,
|
||||||
recipientId: 0,
|
recipientId: recipientId ?? 0,
|
||||||
iv_b64: iv,
|
iv_b64: iv_b64 ?? "",
|
||||||
ciphertext_b64: ciphertext,
|
ciphertext_b64: ciphertext_b64 ?? "",
|
||||||
wrapped_mek_b64: wrappedMk,
|
wrapped_mek_b64,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: timestamp ?? new Date().toISOString()
|
||||||
},
|
},
|
||||||
this.currentUser.currentUser?.id
|
this.currentUser.currentUser?.id
|
||||||
);
|
);
|
||||||
@@ -288,6 +284,7 @@ export class DMPanel extends MessagePanel {
|
|||||||
this.updateMessage(id, { is_edited: true });
|
this.updateMessage(id, { is_edited: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (response.type === "dmDeleted" && this.dmData) {
|
if (response.type === "dmDeleted" && this.dmData) {
|
||||||
const { id } = response.data;
|
const { id } = response.data;
|
||||||
this.removeMessage(id);
|
this.removeMessage(id);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
echo > deployment/.env
|
echo > deployment/.env
|
||||||
|
|
||||||
./.venv/bin/python3 backend/generate_vapid_keys.py >> deployment/.env
|
./.venv/bin/python3 backend/services/main/generate_vapid_keys.py >> deployment/.env
|
||||||
|
|
||||||
cat >> deployment/.env <<EOF
|
cat >> deployment/.env <<EOF
|
||||||
JWT_SECRET="$(openssl rand -base64 32)"
|
JWT_SECRET="$(openssl rand -base64 32)"
|
||||||
|
|||||||
Reference in New Issue
Block a user