iflow-mcp_democratize-technology-chronos-mcp 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chronos_mcp/__init__.py +5 -0
- chronos_mcp/__main__.py +9 -0
- chronos_mcp/accounts.py +410 -0
- chronos_mcp/bulk.py +946 -0
- chronos_mcp/caldav_utils.py +149 -0
- chronos_mcp/calendars.py +204 -0
- chronos_mcp/config.py +187 -0
- chronos_mcp/credentials.py +190 -0
- chronos_mcp/events.py +515 -0
- chronos_mcp/exceptions.py +477 -0
- chronos_mcp/journals.py +477 -0
- chronos_mcp/logging_config.py +23 -0
- chronos_mcp/models.py +202 -0
- chronos_mcp/py.typed +0 -0
- chronos_mcp/rrule.py +259 -0
- chronos_mcp/search.py +315 -0
- chronos_mcp/server.py +121 -0
- chronos_mcp/tasks.py +518 -0
- chronos_mcp/tools/__init__.py +29 -0
- chronos_mcp/tools/accounts.py +151 -0
- chronos_mcp/tools/base.py +59 -0
- chronos_mcp/tools/bulk.py +557 -0
- chronos_mcp/tools/calendars.py +142 -0
- chronos_mcp/tools/events.py +698 -0
- chronos_mcp/tools/journals.py +310 -0
- chronos_mcp/tools/tasks.py +414 -0
- chronos_mcp/utils.py +163 -0
- chronos_mcp/validation.py +636 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/METADATA +299 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/RECORD +68 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/WHEEL +5 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/conftest.py +91 -0
- tests/unit/__init__.py +0 -0
- tests/unit/test_accounts.py +380 -0
- tests/unit/test_accounts_ssrf.py +134 -0
- tests/unit/test_base.py +135 -0
- tests/unit/test_bulk.py +380 -0
- tests/unit/test_bulk_create.py +408 -0
- tests/unit/test_bulk_delete.py +341 -0
- tests/unit/test_bulk_resource_limits.py +74 -0
- tests/unit/test_caldav_utils.py +300 -0
- tests/unit/test_calendars.py +286 -0
- tests/unit/test_config.py +111 -0
- tests/unit/test_config_validation.py +128 -0
- tests/unit/test_credentials_security.py +189 -0
- tests/unit/test_cryptography_security.py +178 -0
- tests/unit/test_events.py +536 -0
- tests/unit/test_exceptions.py +58 -0
- tests/unit/test_journals.py +1097 -0
- tests/unit/test_models.py +95 -0
- tests/unit/test_race_conditions.py +202 -0
- tests/unit/test_recurring_events.py +156 -0
- tests/unit/test_rrule.py +217 -0
- tests/unit/test_search.py +372 -0
- tests/unit/test_search_advanced.py +333 -0
- tests/unit/test_server_input_validation.py +219 -0
- tests/unit/test_ssrf_protection.py +505 -0
- tests/unit/test_tasks.py +918 -0
- tests/unit/test_thread_safety.py +301 -0
- tests/unit/test_tools_journals.py +617 -0
- tests/unit/test_tools_tasks.py +968 -0
- tests/unit/test_url_validation_security.py +234 -0
- tests/unit/test_utils.py +180 -0
- tests/unit/test_validation.py +983 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared CalDAV utility functions for calendar operations.
|
|
3
|
+
|
|
4
|
+
This module provides reusable patterns for working with CalDAV calendars,
|
|
5
|
+
eliminating code duplication across events, tasks, and journals managers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
from icalendar import Calendar as iCalendar
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_item_with_fallback(
|
|
16
|
+
calendar,
|
|
17
|
+
uid: str,
|
|
18
|
+
item_type: str,
|
|
19
|
+
request_id: Optional[str] = None,
|
|
20
|
+
) -> Any:
|
|
21
|
+
"""
|
|
22
|
+
Get CalDAV item by UID with automatic fallback to full search.
|
|
23
|
+
|
|
24
|
+
This function implements a two-method approach for finding CalDAV items:
|
|
25
|
+
1. Method 1: Try the direct UID lookup method if available (fast)
|
|
26
|
+
2. Method 2: Fallback to iterating all items and matching UID (slow but reliable)
|
|
27
|
+
|
|
28
|
+
This pattern is necessary because not all CalDAV servers implement the
|
|
29
|
+
direct UID lookup methods (event_by_uid, todo_by_uid, journal_by_uid).
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
calendar: CalDAV calendar object
|
|
33
|
+
uid: Item UID to find
|
|
34
|
+
item_type: Type of item - one of: "event", "task", "journal"
|
|
35
|
+
request_id: Optional request ID for logging context
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
CalDAV item object (Event, Todo, or Journal)
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: If item not found by either method
|
|
42
|
+
Exception: Any exception from CalDAV operations
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
>>> calendar = account.calendar(calendar_uid)
|
|
46
|
+
>>> event = get_item_with_fallback(calendar, "event-123", "event")
|
|
47
|
+
>>> task = get_item_with_fallback(calendar, "task-456", "task")
|
|
48
|
+
"""
|
|
49
|
+
# Map item types to their CalDAV methods and component names
|
|
50
|
+
type_config = {
|
|
51
|
+
"event": {
|
|
52
|
+
"by_uid_method": "event_by_uid",
|
|
53
|
+
"list_method": "events",
|
|
54
|
+
"fallback_method": None, # events don't have fallback
|
|
55
|
+
"component_name": "VEVENT",
|
|
56
|
+
},
|
|
57
|
+
"task": {
|
|
58
|
+
"by_uid_method": "event_by_uid", # CalDAV uses event_by_uid for tasks
|
|
59
|
+
"list_method": "todos",
|
|
60
|
+
"fallback_method": "events", # If todos() not available, use events()
|
|
61
|
+
"component_name": "VTODO",
|
|
62
|
+
},
|
|
63
|
+
"journal": {
|
|
64
|
+
"by_uid_method": "event_by_uid", # CalDAV uses event_by_uid for journals
|
|
65
|
+
"list_method": "journals",
|
|
66
|
+
"fallback_method": "events", # If journals() not available, use events()
|
|
67
|
+
"component_name": "VJOURNAL",
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if item_type not in type_config:
|
|
72
|
+
raise ValueError(f"Invalid item_type: {item_type}. Must be 'event', 'task', or 'journal'")
|
|
73
|
+
|
|
74
|
+
config = type_config[item_type]
|
|
75
|
+
by_uid_method = config["by_uid_method"]
|
|
76
|
+
list_method = config["list_method"]
|
|
77
|
+
fallback_method = config["fallback_method"]
|
|
78
|
+
component_name = config["component_name"]
|
|
79
|
+
|
|
80
|
+
# Method 1: Try direct UID lookup if available
|
|
81
|
+
if hasattr(calendar, by_uid_method):
|
|
82
|
+
try:
|
|
83
|
+
item = getattr(calendar, by_uid_method)(uid)
|
|
84
|
+
logger.debug(
|
|
85
|
+
f"Found {item_type} '{uid}' using {by_uid_method}",
|
|
86
|
+
extra={"request_id": request_id},
|
|
87
|
+
)
|
|
88
|
+
return item
|
|
89
|
+
except Exception as e:
|
|
90
|
+
logger.warning(
|
|
91
|
+
f"{by_uid_method} failed for {item_type} '{uid}': {e}, trying fallback method",
|
|
92
|
+
extra={"request_id": request_id},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Method 2: Fallback - iterate all items and match UID
|
|
96
|
+
try:
|
|
97
|
+
# Get the list of items
|
|
98
|
+
if hasattr(calendar, list_method):
|
|
99
|
+
items = getattr(calendar, list_method)()
|
|
100
|
+
elif fallback_method and hasattr(calendar, fallback_method):
|
|
101
|
+
# Use fallback method if primary not available (e.g., todos() not available)
|
|
102
|
+
logger.debug(
|
|
103
|
+
f"{list_method}() not available, using {fallback_method}() for {item_type}",
|
|
104
|
+
extra={"request_id": request_id},
|
|
105
|
+
)
|
|
106
|
+
items = getattr(calendar, fallback_method)()
|
|
107
|
+
else:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"Calendar does not support {list_method}() or {fallback_method}()"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Search through items
|
|
113
|
+
for item in items:
|
|
114
|
+
# Check if UID matches in the raw data (fast check)
|
|
115
|
+
# Handle both bytes (real CalDAV) and string (test mocks)
|
|
116
|
+
if isinstance(item.data, bytes):
|
|
117
|
+
uid_to_check = uid.encode('utf-8')
|
|
118
|
+
else:
|
|
119
|
+
uid_to_check = uid
|
|
120
|
+
|
|
121
|
+
if uid_to_check in item.data:
|
|
122
|
+
# Parse iCalendar to verify exact UID match
|
|
123
|
+
try:
|
|
124
|
+
ical = iCalendar.from_ical(item.data)
|
|
125
|
+
for component in ical.walk():
|
|
126
|
+
if component.name == component_name:
|
|
127
|
+
item_uid = str(component.get("uid", ""))
|
|
128
|
+
if item_uid == uid:
|
|
129
|
+
logger.debug(
|
|
130
|
+
f"Found {item_type} '{uid}' using fallback search",
|
|
131
|
+
extra={"request_id": request_id},
|
|
132
|
+
)
|
|
133
|
+
return item
|
|
134
|
+
except Exception as parse_error:
|
|
135
|
+
logger.warning(
|
|
136
|
+
f"Failed to parse {item_type} data: {parse_error}",
|
|
137
|
+
extra={"request_id": request_id},
|
|
138
|
+
)
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.warning(
|
|
143
|
+
f"Fallback search failed for {item_type} '{uid}': {e}",
|
|
144
|
+
extra={"request_id": request_id},
|
|
145
|
+
)
|
|
146
|
+
raise
|
|
147
|
+
|
|
148
|
+
# Item not found by either method
|
|
149
|
+
raise ValueError(f"{item_type.capitalize()} with UID '{uid}' not found")
|
chronos_mcp/calendars.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Calendar operations for Chronos MCP
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
import caldav
|
|
9
|
+
from caldav import Calendar as CalDAVCalendar
|
|
10
|
+
|
|
11
|
+
from .accounts import AccountManager
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
AccountNotFoundError,
|
|
14
|
+
CalendarCreationError,
|
|
15
|
+
CalendarDeletionError,
|
|
16
|
+
CalendarNotFoundError,
|
|
17
|
+
ErrorHandler,
|
|
18
|
+
)
|
|
19
|
+
from .logging_config import setup_logging
|
|
20
|
+
from .models import Calendar
|
|
21
|
+
|
|
22
|
+
logger = setup_logging()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CalendarManager:
|
|
26
|
+
"""Manage calendar operations"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, account_manager: AccountManager):
|
|
29
|
+
self.accounts = account_manager
|
|
30
|
+
|
|
31
|
+
def list_calendars(
|
|
32
|
+
self, account_alias: Optional[str] = None, request_id: Optional[str] = None
|
|
33
|
+
) -> List[Calendar]:
|
|
34
|
+
"""List all calendars for an account - raises exceptions on failure"""
|
|
35
|
+
request_id = request_id or str(uuid.uuid4())
|
|
36
|
+
|
|
37
|
+
principal = self.accounts.get_principal(account_alias)
|
|
38
|
+
if not principal:
|
|
39
|
+
raise AccountNotFoundError(
|
|
40
|
+
account_alias
|
|
41
|
+
or self.accounts.config.config.default_account
|
|
42
|
+
or "default",
|
|
43
|
+
request_id=request_id,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
calendars = []
|
|
47
|
+
try:
|
|
48
|
+
for cal in principal.calendars():
|
|
49
|
+
# Extract calendar properties
|
|
50
|
+
cal_info = Calendar(
|
|
51
|
+
uid=(
|
|
52
|
+
str(cal.url).split("/")[-2]
|
|
53
|
+
if str(cal.url).endswith("/")
|
|
54
|
+
else str(cal.url).split("/")[-1]
|
|
55
|
+
),
|
|
56
|
+
name=cal.name or "Unnamed Calendar",
|
|
57
|
+
description=None, # Will need to fetch from properties
|
|
58
|
+
color=None, # Will need to fetch from properties
|
|
59
|
+
account_alias=account_alias
|
|
60
|
+
or self.accounts.config.config.default_account,
|
|
61
|
+
url=str(cal.url),
|
|
62
|
+
read_only=False, # Will need to check permissions
|
|
63
|
+
)
|
|
64
|
+
calendars.append(cal_info)
|
|
65
|
+
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.error(f"Error listing calendars: {e}")
|
|
68
|
+
|
|
69
|
+
return calendars
|
|
70
|
+
|
|
71
|
+
def create_calendar(
|
|
72
|
+
self,
|
|
73
|
+
name: str,
|
|
74
|
+
description: Optional[str] = None,
|
|
75
|
+
color: Optional[str] = None,
|
|
76
|
+
account_alias: Optional[str] = None,
|
|
77
|
+
request_id: Optional[str] = None,
|
|
78
|
+
) -> Optional[Calendar]:
|
|
79
|
+
"""Create a new calendar - raises exceptions on failure"""
|
|
80
|
+
request_id = request_id or str(uuid.uuid4())
|
|
81
|
+
|
|
82
|
+
principal = self.accounts.get_principal(account_alias)
|
|
83
|
+
if not principal:
|
|
84
|
+
raise AccountNotFoundError(
|
|
85
|
+
account_alias
|
|
86
|
+
or self.accounts.config.config.default_account
|
|
87
|
+
or "default",
|
|
88
|
+
request_id=request_id,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
cal_id = name.lower().replace(" ", "_")
|
|
93
|
+
cal = principal.make_calendar(name=name, cal_id=cal_id)
|
|
94
|
+
|
|
95
|
+
# Note: description and color properties would need CalDAV server support
|
|
96
|
+
# for setting calendar properties beyond name
|
|
97
|
+
|
|
98
|
+
return Calendar(
|
|
99
|
+
uid=cal_id,
|
|
100
|
+
name=name,
|
|
101
|
+
description=description,
|
|
102
|
+
color=color,
|
|
103
|
+
account_alias=account_alias
|
|
104
|
+
or self.accounts.config.config.default_account,
|
|
105
|
+
url=str(cal.url),
|
|
106
|
+
read_only=False,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
except caldav.lib.error.AuthorizationError as e:
|
|
110
|
+
logger.error(
|
|
111
|
+
f"Authorization error creating calendar '{name}': {e}",
|
|
112
|
+
extra={"request_id": request_id},
|
|
113
|
+
)
|
|
114
|
+
raise CalendarCreationError(
|
|
115
|
+
name, "Authorization failed", request_id=request_id
|
|
116
|
+
)
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.error(
|
|
119
|
+
f"Error creating calendar '{name}': {e}",
|
|
120
|
+
extra={"request_id": request_id},
|
|
121
|
+
)
|
|
122
|
+
raise CalendarCreationError(name, str(e), request_id=request_id)
|
|
123
|
+
|
|
124
|
+
def delete_calendar(
|
|
125
|
+
self,
|
|
126
|
+
calendar_uid: str,
|
|
127
|
+
account_alias: Optional[str] = None,
|
|
128
|
+
request_id: Optional[str] = None,
|
|
129
|
+
) -> bool:
|
|
130
|
+
"""Delete a calendar - raises exceptions on failure"""
|
|
131
|
+
request_id = request_id or str(uuid.uuid4())
|
|
132
|
+
|
|
133
|
+
principal = self.accounts.get_principal(account_alias)
|
|
134
|
+
if not principal:
|
|
135
|
+
raise AccountNotFoundError(
|
|
136
|
+
account_alias
|
|
137
|
+
or self.accounts.config.config.default_account
|
|
138
|
+
or "default",
|
|
139
|
+
request_id=request_id,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
# Find calendar by UID
|
|
144
|
+
for cal in principal.calendars():
|
|
145
|
+
cal_id = (
|
|
146
|
+
str(cal.url).split("/")[-2]
|
|
147
|
+
if str(cal.url).endswith("/")
|
|
148
|
+
else str(cal.url).split("/")[-1]
|
|
149
|
+
)
|
|
150
|
+
if cal_id == calendar_uid:
|
|
151
|
+
cal.delete()
|
|
152
|
+
logger.info(
|
|
153
|
+
f"Deleted calendar '{calendar_uid}'",
|
|
154
|
+
extra={"request_id": request_id},
|
|
155
|
+
)
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
# Calendar not found
|
|
159
|
+
raise CalendarNotFoundError(
|
|
160
|
+
calendar_uid, account_alias, request_id=request_id
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
except CalendarNotFoundError:
|
|
164
|
+
raise # Re-raise our own exception
|
|
165
|
+
except caldav.lib.error.AuthorizationError as e:
|
|
166
|
+
logger.error(
|
|
167
|
+
f"Authorization error deleting calendar '{calendar_uid}': {e}",
|
|
168
|
+
extra={"request_id": request_id},
|
|
169
|
+
)
|
|
170
|
+
raise CalendarDeletionError(
|
|
171
|
+
calendar_uid, "Authorization failed", request_id=request_id
|
|
172
|
+
)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
logger.error(
|
|
175
|
+
f"Error deleting calendar '{calendar_uid}': {e}",
|
|
176
|
+
extra={"request_id": request_id},
|
|
177
|
+
)
|
|
178
|
+
raise CalendarDeletionError(calendar_uid, str(e), request_id=request_id)
|
|
179
|
+
|
|
180
|
+
@ErrorHandler.safe_operation(logger, default_return=None)
|
|
181
|
+
def get_calendar(
|
|
182
|
+
self,
|
|
183
|
+
calendar_uid: str,
|
|
184
|
+
account_alias: Optional[str] = None,
|
|
185
|
+
request_id: Optional[str] = None,
|
|
186
|
+
) -> Optional[CalDAVCalendar]:
|
|
187
|
+
"""Get CalDAV calendar object by UID - internal utility method"""
|
|
188
|
+
principal = self.accounts.get_principal(account_alias)
|
|
189
|
+
if not principal:
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
for cal in principal.calendars():
|
|
194
|
+
cal_id = (
|
|
195
|
+
str(cal.url).split("/")[-2]
|
|
196
|
+
if str(cal.url).endswith("/")
|
|
197
|
+
else str(cal.url).split("/")[-1]
|
|
198
|
+
)
|
|
199
|
+
if cal_id == calendar_uid:
|
|
200
|
+
return cal
|
|
201
|
+
except Exception as e:
|
|
202
|
+
logger.error(f"Error getting calendar: {e}")
|
|
203
|
+
|
|
204
|
+
return None
|
chronos_mcp/config.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for Chronos MCP
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Optional
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from .credentials import get_credential_manager
|
|
13
|
+
from .logging_config import setup_logging
|
|
14
|
+
from .models import Account
|
|
15
|
+
|
|
16
|
+
logger = setup_logging()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ChronosConfig(BaseModel):
|
|
20
|
+
"""Main configuration"""
|
|
21
|
+
|
|
22
|
+
accounts: Dict[str, Account] = Field(
|
|
23
|
+
default_factory=dict, description="Configured accounts"
|
|
24
|
+
)
|
|
25
|
+
default_account: Optional[str] = Field(None, description="Default account alias")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConfigManager:
|
|
29
|
+
"""Manage Chronos configuration"""
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self.config_dir = Path.home() / ".chronos"
|
|
33
|
+
self.config_file = self.config_dir / "accounts.json"
|
|
34
|
+
self.config: ChronosConfig = ChronosConfig()
|
|
35
|
+
self._load_config()
|
|
36
|
+
|
|
37
|
+
def _load_config(self):
|
|
38
|
+
"""Load configuration from file and environment"""
|
|
39
|
+
# First, try to load from config file
|
|
40
|
+
if self.config_file.exists():
|
|
41
|
+
try:
|
|
42
|
+
with open(self.config_file, "r") as f:
|
|
43
|
+
data = json.load(f)
|
|
44
|
+
# Convert account dicts to Account objects
|
|
45
|
+
accounts = {}
|
|
46
|
+
for alias, acc_data in data.get("accounts", {}).items():
|
|
47
|
+
acc_data["alias"] = alias
|
|
48
|
+
if "url" in acc_data and isinstance(acc_data["url"], str):
|
|
49
|
+
accounts[alias] = Account(**acc_data)
|
|
50
|
+
|
|
51
|
+
self.config = ChronosConfig(
|
|
52
|
+
accounts=accounts, default_account=data.get("default_account")
|
|
53
|
+
)
|
|
54
|
+
logger.info(f"Loaded {len(accounts)} accounts from config file")
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.error(f"Error loading config file: {e}")
|
|
57
|
+
|
|
58
|
+
env_url = os.getenv("CALDAV_BASE_URL")
|
|
59
|
+
env_username = os.getenv("CALDAV_USERNAME")
|
|
60
|
+
env_password = os.getenv("CALDAV_PASSWORD")
|
|
61
|
+
|
|
62
|
+
# Validate environment variables before use (defense-in-depth)
|
|
63
|
+
if env_url and env_username:
|
|
64
|
+
from .validation import InputValidator
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
# Allow local URLs for development environments
|
|
68
|
+
env_url = InputValidator.validate_url(
|
|
69
|
+
env_url, allow_private_ips=True, field_name="CALDAV_BASE_URL"
|
|
70
|
+
)
|
|
71
|
+
env_username = InputValidator.validate_text_field(
|
|
72
|
+
env_username, "CALDAV_USERNAME", required=True
|
|
73
|
+
)
|
|
74
|
+
if env_password:
|
|
75
|
+
env_password = InputValidator.validate_text_field(
|
|
76
|
+
env_password, "CALDAV_PASSWORD", required=True
|
|
77
|
+
)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.error(f"Invalid environment variable values: {e}")
|
|
80
|
+
return # Skip environment account creation if validation fails
|
|
81
|
+
|
|
82
|
+
if env_url and env_username:
|
|
83
|
+
env_account = Account(
|
|
84
|
+
alias="default",
|
|
85
|
+
url=env_url,
|
|
86
|
+
username=env_username,
|
|
87
|
+
password=env_password,
|
|
88
|
+
display_name="Default Account (from environment)",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if "default" not in self.config.accounts:
|
|
92
|
+
# Store password in keyring if available
|
|
93
|
+
if env_password:
|
|
94
|
+
credential_manager = get_credential_manager()
|
|
95
|
+
if credential_manager.keyring_available:
|
|
96
|
+
if credential_manager.set_password("default", env_password):
|
|
97
|
+
logger.info("Environment password stored in keyring")
|
|
98
|
+
# Don't include password in account object if stored in keyring
|
|
99
|
+
env_account.password = None
|
|
100
|
+
|
|
101
|
+
self.config.accounts["default"] = env_account
|
|
102
|
+
if not self.config.default_account:
|
|
103
|
+
self.config.default_account = "default"
|
|
104
|
+
logger.info("Added default account from environment variables")
|
|
105
|
+
|
|
106
|
+
def save_config(self):
|
|
107
|
+
"""Save configuration to file"""
|
|
108
|
+
self.config_dir.mkdir(exist_ok=True)
|
|
109
|
+
|
|
110
|
+
credential_manager = get_credential_manager()
|
|
111
|
+
|
|
112
|
+
data = {"accounts": {}, "default_account": self.config.default_account}
|
|
113
|
+
|
|
114
|
+
for alias, acc in self.config.accounts.items():
|
|
115
|
+
account_data = {
|
|
116
|
+
"url": str(acc.url),
|
|
117
|
+
"username": acc.username,
|
|
118
|
+
"display_name": acc.display_name,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Only save password to config if keyring is not available
|
|
122
|
+
if not credential_manager.keyring_available and acc.password:
|
|
123
|
+
account_data["password"] = acc.password
|
|
124
|
+
logger.warning(
|
|
125
|
+
f"Saving password for '{alias}' to config file (keyring not available)"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
data["accounts"][alias] = account_data
|
|
129
|
+
|
|
130
|
+
with open(self.config_file, "w") as f:
|
|
131
|
+
json.dump(data, f, indent=2)
|
|
132
|
+
logger.info("Configuration saved")
|
|
133
|
+
|
|
134
|
+
def add_account(self, account: Account):
|
|
135
|
+
"""Add a new account
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
account: The Account object to add
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
AccountAlreadyExistsError: If an account with the same alias already exists
|
|
142
|
+
"""
|
|
143
|
+
if account.alias in self.config.accounts:
|
|
144
|
+
from .exceptions import AccountAlreadyExistsError
|
|
145
|
+
|
|
146
|
+
raise AccountAlreadyExistsError(account.alias)
|
|
147
|
+
|
|
148
|
+
# Store password in keyring if available
|
|
149
|
+
if account.password:
|
|
150
|
+
credential_manager = get_credential_manager()
|
|
151
|
+
if credential_manager.keyring_available:
|
|
152
|
+
if credential_manager.set_password(account.alias, account.password):
|
|
153
|
+
logger.info(f"Password for '{account.alias}' stored in keyring")
|
|
154
|
+
else:
|
|
155
|
+
logger.warning(
|
|
156
|
+
f"Failed to store password in keyring for '{account.alias}'"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
self.config.accounts[account.alias] = account
|
|
160
|
+
if not self.config.default_account:
|
|
161
|
+
self.config.default_account = account.alias
|
|
162
|
+
self.save_config()
|
|
163
|
+
|
|
164
|
+
def remove_account(self, alias: str):
|
|
165
|
+
"""Remove an account"""
|
|
166
|
+
if alias in self.config.accounts:
|
|
167
|
+
# Remove password from keyring if stored there
|
|
168
|
+
credential_manager = get_credential_manager()
|
|
169
|
+
if credential_manager.delete_password(alias):
|
|
170
|
+
logger.info(f"Password removed from keyring for account: {alias}")
|
|
171
|
+
|
|
172
|
+
del self.config.accounts[alias]
|
|
173
|
+
if self.config.default_account == alias:
|
|
174
|
+
self.config.default_account = next(iter(self.config.accounts), None)
|
|
175
|
+
self.save_config()
|
|
176
|
+
|
|
177
|
+
def get_account(self, alias: Optional[str] = None) -> Optional[Account]:
|
|
178
|
+
"""Get an account by alias or return default"""
|
|
179
|
+
if alias:
|
|
180
|
+
return self.config.accounts.get(alias)
|
|
181
|
+
elif self.config.default_account:
|
|
182
|
+
return self.config.accounts.get(self.config.default_account)
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
def list_accounts(self) -> Dict[str, Account]:
|
|
186
|
+
"""List all configured accounts"""
|
|
187
|
+
return self.config.accounts
|