Skip to content

Commit fef9dfc

Browse files
authored
feat: expose env config backup in web settings (#1253)
* feat: expose env config backup in web settings
1 parent 29d3e0b commit fef9dfc

14 files changed

Lines changed: 505 additions & 145 deletions

File tree

api/v1/endpoints/system_config.py

Lines changed: 75 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
# -*- coding: utf-8 -*-
21
"""System configuration endpoints."""
32

43
from __future__ import annotations
54

65
import logging
76
import os
87

9-
from fastapi import APIRouter, Depends, HTTPException, Query
8+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
109

1110
from api.deps import get_system_config_service
1211
from api.v1.schemas.common import ErrorResponse
@@ -29,6 +28,7 @@
2928
ValidateSystemConfigRequest,
3029
ValidateSystemConfigResponse,
3130
)
31+
from src.auth import COOKIE_NAME, is_auth_enabled, refresh_auth_state, verify_session
3232
from src.services.system_config_service import (
3333
ConfigConflictError,
3434
ConfigImportError,
@@ -41,17 +41,50 @@
4141
router = APIRouter()
4242

4343

44-
def _ensure_desktop_mode() -> None:
45-
"""Restrict desktop backup/restore endpoints to desktop runtime only."""
46-
if os.getenv("DSA_DESKTOP_MODE", "").strip().lower() != "true":
47-
raise HTTPException(
44+
class EnvBackupAccessDenied(Exception):
45+
"""Raised when raw `.env` backup access is not allowed for this request."""
46+
47+
def __init__(self, *, status_code: int, message: str) -> None:
48+
super().__init__(message)
49+
self.status_code = status_code
50+
self.message = message
51+
52+
53+
def _allow_env_backup_access(request: Request) -> None:
54+
"""Gate raw .env backup/restore to explicit secure modes.
55+
56+
- Desktop runtime keeps existing local behavior via DSA_DESKTOP_MODE.
57+
- Non-desktop runtime must have admin auth enabled and a valid session.
58+
"""
59+
if os.getenv("DSA_DESKTOP_MODE") == "true":
60+
return
61+
62+
refresh_auth_state()
63+
if not is_auth_enabled():
64+
raise EnvBackupAccessDenied(
4865
status_code=403,
49-
detail={
50-
"error": "desktop_only_feature",
51-
"message": "This endpoint is only available in desktop mode",
52-
},
66+
message="System config backup is disabled; enable admin authentication first",
5367
)
5468

69+
cookie_val = request.cookies.get(COOKIE_NAME)
70+
if cookie_val and verify_session(cookie_val):
71+
return
72+
73+
raise EnvBackupAccessDenied(
74+
status_code=401,
75+
message="System config backup requires a valid admin session",
76+
)
77+
78+
79+
def _raise_env_backup_access_error(exc: EnvBackupAccessDenied) -> None:
80+
raise HTTPException(
81+
status_code=exc.status_code,
82+
detail={
83+
"error": "env_backup_access_denied",
84+
"message": exc.message,
85+
},
86+
)
87+
5588

5689
@router.get(
5790
"/config",
@@ -170,29 +203,35 @@ def update_system_config(
170203
"/config/export",
171204
response_model=ExportSystemConfigResponse,
172205
responses={
173-
200: {"description": "Desktop env exported"},
206+
200: {"description": "Env exported"},
174207
401: {"description": "Unauthorized", "model": ErrorResponse},
175-
403: {"description": "Desktop mode only", "model": ErrorResponse},
208+
403: {"description": "Env backup disabled", "model": ErrorResponse},
176209
500: {"description": "Internal server error", "model": ErrorResponse},
177210
},
178-
summary="Export desktop env backup",
179-
description="Desktop-only endpoint that returns the raw saved .env content.",
211+
summary="Export env backup",
212+
description="Return the raw saved .env content for configuration backup.",
180213
)
181-
def export_desktop_system_config(
214+
def export_system_config(
215+
request: Request,
182216
service: SystemConfigService = Depends(get_system_config_service),
183217
) -> ExportSystemConfigResponse:
184-
"""Export the active `.env` file for desktop backup."""
185-
_ensure_desktop_mode()
218+
"""Export the active `.env` file for config backup."""
186219
try:
187-
payload = service.export_desktop_env()
220+
_allow_env_backup_access(request)
221+
except EnvBackupAccessDenied as exc:
222+
logger.warning("System config export blocked: %s", exc)
223+
_raise_env_backup_access_error(exc)
224+
225+
try:
226+
payload = service.export_env()
188227
return ExportSystemConfigResponse.model_validate(payload)
189228
except Exception as exc:
190-
logger.error("Failed to export desktop system configuration: %s", exc, exc_info=True)
229+
logger.error("Failed to export system configuration: %s", exc, exc_info=True)
191230
raise HTTPException(
192231
status_code=500,
193232
detail={
194233
"error": "internal_error",
195-
"message": "Failed to export desktop system configuration",
234+
"message": "Failed to export system configuration",
196235
},
197236
)
198237

@@ -201,7 +240,7 @@ def export_desktop_system_config(
201240
"/config/import",
202241
response_model=UpdateSystemConfigResponse,
203242
responses={
204-
200: {"description": "Desktop env imported"},
243+
200: {"description": "Env imported"},
205244
400: {
206245
"description": "Import failed",
207246
"content": {
@@ -216,21 +255,27 @@ def export_desktop_system_config(
216255
},
217256
},
218257
401: {"description": "Unauthorized", "model": ErrorResponse},
219-
403: {"description": "Desktop mode only", "model": ErrorResponse},
258+
403: {"description": "Env backup disabled", "model": ErrorResponse},
220259
409: {"description": "Version conflict", "model": SystemConfigConflictResponse},
221260
500: {"description": "Internal server error", "model": ErrorResponse},
222261
},
223-
summary="Import desktop env backup",
224-
description="Desktop-only endpoint that merges raw .env text into the saved configuration.",
262+
summary="Import env backup",
263+
description="Merge raw .env text into the saved configuration with config version conflict protection.",
225264
)
226-
def import_desktop_system_config(
265+
def import_system_config(
227266
request: ImportSystemConfigRequest,
267+
request_obj: Request,
228268
service: SystemConfigService = Depends(get_system_config_service),
229269
) -> UpdateSystemConfigResponse:
230-
"""Import a desktop `.env` backup into the active config."""
231-
_ensure_desktop_mode()
270+
"""Import a `.env` backup into the active config."""
271+
try:
272+
_allow_env_backup_access(request_obj)
273+
except EnvBackupAccessDenied as exc:
274+
logger.warning("System config import blocked: %s", exc)
275+
_raise_env_backup_access_error(exc)
276+
232277
try:
233-
payload = service.import_desktop_env(
278+
payload = service.import_env(
234279
config_version=request.config_version,
235280
content=request.content,
236281
reload_now=request.reload_now,
@@ -263,12 +308,12 @@ def import_desktop_system_config(
263308
},
264309
)
265310
except Exception as exc:
266-
logger.error("Failed to import desktop system configuration: %s", exc, exc_info=True)
311+
logger.error("Failed to import system configuration: %s", exc, exc_info=True)
267312
raise HTTPException(
268313
status_code=500,
269314
detail={
270315
"error": "internal_error",
271-
"message": "Failed to import desktop system configuration",
316+
"message": "Failed to import system configuration",
272317
},
273318
)
274319

api/v1/schemas/system_config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ class SetupStatusResponse(BaseModel):
120120

121121

122122
class ExportSystemConfigResponse(BaseModel):
123-
"""Desktop-only export payload for raw `.env` backups."""
123+
"""Export payload for raw `.env` backups."""
124124

125125
content: str
126126
config_version: str
@@ -162,7 +162,7 @@ class ValidateSystemConfigRequest(BaseModel):
162162

163163

164164
class ImportSystemConfigRequest(BaseModel):
165-
"""Desktop-only import request payload."""
165+
"""Import request payload for raw `.env` backups."""
166166

167167
config_version: str
168168
content: str

apps/dsa-web/src/api/systemConfig.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,15 @@ export const systemConfigApi = {
135135
return toCamelCase<SystemConfigResponse>(response.data);
136136
},
137137

138-
async exportDesktopEnv(): Promise<ExportSystemConfigResponse> {
138+
async exportEnv(): Promise<ExportSystemConfigResponse> {
139139
const response = await apiClient.get<Record<string, unknown>>('/api/v1/system/config/export');
140140
return toCamelCase<ExportSystemConfigResponse>(response.data);
141141
},
142142

143+
async exportDesktopEnv(): Promise<ExportSystemConfigResponse> {
144+
return this.exportEnv();
145+
},
146+
143147
async getSchema(): Promise<SystemConfigSchemaResponse> {
144148
const response = await apiClient.get<Record<string, unknown>>('/api/v1/system/config/schema');
145149
return toCamelCase<SystemConfigSchemaResponse>(response.data);
@@ -158,14 +162,18 @@ export const systemConfigApi = {
158162
return toCamelCase<ValidateSystemConfigResponse>(response.data);
159163
},
160164

161-
async importDesktopEnv(payload: ImportSystemConfigRequest): Promise<UpdateSystemConfigResponse> {
165+
async importEnv(payload: ImportSystemConfigRequest): Promise<UpdateSystemConfigResponse> {
162166
const response = await apiClient.post<Record<string, unknown>>(
163167
'/api/v1/system/config/import',
164168
toSnakeImportPayload(payload),
165169
);
166170
return toCamelCase<UpdateSystemConfigResponse>(response.data);
167171
},
168172

173+
async importDesktopEnv(payload: ImportSystemConfigRequest): Promise<UpdateSystemConfigResponse> {
174+
return this.importEnv(payload);
175+
},
176+
169177
async testLLMChannel(payload: TestLLMChannelRequest): Promise<TestLLMChannelResponse> {
170178
const response = await apiClient.post<Record<string, unknown>>(
171179
'/api/v1/system/config/llm/test-channel',

0 commit comments

Comments
 (0)