Files
replypier/backend/app/main.py
T
WangShen007 f3ad539998 feat(updates): check Gitea automatically and deploy on owner confirmation
Add owner-only update controls, a read-only SSH checker and independent systemd deployment services. Build before maintenance; back up code and database; restore on failed deployment. Preserve runtime configuration and gate business writes during the switch.
2026-10-24 07:26:41 +00:00

393 lines
12 KiB
Python

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
# Set environment variable to prevent tokenizer deadlock warnings
import os
os.environ.setdefault('TOKENIZERS_PARALLELISM', 'false')
# Add users import
from fastapi.staticfiles import StaticFiles
import socketio
from app.api import chat, organizations, users, ai_setup, knowledge, agent, notification, widget, widget_apps, user_groups, roles, analytics, jira, shopify, workflow, workflow_node, mcp_tool, file_upload, token, lead_capture, people, tickets
from app.api import system_updates
from app.api import help_center as help_center_api
from app.api import help_center_images
from app.api import channels as channels_api
from app.api import webhooks as channel_webhooks
# Import widget_chat to register socket.io event handlers for /widget namespace
from app.api import widget_chat # noqa: F401 - imported for side effects (socket.io handlers registration)
from fastapi import FastAPI, BackgroundTasks
from app.core.config import settings, verify_secret_configuration
from app.core.encryption import verify_encryption_key
from app.services.file_storage import PUBLIC_UPLOAD_FOLDERS
from app.services.firebase import initialize_firebase
from app.database import engine, Base
import asyncio
from app.core.logger import get_logger
from contextlib import asynccontextmanager
import os
from app.channels.app_hooks import load_extensions
from app.core.socketio import socket_app, configure_socketio, sio
from app.core.cors import (
FirstPartyCredentialsCORSMiddleware,
first_party_origins,
get_cors_origin_regex,
get_cors_origins,
with_configured_origins,
)
from app.core.application import app, initialize_cors_listener
from app.core.crawler_policy import install_crawler_policy
# Import models to ensure they're registered with SQLAlchemy
from app.models import Organization, User, Customer
try:
from app.enterprise.models import OTP
except ImportError:
print("Enterprise models not available")
from app.api import session_to_agent
from app.workers.chat_auto_closer import run_auto_closer_loop
logger = get_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
# First: a bad ENCRYPTION_KEY must stop the boot, not surface as an unreadable
# conversation once the app is already serving traffic.
verify_encryption_key()
verify_secret_configuration()
initialize_firebase()
await startup_event(app)
yield
# Shutdown
pass
# Move the CORS setup before app instantiation
cors_origins = with_configured_origins(get_cors_origins())
logger.debug(f"CORS origins: {cors_origins}")
# Update the FastAPI app to use the lifespan function
app.router.lifespan_context = lifespan
# Add CORS middleware to FastAPI app. The regex covers every help-center
# subdomain (*.help.replypier.site) so a newly-created slug is allowed without a
# cache refresh; explicit origins still cover org domains + custom domains.
# Only first-party origins (the dashboard) may send credentials.
app.add_middleware(
FirstPartyCredentialsCORSMiddleware,
credentialed_origins=first_party_origins(),
allow_origins=cors_origins,
allow_origin_regex=get_cors_origin_regex(),
allow_methods=["*"],
allow_headers=["*"],
)
async def startup_event(api: FastAPI):
"""Configure Socket.IO on startup. Called from lifespan() with the FastAPI
instance; the on_event registration it used to carry never ran once a
custom lifespan was set. The module-level `app` is rebound to the Socket.IO
ASGI wrapper below, so the instance has to be passed in."""
configure_socketio(cors_origins)
# Channel extensions (e.g. an external-app integration) hook into message
# handling; loaded explicitly so other entry points never pull them in.
load_extensions()
# Start CORS listener for multi-worker synchronization
initialize_cors_listener()
# Start chat auto-closer background task, AI chat will auto close after 1 day.
# Held on app.state so the task cannot be garbage-collected mid-run.
api.state.auto_closer_task = asyncio.create_task(run_auto_closer_loop())
# Include routers
app.include_router(system_updates.router, prefix=f"{settings.API_V1_STR}/system-updates", tags=["system-updates"])
app.include_router(
chat.router,
prefix=f"{settings.API_V1_STR}/chats",
tags=["chats"]
)
app.include_router(
channels_api.router,
prefix=f"{settings.API_V1_STR}/channels",
tags=["channels"]
)
app.include_router(
tickets.router,
prefix=f"{settings.API_V1_STR}/tickets",
tags=["tickets"]
)
from app.api import crm as crm_api
app.include_router(
crm_api.router,
prefix=f"{settings.API_V1_STR}/crm",
tags=["crm"]
)
from app.api import ticket_db_connectors, ticket_webhooks
app.include_router(
ticket_db_connectors.router,
prefix=f"{settings.API_V1_STR}/ticket-db-connectors",
tags=["tickets"]
)
app.include_router(
ticket_webhooks.router,
prefix=f"{settings.API_V1_STR}/tickets/webhooks",
tags=["tickets"]
)
app.include_router(
channel_webhooks.router,
prefix=f"{settings.API_V1_STR}/webhooks",
tags=["webhooks"]
)
# Try to import enterprise module if available
try:
from app.enterprise import router as enterprise_router
app.include_router(enterprise_router, prefix=f"{settings.API_V1_STR}/enterprise", tags=["enterprise"])
except ImportError as e:
if os.path.exists(os.path.join(os.path.dirname(__file__), "enterprise", "__init__.py")):
# The module is there but won't load (e.g. it needs newer core code):
# signup and billing would vanish silently, so say so loudly.
logger.error(f"Enterprise module present but failed to import: {e}")
else:
logger.info("Enterprise module not available - running in community edition mode")
app.include_router(
organizations.router,
prefix=f"{settings.API_V1_STR}/organizations",
tags=["organizations"]
)
app.include_router(
users.router,
prefix=f"{settings.API_V1_STR}/users",
tags=["users"]
)
app.include_router(
help_center_api.router,
prefix=f"{settings.API_V1_STR}/help-center",
tags=["help-center"]
)
# Public: article images baked into FAQ Markdown. Same prefix as the admin
# router above but unauthenticated — article pages are public. Also registered
# on public_app for host mode.
app.include_router(
help_center_images.router,
prefix=f"{settings.API_V1_STR}/help-center",
tags=["help-center"]
)
app.include_router(
knowledge.router,
prefix=f"{settings.API_V1_STR}/knowledge",
tags=["knowledge"]
)
app.include_router(
ai_setup.router,
prefix=f"{settings.API_V1_STR}/ai",
tags=["ai"]
)
app.include_router(
agent.router,
prefix=f"{settings.API_V1_STR}/agent",
tags=["agent"]
)
app.include_router(
lead_capture.router,
prefix=f"{settings.API_V1_STR}/agent",
tags=["lead-capture"]
)
app.include_router(
people.router,
prefix=f"{settings.API_V1_STR}/people",
tags=["people"]
)
app.include_router(
mcp_tool.router,
prefix=f"{settings.API_V1_STR}/mcp-tools",
tags=["mcp-tools"]
)
# Proxy router moved to enterprise module
app.include_router(
notification.router,
prefix=f"{settings.API_V1_STR}/notifications",
tags=["notification"]
)
app.include_router(
widget.router,
prefix=f"{settings.API_V1_STR}/widgets",
tags=["widget"]
)
app.include_router(
user_groups.router,
prefix=f"{settings.API_V1_STR}/groups",
tags=["groups"]
)
app.include_router(
roles.router,
prefix=f"{settings.API_V1_STR}/roles",
tags=["roles"]
)
app.include_router(
session_to_agent.router,
prefix=f"{settings.API_V1_STR}/sessions",
tags=["session_to_agent"]
)
app.include_router(
analytics.router,
prefix=f"{settings.API_V1_STR}/analytics",
tags=["analytics"]
)
app.include_router(
jira.router,
prefix=f"{settings.API_V1_STR}/jira",
tags=["jira"]
)
app.include_router(
token.router,
prefix=f"{settings.API_V1_STR}",
tags=["token"]
)
app.include_router(
widget_apps.router,
prefix=f"{settings.API_V1_STR}/widget-apps",
tags=["widget-apps"]
)
app.include_router(
shopify.router,
prefix=f"{settings.API_V1_STR}/shopify",
tags=["shopify"]
)
app.include_router(
workflow.router,
prefix=f"{settings.API_V1_STR}/workflow",
tags=["workflow"]
)
app.include_router(
workflow_node.router,
prefix=f"{settings.API_V1_STR}/workflow",
tags=["workflow_node"]
)
app.include_router(
file_upload.router,
prefix=f"{settings.API_V1_STR}/files",
tags=["files"]
)
@app.get("/")
async def root():
return {
"name": settings.PROJECT_NAME,
"version": settings.VERSION,
"description": "Welcome to ReplyPier API"
}
@app.api_route("/health", methods=["GET"], operation_id="get_health_check")
async def get_health_check():
return {
"status": "healthy",
"version": settings.VERSION
}
@app.api_route("/health", methods=["HEAD"], operation_id="head_health_check")
async def head_health_check():
return {
"status": "healthy",
"version": settings.VERSION
}
@app.get("/health/help-center-domain", include_in_schema=False, operation_id="help_center_domain_tls_check")
async def help_center_domain_tls_check(domain: str = ""):
"""On-demand-TLS "ask" gate for the edge proxy (e.g. Caddy).
Returns 200 only when ``domain`` is a help-center host we actually serve —
a ``{slug}.HELP_CENTER_BASE_DOMAIN`` subdomain or a DB-verified custom
domain — so the edge provisions a certificate for those and refuses every
other name pointed at us. Public by design (the edge calls it
unauthenticated) but it leaks nothing: the answer is a bare status code and
the lookup reuses the same in-memory cache/slug check as host dispatch, so
there is no per-request database hit.
"""
from fastapi import Response
from app.core.help_center_host import is_help_center_host, normalize_host
host = normalize_host(domain)
if host and is_help_center_host(host):
return Response(status_code=200)
return Response(status_code=404)
# Create upload directories if they don't exist
if not os.path.exists("uploads"):
os.makedirs("uploads")
if not os.path.exists("uploads/agents"):
os.makedirs("uploads/agents")
# Only the public upload folders are static-mounted. Chat attachments are
# served through signed links (GET {API_V1_STR}/files/local/...), and the rest
# of uploads/ is never served. check_dir=False: a folder may not exist until
# its first upload.
for folder in PUBLIC_UPLOAD_FOLDERS:
app.mount(
f"{settings.API_V1_STR}/uploads/{folder}",
StaticFiles(directory=os.path.join("uploads", folder), check_dir=False),
name=f"uploads_{folder}",
)
app.mount("/assets", StaticFiles(directory=settings.WIDGET_ASSETS_DIR), name="assets")
# The API origin is not a website: no robots.txt meant crawlers were free to
# discover /api/v1/... endpoints and report them as 404s.
install_crawler_policy(app)
# Create final ASGI app
# Public help center: requests for {slug}.<help domain> / verified custom
# domains are dispatched to the SSR help-center app before reaching the API.
from app.api.help_center_public import public_app
from app.core.help_center_host import HelpCenterHostMiddleware
from app.core.system_maintenance import SystemMaintenanceMiddleware
app = SystemMaintenanceMiddleware(socketio.ASGIApp(sio, HelpCenterHostMiddleware(app, public_app)))