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.
393 lines
12 KiB
Python
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)))
|