diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index e0e853e3a..b599ff575 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1541,6 +1541,18 @@ ENABLE_CHANNELS = PersistentConfig( os.environ.get('ENABLE_CHANNELS', 'False').lower() == 'true', ) +AUTOMATION_MAX_COUNT = PersistentConfig( + 'AUTOMATION_MAX_COUNT', + 'automations.max_count', + os.environ.get('AUTOMATION_MAX_COUNT', ''), +) + +AUTOMATION_MIN_INTERVAL = PersistentConfig( + 'AUTOMATION_MIN_INTERVAL', + 'automations.min_interval', + os.environ.get('AUTOMATION_MIN_INTERVAL', ''), +) + ENABLE_NOTES = PersistentConfig( 'ENABLE_NOTES', 'notes.enable', diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 03bb65108..8fc56a665 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -383,6 +383,8 @@ from open_webui.config import ( API_KEYS_ALLOWED_ENDPOINTS, ENABLE_FOLDERS, FOLDER_MAX_FILE_COUNT, + AUTOMATION_MAX_COUNT, + AUTOMATION_MIN_INTERVAL, ENABLE_CHANNELS, ENABLE_NOTES, ENABLE_USER_STATUS, @@ -874,6 +876,8 @@ app.state.config.BANNERS = WEBUI_BANNERS app.state.config.ENABLE_FOLDERS = ENABLE_FOLDERS app.state.config.FOLDER_MAX_FILE_COUNT = FOLDER_MAX_FILE_COUNT +app.state.config.AUTOMATION_MAX_COUNT = AUTOMATION_MAX_COUNT +app.state.config.AUTOMATION_MIN_INTERVAL = AUTOMATION_MIN_INTERVAL app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS app.state.config.ENABLE_NOTES = ENABLE_NOTES app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING diff --git a/backend/open_webui/models/automations.py b/backend/open_webui/models/automations.py index e6b842153..7a6bccb9a 100644 --- a/backend/open_webui/models/automations.py +++ b/backend/open_webui/models/automations.py @@ -143,6 +143,10 @@ class AutomationTable: db.refresh(row) return AutomationModel.model_validate(row) + def count_by_user(self, user_id: str, db: Optional[Session] = None) -> int: + with get_db_context(db) as db: + return db.query(Automation).filter_by(user_id=user_id).count() + def get_by_id(self, id: str, db: Optional[Session] = None) -> Optional[AutomationModel]: with get_db_context(db) as db: row = db.get(Automation, id) diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 8cca63baf..5e054596c 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -950,6 +950,8 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)): 'ENABLE_MESSAGE_RATING': request.app.state.config.ENABLE_MESSAGE_RATING, 'ENABLE_FOLDERS': request.app.state.config.ENABLE_FOLDERS, 'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT, + 'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT, + 'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL, 'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS, 'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES, 'ENABLE_NOTES': request.app.state.config.ENABLE_NOTES, @@ -976,6 +978,8 @@ class AdminConfig(BaseModel): ENABLE_MESSAGE_RATING: bool ENABLE_FOLDERS: bool FOLDER_MAX_FILE_COUNT: Optional[int | str] = None + AUTOMATION_MAX_COUNT: Optional[int | str] = None + AUTOMATION_MIN_INTERVAL: Optional[int | str] = None ENABLE_CHANNELS: bool ENABLE_MEMORIES: bool ENABLE_NOTES: bool @@ -1001,6 +1005,12 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep request.app.state.config.FOLDER_MAX_FILE_COUNT = ( int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else '' ) + request.app.state.config.AUTOMATION_MAX_COUNT = ( + int(form_data.AUTOMATION_MAX_COUNT) if form_data.AUTOMATION_MAX_COUNT else '' + ) + request.app.state.config.AUTOMATION_MIN_INTERVAL = ( + int(form_data.AUTOMATION_MIN_INTERVAL) if form_data.AUTOMATION_MIN_INTERVAL else '' + ) request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS request.app.state.config.ENABLE_MEMORIES = form_data.ENABLE_MEMORIES request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES @@ -1042,6 +1052,8 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep 'ENABLE_MESSAGE_RATING': request.app.state.config.ENABLE_MESSAGE_RATING, 'ENABLE_FOLDERS': request.app.state.config.ENABLE_FOLDERS, 'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT, + 'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT, + 'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL, 'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS, 'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES, 'ENABLE_NOTES': request.app.state.config.ENABLE_NOTES, diff --git a/backend/open_webui/routers/automations.py b/backend/open_webui/routers/automations.py index f4608d077..0f8511572 100644 --- a/backend/open_webui/routers/automations.py +++ b/backend/open_webui/routers/automations.py @@ -19,6 +19,7 @@ from open_webui.utils.automations import ( next_run_ns, next_n_runs_ns, execute_automation, + rrule_interval_seconds, ) from open_webui.utils.auth import get_verified_user, get_admin_user from open_webui.utils.access_control import has_permission @@ -60,6 +61,35 @@ def check_automation_access(automation, user): ) +def check_automation_limits(request, user, rrule_str: str, db, is_create: bool = False): + """Enforce global automation limits. Admins bypass all checks.""" + if user.role == 'admin': + return + + # Max count (create only) + if is_create: + max_count = request.app.state.config.AUTOMATION_MAX_COUNT + if max_count: + max_count = int(max_count) + if max_count > 0 and Automations.count_by_user(user.id, db=db) >= max_count: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f'Automation limit reached ({max_count})', + ) + + # Min interval (create + update) + min_interval = request.app.state.config.AUTOMATION_MIN_INTERVAL + if min_interval: + min_interval = int(min_interval) + if min_interval > 0: + interval = rrule_interval_seconds(rrule_str) + if interval is not None and interval < min_interval: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Schedule too frequent. Minimum interval is {min_interval} seconds.', + ) + + def enrich_automation(automation: AutomationModel, db: Session, tz: str = None) -> AutomationResponse: """Full enrichment for single-item views (includes next_runs computation).""" last_run = AutomationRuns.get_latest(automation.id, db=db) @@ -135,6 +165,8 @@ async def create_new_automation( detail=str(e), ) + check_automation_limits(request, user, form_data.data.rrule, db, is_create=True) + # Validate terminal server exists if linked if form_data.data.terminal and form_data.data.terminal.server_id: connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] @@ -192,6 +224,8 @@ async def update_automation_by_id( detail=str(e), ) + check_automation_limits(request, user, form_data.data.rrule, db, is_create=False) + # Validate terminal server exists if linked if form_data.data.terminal and form_data.data.terminal.server_id: connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py index 4d9eb2fb6..5d28307f7 100644 --- a/backend/open_webui/utils/automations.py +++ b/backend/open_webui/utils/automations.py @@ -92,6 +92,25 @@ def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]: return result +def rrule_interval_seconds(s: str) -> Optional[int]: + """Approximate interval between recurrences in seconds. + + Returns None for one-shot (COUNT=1) schedules or rules + with fewer than two future occurrences. + """ + if 'COUNT=1' in s: + return None + rule = _parse_rule(s) + now = datetime.now() + first = rule.after(now) + if first is None: + return None + second = rule.after(first) + if second is None: + return None + return int((second - first).total_seconds()) + + ############################ # Worker Loop ############################