mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-06 22:12:12 +02:00
merge upstream/dev: add user_id to configs, provider icons, i18n
This commit is contained in:
commit
78127243e2
87 changed files with 10445 additions and 5029 deletions
|
|
@ -70,10 +70,33 @@ COPY . .
|
|||
COPY scripts/docker/entrypoint.sh /app/scripts/docker/entrypoint.sh
|
||||
RUN dos2unix /app/scripts/docker/entrypoint.sh && chmod +x /app/scripts/docker/entrypoint.sh
|
||||
|
||||
# Shared temp directory for file uploads between API and Worker containers.
|
||||
# Python's tempfile module uses TMPDIR, so uploaded files land here.
|
||||
# Mount the SAME volume at /shared_tmp on both API and Worker in Coolify.
|
||||
RUN mkdir -p /shared_tmp
|
||||
ENV TMPDIR=/shared_tmp
|
||||
|
||||
# Prevent uvloop compatibility issues
|
||||
ENV PYTHONPATH=/app
|
||||
ENV UVICORN_LOOP=asyncio
|
||||
|
||||
# SERVICE_ROLE controls which process this container runs:
|
||||
# api – FastAPI backend only (runs migrations on startup)
|
||||
# worker – Celery worker only
|
||||
# beat – Celery beat scheduler only
|
||||
# all – All three (legacy / dev default)
|
||||
ENV SERVICE_ROLE=all
|
||||
|
||||
# Celery worker tuning (only used when SERVICE_ROLE=worker or all)
|
||||
ENV CELERY_MAX_WORKERS=10
|
||||
ENV CELERY_MIN_WORKERS=2
|
||||
ENV CELERY_MAX_TASKS_PER_CHILD=50
|
||||
# CELERY_QUEUES: comma-separated queues to consume (empty = all queues)
|
||||
# "surfsense" – fast tasks only (file uploads, podcasts, etc.)
|
||||
# "surfsense.connectors" – slow connector indexing tasks only
|
||||
# "" – both queues (default, for single-worker setups)
|
||||
ENV CELERY_QUEUES=""
|
||||
|
||||
# Run
|
||||
EXPOSE 8000-8001
|
||||
CMD ["/app/scripts/docker/entrypoint.sh"]
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
"""Add user_id to new_llm_configs and image_generation_configs
|
||||
|
||||
Revision ID: 98
|
||||
Revises: 97
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "98"
|
||||
down_revision: str | None = "97"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add user_id column to new_llm_configs and image_generation_configs.
|
||||
|
||||
Backfills existing rows with the search space owner's user_id.
|
||||
"""
|
||||
# --- new_llm_configs ---
|
||||
# 1. Add nullable column first
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE new_llm_configs
|
||||
ADD COLUMN IF NOT EXISTS user_id UUID;
|
||||
"""
|
||||
)
|
||||
|
||||
# 2. Backfill from search space owner
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE new_llm_configs nlc
|
||||
SET user_id = ss.user_id
|
||||
FROM searchspaces ss
|
||||
WHERE nlc.search_space_id = ss.id
|
||||
AND nlc.user_id IS NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
# 3. Make NOT NULL
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE new_llm_configs
|
||||
ALTER COLUMN user_id SET NOT NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
# 4. Add FK constraint
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_new_llm_configs_user_id'
|
||||
AND table_name = 'new_llm_configs'
|
||||
) THEN
|
||||
ALTER TABLE new_llm_configs
|
||||
ADD CONSTRAINT fk_new_llm_configs_user_id
|
||||
FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# 5. Add index for user_id lookups
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_new_llm_configs_user_id
|
||||
ON new_llm_configs (user_id);
|
||||
"""
|
||||
)
|
||||
|
||||
# --- image_generation_configs ---
|
||||
# 1. Add nullable column first
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE image_generation_configs
|
||||
ADD COLUMN IF NOT EXISTS user_id UUID;
|
||||
"""
|
||||
)
|
||||
|
||||
# 2. Backfill from search space owner
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE image_generation_configs igc
|
||||
SET user_id = ss.user_id
|
||||
FROM searchspaces ss
|
||||
WHERE igc.search_space_id = ss.id
|
||||
AND igc.user_id IS NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
# 3. Make NOT NULL
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE image_generation_configs
|
||||
ALTER COLUMN user_id SET NOT NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
# 4. Add FK constraint
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_image_generation_configs_user_id'
|
||||
AND table_name = 'image_generation_configs'
|
||||
) THEN
|
||||
ALTER TABLE image_generation_configs
|
||||
ADD CONSTRAINT fk_image_generation_configs_user_id
|
||||
FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# 5. Add index for user_id lookups
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_image_generation_configs_user_id
|
||||
ON image_generation_configs (user_id);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove user_id from new_llm_configs and image_generation_configs."""
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE new_llm_configs DROP COLUMN IF EXISTS user_id;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE image_generation_configs DROP COLUMN IF EXISTS user_id;
|
||||
"""
|
||||
)
|
||||
|
|
@ -109,7 +109,7 @@ class AgentConfig:
|
|||
use_default_system_instructions=True,
|
||||
citations_enabled=True,
|
||||
config_id=AUTO_MODE_ID,
|
||||
config_name="Auto (Load Balanced)",
|
||||
config_name="Auto (Fastest)",
|
||||
is_auto_mode=True,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager
|
||||
from threading import Lock
|
||||
|
||||
import redis
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from slowapi import Limiter
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from slowapi.util import get_remote_address
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
|
||||
|
|
@ -17,6 +27,147 @@ from app.schemas import UserCreate, UserRead, UserUpdate
|
|||
from app.tasks.surfsense_docs_indexer import seed_surfsense_docs
|
||||
from app.users import SECRET, auth_backend, current_active_user, fastapi_users
|
||||
|
||||
rate_limit_logger = logging.getLogger("surfsense.rate_limit")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Rate Limiting Configuration (SlowAPI + Redis)
|
||||
# ============================================================================
|
||||
# Uses the same Redis instance as Celery for zero additional infrastructure.
|
||||
# Protects auth endpoints from brute force and user enumeration attacks.
|
||||
|
||||
# SlowAPI limiter — provides default rate limits (60/min) for ALL routes
|
||||
# via the ASGI middleware. This is the general safety net.
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
storage_uri=config.REDIS_APP_URL,
|
||||
default_limits=["60/minute"],
|
||||
)
|
||||
|
||||
|
||||
def _rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
|
||||
"""Custom 429 handler that returns JSON matching our frontend error format."""
|
||||
retry_after = exc.detail.split("per")[-1].strip() if exc.detail else "60"
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "RATE_LIMIT_EXCEEDED"},
|
||||
headers={"Retry-After": retry_after},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Auth-Specific Rate Limits (Redis-backed with in-memory fallback)
|
||||
# ============================================================================
|
||||
# Stricter per-IP limits on auth endpoints to prevent:
|
||||
# - Brute force password attacks
|
||||
# - User enumeration via REGISTER_USER_ALREADY_EXISTS
|
||||
# - Email spam via forgot-password
|
||||
#
|
||||
# Primary: Redis INCR+EXPIRE (shared across all workers).
|
||||
# Fallback: In-memory sliding window (per-worker) when Redis is unavailable.
|
||||
# Same Redis instance as SlowAPI / Celery.
|
||||
_rate_limit_redis: redis.Redis | None = None
|
||||
|
||||
# In-memory fallback rate limiter (per-worker, used only when Redis is down)
|
||||
_memory_rate_limits: dict[str, list[float]] = defaultdict(list)
|
||||
_memory_lock = Lock()
|
||||
|
||||
|
||||
def _get_rate_limit_redis() -> redis.Redis:
|
||||
"""Get or create Redis client for auth rate limiting."""
|
||||
global _rate_limit_redis
|
||||
if _rate_limit_redis is None:
|
||||
_rate_limit_redis = redis.from_url(config.REDIS_APP_URL, decode_responses=True)
|
||||
return _rate_limit_redis
|
||||
|
||||
|
||||
def _check_rate_limit_memory(
|
||||
client_ip: str, max_requests: int, window_seconds: int, scope: str
|
||||
):
|
||||
"""
|
||||
In-memory fallback rate limiter using a sliding window.
|
||||
Used only when Redis is unavailable. Per-worker only (not shared),
|
||||
so effective limit = max_requests x num_workers.
|
||||
"""
|
||||
key = f"{scope}:{client_ip}"
|
||||
now = time.monotonic()
|
||||
|
||||
with _memory_lock:
|
||||
# Evict timestamps outside the current window
|
||||
_memory_rate_limits[key] = [
|
||||
t for t in _memory_rate_limits[key] if now - t < window_seconds
|
||||
]
|
||||
|
||||
if len(_memory_rate_limits[key]) >= max_requests:
|
||||
rate_limit_logger.warning(
|
||||
f"Rate limit exceeded (in-memory fallback) on {scope} for IP {client_ip} "
|
||||
f"({len(_memory_rate_limits[key])}/{max_requests} in {window_seconds}s)"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="RATE_LIMIT_EXCEEDED",
|
||||
)
|
||||
|
||||
_memory_rate_limits[key].append(now)
|
||||
|
||||
|
||||
def _check_rate_limit(
|
||||
request: Request, max_requests: int, window_seconds: int, scope: str
|
||||
):
|
||||
"""
|
||||
Check per-IP rate limit using Redis. Raises 429 if exceeded.
|
||||
Uses atomic INCR + EXPIRE to avoid race conditions.
|
||||
Falls back to in-memory sliding window if Redis is unavailable.
|
||||
"""
|
||||
client_ip = get_remote_address(request)
|
||||
key = f"surfsense:auth_rate_limit:{scope}:{client_ip}"
|
||||
|
||||
try:
|
||||
r = _get_rate_limit_redis()
|
||||
|
||||
# Atomic: increment first, then set TTL if this is a new key
|
||||
pipe = r.pipeline()
|
||||
pipe.incr(key)
|
||||
pipe.expire(key, window_seconds)
|
||||
result = pipe.execute()
|
||||
except (redis.exceptions.RedisError, OSError) as exc:
|
||||
# Redis unavailable — fall back to in-memory rate limiting
|
||||
rate_limit_logger.warning(
|
||||
f"Redis unavailable for rate limiting ({scope}), "
|
||||
f"falling back to in-memory limiter for {client_ip}: {exc}"
|
||||
)
|
||||
_check_rate_limit_memory(client_ip, max_requests, window_seconds, scope)
|
||||
return
|
||||
|
||||
current_count = result[0] # INCR returns the new value
|
||||
|
||||
if current_count > max_requests:
|
||||
rate_limit_logger.warning(
|
||||
f"Rate limit exceeded on {scope} for IP {client_ip} "
|
||||
f"({current_count}/{max_requests} in {window_seconds}s)"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="RATE_LIMIT_EXCEEDED",
|
||||
)
|
||||
|
||||
|
||||
def rate_limit_login(request: Request):
|
||||
"""5 login attempts per minute per IP."""
|
||||
_check_rate_limit(request, max_requests=5, window_seconds=60, scope="login")
|
||||
|
||||
|
||||
def rate_limit_register(request: Request):
|
||||
"""3 registration attempts per minute per IP."""
|
||||
_check_rate_limit(request, max_requests=3, window_seconds=60, scope="register")
|
||||
|
||||
|
||||
def rate_limit_password_reset(request: Request):
|
||||
"""2 password reset attempts per minute per IP."""
|
||||
_check_rate_limit(
|
||||
request, max_requests=2, window_seconds=60, scope="password_reset"
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
|
|
@ -45,6 +196,16 @@ def registration_allowed():
|
|||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# Register rate limiter and custom 429 handler
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# Add SlowAPI middleware for automatic rate limiting
|
||||
# Uses Starlette BaseHTTPMiddleware (not the raw ASGI variant) to avoid
|
||||
# corrupting StreamingResponse — SlowAPIASGIMiddleware re-sends
|
||||
# http.response.start on every body chunk, breaking SSE/streaming endpoints.
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
|
||||
# Add ProxyHeaders middleware FIRST to trust proxy headers (e.g., from Cloudflare)
|
||||
# This ensures FastAPI uses HTTPS in redirects when behind a proxy
|
||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
||||
|
|
@ -90,18 +251,25 @@ app.add_middleware(
|
|||
)
|
||||
|
||||
app.include_router(
|
||||
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]
|
||||
fastapi_users.get_auth_router(auth_backend),
|
||||
prefix="/auth/jwt",
|
||||
tags=["auth"],
|
||||
dependencies=[Depends(rate_limit_login)],
|
||||
)
|
||||
app.include_router(
|
||||
fastapi_users.get_register_router(UserRead, UserCreate),
|
||||
prefix="/auth",
|
||||
tags=["auth"],
|
||||
dependencies=[Depends(registration_allowed)], # blocks registration when disabled
|
||||
dependencies=[
|
||||
Depends(rate_limit_register),
|
||||
Depends(registration_allowed), # blocks registration when disabled
|
||||
],
|
||||
)
|
||||
app.include_router(
|
||||
fastapi_users.get_reset_password_router(),
|
||||
prefix="/auth",
|
||||
tags=["auth"],
|
||||
dependencies=[Depends(rate_limit_password_reset)],
|
||||
)
|
||||
app.include_router(
|
||||
fastapi_users.get_verify_router(UserRead),
|
||||
|
|
|
|||
|
|
@ -86,6 +86,11 @@ celery_app = Celery(
|
|||
],
|
||||
)
|
||||
|
||||
# ── Queue names ──────────────────────────────────────────────
|
||||
# Default queue : fast, user-facing tasks (file upload, podcast, reindex, …)
|
||||
# Connectors queue: slow, long-running indexing tasks (Notion, Gmail, web crawl, …)
|
||||
CONNECTORS_QUEUE = f"{CELERY_TASK_DEFAULT_QUEUE}.connectors"
|
||||
|
||||
# Celery configuration
|
||||
celery_app.conf.update(
|
||||
# Task settings
|
||||
|
|
@ -114,6 +119,34 @@ celery_app.conf.update(
|
|||
broker_connection_retry_on_startup=True,
|
||||
# Beat scheduler settings
|
||||
beat_max_loop_interval=60, # Check every minute
|
||||
# ── Task routing ─────────────────────────────────────────
|
||||
# Route slow connector/indexing tasks to a dedicated queue so they
|
||||
# never block fast user-facing tasks (file uploads, podcasts, etc.)
|
||||
task_routes={
|
||||
# Connector indexing tasks → connectors queue
|
||||
"index_slack_messages": {"queue": CONNECTORS_QUEUE},
|
||||
"index_notion_pages": {"queue": CONNECTORS_QUEUE},
|
||||
"index_github_repos": {"queue": CONNECTORS_QUEUE},
|
||||
"index_linear_issues": {"queue": CONNECTORS_QUEUE},
|
||||
"index_jira_issues": {"queue": CONNECTORS_QUEUE},
|
||||
"index_confluence_pages": {"queue": CONNECTORS_QUEUE},
|
||||
"index_clickup_tasks": {"queue": CONNECTORS_QUEUE},
|
||||
"index_google_calendar_events": {"queue": CONNECTORS_QUEUE},
|
||||
"index_airtable_records": {"queue": CONNECTORS_QUEUE},
|
||||
"index_google_gmail_messages": {"queue": CONNECTORS_QUEUE},
|
||||
"index_google_drive_files": {"queue": CONNECTORS_QUEUE},
|
||||
"index_discord_messages": {"queue": CONNECTORS_QUEUE},
|
||||
"index_teams_messages": {"queue": CONNECTORS_QUEUE},
|
||||
"index_luma_events": {"queue": CONNECTORS_QUEUE},
|
||||
"index_elasticsearch_documents": {"queue": CONNECTORS_QUEUE},
|
||||
"index_crawled_urls": {"queue": CONNECTORS_QUEUE},
|
||||
"index_bookstack_pages": {"queue": CONNECTORS_QUEUE},
|
||||
"index_obsidian_vault": {"queue": CONNECTORS_QUEUE},
|
||||
"index_composio_connector": {"queue": CONNECTORS_QUEUE},
|
||||
"delete_connector_with_documents": {"queue": CONNECTORS_QUEUE},
|
||||
# Everything else (document processing, podcasts, reindexing,
|
||||
# schedule checker, cleanup) stays on the default fast queue.
|
||||
},
|
||||
)
|
||||
|
||||
# Configure Celery Beat schedule
|
||||
|
|
|
|||
|
|
@ -1066,6 +1066,12 @@ class ImageGenerationConfig(BaseModel, TimestampMixin):
|
|||
"SearchSpace", back_populates="image_generation_configs"
|
||||
)
|
||||
|
||||
# User who created this config
|
||||
user_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user = relationship("User", back_populates="image_generation_configs")
|
||||
|
||||
|
||||
class ImageGeneration(BaseModel, TimestampMixin):
|
||||
"""
|
||||
|
|
@ -1284,6 +1290,7 @@ class SearchSourceConnector(BaseModel, TimestampMixin):
|
|||
user_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user = relationship("User", back_populates="search_source_connectors")
|
||||
|
||||
# Documents created by this connector (for cleanup on connector deletion)
|
||||
documents = relationship("Document", back_populates="connector")
|
||||
|
|
@ -1340,6 +1347,12 @@ class NewLLMConfig(BaseModel, TimestampMixin):
|
|||
)
|
||||
search_space = relationship("SearchSpace", back_populates="new_llm_configs")
|
||||
|
||||
# User who created this config
|
||||
user_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user = relationship("User", back_populates="new_llm_configs")
|
||||
|
||||
|
||||
class Log(BaseModel, TimestampMixin):
|
||||
__tablename__ = "logs"
|
||||
|
|
@ -1608,6 +1621,27 @@ if config.AUTH_TYPE == "GOOGLE":
|
|||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# Connectors created by this user
|
||||
search_source_connectors = relationship(
|
||||
"SearchSourceConnector",
|
||||
back_populates="user",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# LLM configs created by this user
|
||||
new_llm_configs = relationship(
|
||||
"NewLLMConfig",
|
||||
back_populates="user",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# Image generation configs created by this user
|
||||
image_generation_configs = relationship(
|
||||
"ImageGenerationConfig",
|
||||
back_populates="user",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# User memories for personalized AI responses
|
||||
memories = relationship(
|
||||
"UserMemory",
|
||||
|
|
@ -1687,6 +1721,27 @@ else:
|
|||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# Connectors created by this user
|
||||
search_source_connectors = relationship(
|
||||
"SearchSourceConnector",
|
||||
back_populates="user",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# LLM configs created by this user
|
||||
new_llm_configs = relationship(
|
||||
"NewLLMConfig",
|
||||
back_populates="user",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# Image generation configs created by this user
|
||||
image_generation_configs = relationship(
|
||||
"ImageGenerationConfig",
|
||||
back_populates="user",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
# User memories for personalized AI responses
|
||||
memories = relationship(
|
||||
"UserMemory",
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ def _get_global_image_gen_config(config_id: int) -> dict | None:
|
|||
if config_id == IMAGE_GEN_AUTO_MODE_ID:
|
||||
return {
|
||||
"id": IMAGE_GEN_AUTO_MODE_ID,
|
||||
"name": "Auto (Load Balanced)",
|
||||
"name": "Auto (Fastest)",
|
||||
"provider": "AUTO",
|
||||
"model_name": "auto",
|
||||
"is_auto_mode": True,
|
||||
|
|
@ -215,7 +215,7 @@ async def get_global_image_gen_configs(
|
|||
safe_configs.append(
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Auto (Load Balanced)",
|
||||
"name": "Auto (Fastest)",
|
||||
"description": "Automatically routes across available image generation providers.",
|
||||
"provider": "AUTO",
|
||||
"custom_provider": None,
|
||||
|
|
@ -273,7 +273,7 @@ async def create_image_gen_config(
|
|||
"You don't have permission to create image generation configs in this search space",
|
||||
)
|
||||
|
||||
db_config = ImageGenerationConfig(**config_data.model_dump())
|
||||
db_config = ImageGenerationConfig(**config_data.model_dump(), user_id=user.id)
|
||||
session.add(db_config)
|
||||
await session.commit()
|
||||
await session.refresh(db_config)
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ async def get_global_new_llm_configs(
|
|||
safe_configs.append(
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Auto (Load Balanced)",
|
||||
"name": "Auto (Fastest)",
|
||||
"description": "Automatically routes requests across available LLM providers for optimal performance and rate limit handling. Recommended for most users.",
|
||||
"provider": "AUTO",
|
||||
"custom_provider": None,
|
||||
|
|
@ -149,8 +149,8 @@ async def create_new_llm_config(
|
|||
detail=f"Invalid LLM configuration: {error_message}",
|
||||
)
|
||||
|
||||
# Create the config
|
||||
db_config = NewLLMConfig(**config_data.model_dump())
|
||||
# Create the config with user association
|
||||
db_config = NewLLMConfig(**config_data.model_dump(), user_id=user.id)
|
||||
session.add(db_config)
|
||||
await session.commit()
|
||||
await session.refresh(db_config)
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@ async def _get_llm_config_by_id(
|
|||
if config_id == 0:
|
||||
return {
|
||||
"id": 0,
|
||||
"name": "Auto (Load Balanced)",
|
||||
"name": "Auto (Fastest)",
|
||||
"description": "Automatically routes requests across available LLM providers for optimal performance and rate limit handling",
|
||||
"provider": "AUTO",
|
||||
"custom_provider": None,
|
||||
|
|
@ -402,7 +402,7 @@ async def _get_image_gen_config_by_id(
|
|||
if config_id == 0:
|
||||
return {
|
||||
"id": 0,
|
||||
"name": "Auto (Load Balanced)",
|
||||
"name": "Auto (Fastest)",
|
||||
"description": "Automatically routes requests across available image generation providers",
|
||||
"provider": "AUTO",
|
||||
"model_name": "auto",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ ImageGeneration: Schemas for the actual image generation requests/results.
|
|||
GlobalImageGenConfigRead: Schema for admin-configured YAML configs.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -79,6 +80,7 @@ class ImageGenerationConfigRead(ImageGenerationConfigBase):
|
|||
id: int
|
||||
created_at: datetime
|
||||
search_space_id: int
|
||||
user_id: uuid.UUID
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
@ -97,6 +99,7 @@ class ImageGenerationConfigPublic(BaseModel):
|
|||
litellm_params: dict[str, Any] | None = None
|
||||
created_at: datetime
|
||||
search_space_id: int
|
||||
user_id: uuid.UUID
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -243,6 +243,7 @@ class PublicChatSnapshotDetail(BaseModel):
|
|||
message_count: int
|
||||
thread_id: int
|
||||
thread_title: str
|
||||
created_by_user_id: str | None = None
|
||||
|
||||
|
||||
class PublicChatSnapshotsBySpaceResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ NewLLMConfig combines LLM model settings with prompt configuration:
|
|||
- Citation toggle
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -90,6 +91,7 @@ class NewLLMConfigRead(NewLLMConfigBase):
|
|||
id: int
|
||||
created_at: datetime
|
||||
search_space_id: int
|
||||
user_id: uuid.UUID
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
@ -118,6 +120,7 @@ class NewLLMConfigPublic(BaseModel):
|
|||
|
||||
created_at: datetime
|
||||
search_space_id: int
|
||||
user_id: uuid.UUID
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ def get_global_llm_config(llm_config_id: int) -> dict | None:
|
|||
if llm_config_id == AUTO_MODE_ID:
|
||||
return {
|
||||
"id": AUTO_MODE_ID,
|
||||
"name": "Auto (Load Balanced)",
|
||||
"name": "Auto (Fastest)",
|
||||
"description": "Automatically routes requests across available LLM providers for optimal performance and rate limit handling",
|
||||
"provider": "AUTO",
|
||||
"model_name": "auto",
|
||||
|
|
|
|||
|
|
@ -439,6 +439,9 @@ async def list_snapshots_for_search_space(
|
|||
"message_count": len(s.message_ids) if s.message_ids else 0,
|
||||
"thread_id": s.thread_id,
|
||||
"thread_title": thread_titles.get(s.thread_id, "Untitled"),
|
||||
"created_by_user_id": str(s.created_by_user_id)
|
||||
if s.created_by_user_id
|
||||
else None,
|
||||
}
|
||||
for s in snapshots
|
||||
]
|
||||
|
|
|
|||
|
|
@ -47,6 +47,14 @@ if config.AUTH_TYPE == "GOOGLE":
|
|||
|
||||
|
||||
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||
"""
|
||||
Custom user manager extending fastapi-users BaseUserManager.
|
||||
|
||||
Authentication returns a generic error for both non-existent accounts
|
||||
and incorrect passwords to comply with OWASP WSTG-IDNT-04 and
|
||||
prevent user enumeration attacks.
|
||||
"""
|
||||
|
||||
reset_password_token_secret = SECRET
|
||||
verification_token_secret = SECRET
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ dependencies = [
|
|||
"unstructured[all-docs]>=0.18.31",
|
||||
"unstructured-client>=0.42.3",
|
||||
"langchain-unstructured>=1.0.1",
|
||||
"slowapi>=0.1.9",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
|
|
|||
|
|
@ -1,58 +1,129 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Function to handle shutdown gracefully
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# SERVICE_ROLE controls which process(es) this container runs.
|
||||
#
|
||||
# api – FastAPI backend only (runs migrations on startup)
|
||||
# worker – Celery worker only
|
||||
# beat – Celery beat scheduler only
|
||||
# all – All three in one container (legacy / dev default)
|
||||
#
|
||||
# Set SERVICE_ROLE as an environment variable in Coolify for
|
||||
# each service deployment.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
SERVICE_ROLE="${SERVICE_ROLE:-all}"
|
||||
echo "Starting SurfSense with SERVICE_ROLE=${SERVICE_ROLE}"
|
||||
|
||||
# ── Autoscale defaults (override via env) ────────────────────
|
||||
# CELERY_MAX_WORKERS – max concurrent worker processes
|
||||
# CELERY_MIN_WORKERS – min workers kept warm
|
||||
# CELERY_QUEUES – comma-separated queues to consume
|
||||
# (empty = all queues for backward compat)
|
||||
CELERY_MAX_WORKERS="${CELERY_MAX_WORKERS:-10}"
|
||||
CELERY_MIN_WORKERS="${CELERY_MIN_WORKERS:-2}"
|
||||
CELERY_MAX_TASKS_PER_CHILD="${CELERY_MAX_TASKS_PER_CHILD:-50}"
|
||||
CELERY_QUEUES="${CELERY_QUEUES:-}"
|
||||
|
||||
# ── Graceful shutdown ────────────────────────────────────────
|
||||
PIDS=()
|
||||
|
||||
cleanup() {
|
||||
echo "Shutting down services..."
|
||||
kill -TERM "$backend_pid" "$celery_worker_pid" "$celery_beat_pid" 2>/dev/null || true
|
||||
wait "$backend_pid" "$celery_worker_pid" "$celery_beat_pid" 2>/dev/null || true
|
||||
for pid in "${PIDS[@]}"; do
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
done
|
||||
for pid in "${PIDS[@]}"; do
|
||||
wait "$pid" 2>/dev/null || true
|
||||
done
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup SIGTERM SIGINT
|
||||
|
||||
# Run database migrations with safeguards
|
||||
echo "Running database migrations..."
|
||||
# Wait for database to be ready (max 30 seconds)
|
||||
for i in {1..30}; do
|
||||
if python -c "from app.db import engine; import asyncio; asyncio.run(engine.dispose())" 2>/dev/null; then
|
||||
echo "Database is ready."
|
||||
break
|
||||
# ── Database migrations (only for api / all) ─────────────────
|
||||
run_migrations() {
|
||||
echo "Running database migrations..."
|
||||
for i in {1..30}; do
|
||||
if python -c "from app.db import engine; import asyncio; asyncio.run(engine.dispose())" 2>/dev/null; then
|
||||
echo "Database is ready."
|
||||
break
|
||||
fi
|
||||
echo "Waiting for database... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if timeout 60 alembic upgrade head 2>&1; then
|
||||
echo "Migrations completed successfully."
|
||||
else
|
||||
echo "WARNING: Migration failed or timed out. Continuing anyway..."
|
||||
echo "You may need to run migrations manually: alembic upgrade head"
|
||||
fi
|
||||
echo "Waiting for database... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# Run migrations with timeout (60 seconds max)
|
||||
if timeout 60 alembic upgrade head 2>&1; then
|
||||
echo "Migrations completed successfully."
|
||||
else
|
||||
echo "WARNING: Migration failed or timed out. Continuing anyway..."
|
||||
echo "You may need to run migrations manually: alembic upgrade head"
|
||||
fi
|
||||
# ── Service starters ─────────────────────────────────────────
|
||||
start_api() {
|
||||
echo "Starting FastAPI Backend..."
|
||||
python main.py &
|
||||
PIDS+=($!)
|
||||
echo " FastAPI PID=${PIDS[-1]}"
|
||||
}
|
||||
|
||||
echo "Starting FastAPI Backend..."
|
||||
python main.py &
|
||||
backend_pid=$!
|
||||
start_worker() {
|
||||
QUEUE_ARGS=""
|
||||
if [ -n "${CELERY_QUEUES}" ]; then
|
||||
QUEUE_ARGS="--queues=${CELERY_QUEUES}"
|
||||
fi
|
||||
|
||||
# Wait a bit for backend to initialize
|
||||
sleep 5
|
||||
echo "Starting Celery Worker (autoscale=${CELERY_MAX_WORKERS},${CELERY_MIN_WORKERS}, max-tasks-per-child=${CELERY_MAX_TASKS_PER_CHILD}, queues=${CELERY_QUEUES:-all})..."
|
||||
celery -A app.celery_app worker \
|
||||
--loglevel=info \
|
||||
--autoscale="${CELERY_MAX_WORKERS},${CELERY_MIN_WORKERS}" \
|
||||
--max-tasks-per-child="${CELERY_MAX_TASKS_PER_CHILD}" \
|
||||
--prefetch-multiplier=1 \
|
||||
-Ofair \
|
||||
${QUEUE_ARGS} &
|
||||
PIDS+=($!)
|
||||
echo " Celery Worker PID=${PIDS[-1]}"
|
||||
}
|
||||
|
||||
echo "Starting Celery Worker..."
|
||||
celery -A app.celery_app worker --loglevel=info --autoscale=128,4 &
|
||||
celery_worker_pid=$!
|
||||
start_beat() {
|
||||
echo "Starting Celery Beat..."
|
||||
celery -A app.celery_app beat --loglevel=info &
|
||||
PIDS+=($!)
|
||||
echo " Celery Beat PID=${PIDS[-1]}"
|
||||
}
|
||||
|
||||
# Wait a bit for worker to initialize
|
||||
sleep 3
|
||||
# ── Main: run based on role ──────────────────────────────────
|
||||
case "${SERVICE_ROLE}" in
|
||||
api)
|
||||
run_migrations
|
||||
start_api
|
||||
;;
|
||||
worker)
|
||||
start_worker
|
||||
;;
|
||||
beat)
|
||||
start_beat
|
||||
;;
|
||||
all)
|
||||
run_migrations
|
||||
start_api
|
||||
sleep 5
|
||||
start_worker
|
||||
sleep 3
|
||||
start_beat
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown SERVICE_ROLE '${SERVICE_ROLE}'. Use: api, worker, beat, or all"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Starting Celery Beat..."
|
||||
celery -A app.celery_app beat --loglevel=info &
|
||||
celery_beat_pid=$!
|
||||
|
||||
echo "All services started. PIDs: Backend=$backend_pid, Worker=$celery_worker_pid, Beat=$celery_beat_pid"
|
||||
echo "All requested services started. PIDs: ${PIDS[*]}"
|
||||
|
||||
# Wait for any process to exit
|
||||
wait -n
|
||||
|
||||
# If we get here, one process exited, so exit with its status
|
||||
# If we get here, one process exited unexpectedly
|
||||
exit $?
|
||||
|
|
|
|||
6441
surfsense_backend/uv.lock
generated
6441
surfsense_backend/uv.lock
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue