Compare commits
10 Commits
a52e35b52b
...
74495e56cb
| Author | SHA1 | Date | |
|---|---|---|---|
| 74495e56cb | |||
| 6ca1816c95 | |||
| 2d7d105ba3 | |||
| 41b9426670 | |||
| baf624c7a6 | |||
| d10570fc0c | |||
| 30f6d79f9e | |||
| 760a87ab99 | |||
| 7098751f7f | |||
| 1df2a1a730 |
10
README.md
10
README.md
@@ -40,9 +40,13 @@ The first backend implementation stores VEVENT data in two layers:
|
|||||||
CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source
|
CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source
|
||||||
records the remote collection URL, sync token, ETag/ctag state, username,
|
records the remote collection URL, sync token, ETag/ctag state, username,
|
||||||
credential reference, sync interval, sync direction, and conflict policy.
|
credential reference, sync interval, sync direction, and conflict policy.
|
||||||
Credentials can be supplied transiently for manual sync, referenced from
|
Credentials can be supplied transiently for manual sync. Persisted API-managed
|
||||||
environment variables with `env:NAME`, stored through a platform secret provider
|
sources accept only a password or token: Calendar stores an opaque, tenant- and
|
||||||
when one is available, or stored as encrypted calendar-owned credentials.
|
source-bound local reference, backed by the platform secret provider when one is
|
||||||
|
available or by an encrypted calendar-owned credential otherwise. Caller-selected
|
||||||
|
environment and external-provider references are rejected. Trusted deployment
|
||||||
|
code may resolve an `env:NAME` reference only through the separate deployment
|
||||||
|
configuration helper.
|
||||||
|
|
||||||
Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection`
|
Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection`
|
||||||
when a sync token exists. It imports all VEVENT components in a resource and
|
when a sync token exists. It imports all VEVENT components in a resource and
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.9",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ requires-python = ">=3.12"
|
|||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.8",
|
"govoplan-core>=0.1.9",
|
||||||
"govoplan-access>=0.1.8",
|
"govoplan-access>=0.1.8",
|
||||||
"defusedxml>=0.7,<1",
|
"defusedxml>=0.7,<1",
|
||||||
"icalendar>=7.2",
|
"icalendar>=7.2",
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
__version__ = "0.1.4"
|
__version__ = "0.1.8"
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Any, Mapping, Protocol
|
from typing import Any, Mapping, Protocol
|
||||||
|
|
||||||
from defusedxml import ElementTree as SafeElementTree
|
from defusedxml import ElementTree as SafeElementTree
|
||||||
|
from govoplan_core.security.outbound_http import (
|
||||||
|
OutboundHttpError,
|
||||||
|
bounded_response_bytes,
|
||||||
|
build_outbound_http_opener,
|
||||||
|
validate_outbound_http_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalDAVError(RuntimeError):
|
class CalDAVError(RuntimeError):
|
||||||
@@ -353,20 +359,31 @@ class CalDAVClient:
|
|||||||
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
|
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
|
||||||
url = validate_http_url(url)
|
url = validate_http_url(url)
|
||||||
try:
|
try:
|
||||||
|
url = validate_outbound_http_url(url, label="CalDAV URL")
|
||||||
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
|
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
|
||||||
url,
|
url,
|
||||||
data=body,
|
data=body,
|
||||||
headers=dict(headers),
|
headers=dict(headers),
|
||||||
method=method,
|
method=method,
|
||||||
)
|
)
|
||||||
opener = urllib.request.build_opener(_SameOriginRedirectHandler(url))
|
opener = build_outbound_http_opener(_SameOriginRedirectHandler(url))
|
||||||
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV URL; redirects remain on origin. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV URL; redirects remain on origin. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||||
return response.status, dict(response.headers.items()), response.read()
|
response_headers = dict(response.headers.items())
|
||||||
|
return response.status, response_headers, bounded_response_bytes(
|
||||||
|
response,
|
||||||
|
headers=response_headers,
|
||||||
|
label="CalDAV response",
|
||||||
|
)
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
return exc.code, dict(exc.headers.items()), exc.read()
|
response_headers = dict(exc.headers.items())
|
||||||
|
try:
|
||||||
|
payload = bounded_response_bytes(exc, headers=response_headers, label="CalDAV error response")
|
||||||
|
except OutboundHttpError as policy_exc:
|
||||||
|
raise CalDAVError(f"{method} {url} failed: {policy_exc}") from policy_exc
|
||||||
|
return exc.code, response_headers, payload
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
|
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
|
||||||
except ValueError as exc:
|
except (OutboundHttpError, ValueError) as exc:
|
||||||
raise CalDAVError(f"{method} {url} failed: {exc}") from exc
|
raise CalDAVError(f"{method} {url} failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
@@ -542,7 +559,8 @@ class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|||||||
del fp, msg, headers
|
del fp, msg, headers
|
||||||
try:
|
try:
|
||||||
candidate = validate_http_url(newurl)
|
candidate = validate_http_url(newurl)
|
||||||
except CalDAVError:
|
candidate = validate_outbound_http_url(candidate, label="CalDAV redirect URL")
|
||||||
|
except (CalDAVError, OutboundHttpError):
|
||||||
return None
|
return None
|
||||||
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
|
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -858,15 +858,18 @@ def _fail_operation(
|
|||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
def execute_calendar_outbox_operation(
|
def _lock_leased_outbox_operation(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
operation_id: str,
|
operation_id: str,
|
||||||
lease_token: str,
|
lease_token: str,
|
||||||
client_factory: Callable[[Session, CalendarSyncSource], object] | None = None,
|
) -> tuple[CalendarOutboxOperation, CalendarSyncSource | None]:
|
||||||
) -> CalendarOutboxOperation:
|
|
||||||
operation = session.get(CalendarOutboxOperation, operation_id)
|
operation = session.get(CalendarOutboxOperation, operation_id)
|
||||||
if operation is None or operation.status != "in_progress" or operation.lease_token != lease_token:
|
if (
|
||||||
|
operation is None
|
||||||
|
or operation.status != "in_progress"
|
||||||
|
or operation.lease_token != lease_token
|
||||||
|
):
|
||||||
raise ValueError("Calendar outbox lease is no longer valid")
|
raise ValueError("Calendar outbox lease is no longer valid")
|
||||||
source = (
|
source = (
|
||||||
session.query(CalendarSyncSource)
|
session.query(CalendarSyncSource)
|
||||||
@@ -882,36 +885,86 @@ def execute_calendar_outbox_operation(
|
|||||||
)
|
)
|
||||||
if operation.status != "in_progress" or operation.lease_token != lease_token:
|
if operation.status != "in_progress" or operation.lease_token != lease_token:
|
||||||
raise ValueError("Calendar outbox lease is no longer valid")
|
raise ValueError("Calendar outbox lease is no longer valid")
|
||||||
operation_metadata = operation.metadata_ or {}
|
return operation, source
|
||||||
unavailable_reason = _operation_source_unavailable_reason(operation, source)
|
|
||||||
if unavailable_reason:
|
|
||||||
|
def _cancel_undeliverable_operation(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
operation: CalendarOutboxOperation,
|
||||||
|
source: CalendarSyncSource | None,
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
if source is not None and source.tenant_id == operation.tenant_id:
|
if source is not None and source.tenant_id == operation.tenant_id:
|
||||||
_fail_operation(
|
_fail_operation(
|
||||||
session,
|
session,
|
||||||
operation=operation,
|
operation=operation,
|
||||||
source=source,
|
source=source,
|
||||||
error=unavailable_reason,
|
error=reason,
|
||||||
terminal_status="cancelled",
|
terminal_status="cancelled",
|
||||||
)
|
)
|
||||||
else:
|
return
|
||||||
operation.status = "cancelled"
|
operation.status = "cancelled"
|
||||||
operation.completed_at = utcnow()
|
operation.completed_at = utcnow()
|
||||||
operation.last_error = unavailable_reason
|
operation.last_error = reason
|
||||||
operation.lease_token = None
|
operation.lease_token = None
|
||||||
operation.lease_expires_at = None
|
operation.lease_expires_at = None
|
||||||
session.flush()
|
session.flush()
|
||||||
return operation
|
|
||||||
|
|
||||||
overwrite = bool(operation_metadata.get("overwrite")) if isinstance(operation_metadata, dict) else False
|
|
||||||
client: object | None = None
|
def _calendar_outbox_client(
|
||||||
try:
|
session: Session,
|
||||||
if client_factory is None:
|
*,
|
||||||
|
source: CalendarSyncSource,
|
||||||
|
client_factory: Callable[[Session, CalendarSyncSource], object] | None,
|
||||||
|
) -> object:
|
||||||
|
if client_factory is not None:
|
||||||
|
return client_factory(session, source)
|
||||||
from govoplan_calendar.backend.service import caldav_client_for_source
|
from govoplan_calendar.backend.service import caldav_client_for_source
|
||||||
|
|
||||||
client = caldav_client_for_source(session, source)
|
return caldav_client_for_source(session, source)
|
||||||
else:
|
|
||||||
client = client_factory(session, source)
|
|
||||||
if operation.operation_kind == "put":
|
def _try_remote_match(
|
||||||
|
client: object,
|
||||||
|
operation: CalendarOutboxOperation,
|
||||||
|
) -> tuple[bool, str | None] | None:
|
||||||
|
try:
|
||||||
|
return _remote_matches_operation(client, operation)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _complete_if_remote_match_is_usable(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
operation: CalendarOutboxOperation,
|
||||||
|
source: CalendarSyncSource,
|
||||||
|
remote_match: tuple[bool, str | None] | None,
|
||||||
|
) -> bool:
|
||||||
|
if remote_match is None:
|
||||||
|
return False
|
||||||
|
matched, remote_etag = remote_match
|
||||||
|
if not matched or (operation.operation_kind != "delete" and not remote_etag):
|
||||||
|
return False
|
||||||
|
_complete_operation(
|
||||||
|
session,
|
||||||
|
operation=operation,
|
||||||
|
source=source,
|
||||||
|
remote_etag=remote_etag,
|
||||||
|
reconciled=True,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_calendar_outbox_put(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
operation: CalendarOutboxOperation,
|
||||||
|
source: CalendarSyncSource,
|
||||||
|
client: object,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> None:
|
||||||
result = client.put_object(
|
result = client.put_object(
|
||||||
operation.resource_href,
|
operation.resource_href,
|
||||||
operation.payload_ics or "",
|
operation.payload_ics or "",
|
||||||
@@ -920,41 +973,48 @@ def execute_calendar_outbox_operation(
|
|||||||
overwrite=overwrite,
|
overwrite=overwrite,
|
||||||
)
|
)
|
||||||
remote_etag = result.etag
|
remote_etag = result.etag
|
||||||
reconciled = False
|
if remote_etag:
|
||||||
if not remote_etag:
|
_complete_operation(
|
||||||
try:
|
session,
|
||||||
matched, remote_etag = _remote_matches_operation(client, operation)
|
operation=operation,
|
||||||
except Exception:
|
source=source,
|
||||||
matched, remote_etag = False, None
|
remote_etag=remote_etag,
|
||||||
if not matched or not remote_etag:
|
reconciled=False,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
remote_match = _try_remote_match(client, operation)
|
||||||
|
if _complete_if_remote_match_is_usable(
|
||||||
|
session,
|
||||||
|
operation=operation,
|
||||||
|
source=source,
|
||||||
|
remote_match=remote_match,
|
||||||
|
):
|
||||||
|
return
|
||||||
_fail_operation(
|
_fail_operation(
|
||||||
session,
|
session,
|
||||||
operation=operation,
|
operation=operation,
|
||||||
source=source,
|
source=source,
|
||||||
error="CalDAV PUT succeeded without an ETag and could not be reconciled safely",
|
error="CalDAV PUT succeeded without an ETag and could not be reconciled safely",
|
||||||
)
|
)
|
||||||
return operation
|
|
||||||
reconciled = True
|
|
||||||
_complete_operation(
|
|
||||||
session,
|
|
||||||
operation=operation,
|
|
||||||
source=source,
|
|
||||||
remote_etag=remote_etag,
|
|
||||||
reconciled=reconciled,
|
|
||||||
)
|
|
||||||
return operation
|
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_calendar_outbox_delete(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
operation: CalendarOutboxOperation,
|
||||||
|
source: CalendarSyncSource,
|
||||||
|
client: object,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> None:
|
||||||
if not operation.expected_etag and not overwrite:
|
if not operation.expected_etag and not overwrite:
|
||||||
matched, remote_etag = _remote_matches_operation(client, operation)
|
remote_match = _remote_matches_operation(client, operation)
|
||||||
if matched:
|
if _complete_if_remote_match_is_usable(
|
||||||
_complete_operation(
|
|
||||||
session,
|
session,
|
||||||
operation=operation,
|
operation=operation,
|
||||||
source=source,
|
source=source,
|
||||||
remote_etag=remote_etag,
|
remote_match=remote_match,
|
||||||
reconciled=True,
|
):
|
||||||
)
|
return
|
||||||
return operation
|
|
||||||
_fail_operation(
|
_fail_operation(
|
||||||
session,
|
session,
|
||||||
operation=operation,
|
operation=operation,
|
||||||
@@ -965,7 +1025,7 @@ def execute_calendar_outbox_operation(
|
|||||||
),
|
),
|
||||||
terminal_status="conflict",
|
terminal_status="conflict",
|
||||||
)
|
)
|
||||||
return operation
|
return
|
||||||
result = client.delete_object(
|
result = client.delete_object(
|
||||||
operation.resource_href,
|
operation.resource_href,
|
||||||
etag=operation.expected_etag,
|
etag=operation.expected_etag,
|
||||||
@@ -978,25 +1038,28 @@ def execute_calendar_outbox_operation(
|
|||||||
remote_etag=result.etag,
|
remote_etag=result.etag,
|
||||||
reconciled=result.status == 404,
|
reconciled=result.status == 404,
|
||||||
)
|
)
|
||||||
return operation
|
|
||||||
except CalDAVPreconditionFailed as exc:
|
|
||||||
if client is None:
|
def _handle_outbox_precondition_failure(
|
||||||
_fail_operation(session, operation=operation, source=source, error=exc)
|
session: Session,
|
||||||
return operation
|
*,
|
||||||
try:
|
operation: CalendarOutboxOperation,
|
||||||
matched, remote_etag = _remote_matches_operation(client, operation)
|
source: CalendarSyncSource,
|
||||||
except Exception:
|
client: object | None,
|
||||||
_fail_operation(session, operation=operation, source=source, error=exc)
|
error: CalDAVPreconditionFailed,
|
||||||
else:
|
) -> None:
|
||||||
if matched and (operation.operation_kind == "delete" or remote_etag):
|
remote_match = _try_remote_match(client, operation) if client is not None else None
|
||||||
_complete_operation(
|
if _complete_if_remote_match_is_usable(
|
||||||
session,
|
session,
|
||||||
operation=operation,
|
operation=operation,
|
||||||
source=source,
|
source=source,
|
||||||
remote_etag=remote_etag,
|
remote_match=remote_match,
|
||||||
reconciled=True,
|
):
|
||||||
)
|
return
|
||||||
else:
|
if remote_match is None:
|
||||||
|
_fail_operation(session, operation=operation, source=source, error=error)
|
||||||
|
return
|
||||||
|
matched, _remote_etag = remote_match
|
||||||
_fail_operation(
|
_fail_operation(
|
||||||
session,
|
session,
|
||||||
operation=operation,
|
operation=operation,
|
||||||
@@ -1008,24 +1071,96 @@ def execute_calendar_outbox_operation(
|
|||||||
),
|
),
|
||||||
terminal_status=None if matched else "conflict",
|
terminal_status=None if matched else "conflict",
|
||||||
)
|
)
|
||||||
return operation
|
|
||||||
except Exception as exc:
|
|
||||||
matched, remote_etag = False, None
|
def _handle_outbox_delivery_failure(
|
||||||
if client is not None:
|
session: Session,
|
||||||
try:
|
*,
|
||||||
matched, remote_etag = _remote_matches_operation(client, operation)
|
operation: CalendarOutboxOperation,
|
||||||
except Exception:
|
source: CalendarSyncSource,
|
||||||
matched, remote_etag = False, None
|
client: object | None,
|
||||||
if matched and (operation.operation_kind == "delete" or remote_etag):
|
error: Exception,
|
||||||
_complete_operation(
|
) -> None:
|
||||||
|
remote_match = _try_remote_match(client, operation) if client is not None else None
|
||||||
|
if _complete_if_remote_match_is_usable(
|
||||||
session,
|
session,
|
||||||
operation=operation,
|
operation=operation,
|
||||||
source=source,
|
source=source,
|
||||||
remote_etag=remote_etag,
|
remote_match=remote_match,
|
||||||
reconciled=True,
|
):
|
||||||
|
return
|
||||||
|
_fail_operation(session, operation=operation, source=source, error=error)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_calendar_outbox_operation(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
operation_id: str,
|
||||||
|
lease_token: str,
|
||||||
|
client_factory: Callable[[Session, CalendarSyncSource], object] | None = None,
|
||||||
|
) -> CalendarOutboxOperation:
|
||||||
|
operation, source = _lock_leased_outbox_operation(
|
||||||
|
session,
|
||||||
|
operation_id=operation_id,
|
||||||
|
lease_token=lease_token,
|
||||||
|
)
|
||||||
|
unavailable_reason = _operation_source_unavailable_reason(operation, source)
|
||||||
|
if unavailable_reason:
|
||||||
|
_cancel_undeliverable_operation(
|
||||||
|
session,
|
||||||
|
operation=operation,
|
||||||
|
source=source,
|
||||||
|
reason=unavailable_reason,
|
||||||
|
)
|
||||||
|
return operation
|
||||||
|
|
||||||
|
if source is None:
|
||||||
|
raise ValueError("Calendar outbox source is no longer available")
|
||||||
|
operation_metadata = operation.metadata_ or {}
|
||||||
|
overwrite = (
|
||||||
|
bool(operation_metadata.get("overwrite"))
|
||||||
|
if isinstance(operation_metadata, dict)
|
||||||
|
else False
|
||||||
|
)
|
||||||
|
client: object | None = None
|
||||||
|
try:
|
||||||
|
client = _calendar_outbox_client(
|
||||||
|
session,
|
||||||
|
source=source,
|
||||||
|
client_factory=client_factory,
|
||||||
|
)
|
||||||
|
if operation.operation_kind == "put":
|
||||||
|
_execute_calendar_outbox_put(
|
||||||
|
session,
|
||||||
|
operation=operation,
|
||||||
|
source=source,
|
||||||
|
client=client,
|
||||||
|
overwrite=overwrite,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_fail_operation(session, operation=operation, source=source, error=exc)
|
_execute_calendar_outbox_delete(
|
||||||
|
session,
|
||||||
|
operation=operation,
|
||||||
|
source=source,
|
||||||
|
client=client,
|
||||||
|
overwrite=overwrite,
|
||||||
|
)
|
||||||
|
except CalDAVPreconditionFailed as exc:
|
||||||
|
_handle_outbox_precondition_failure(
|
||||||
|
session,
|
||||||
|
operation=operation,
|
||||||
|
source=source,
|
||||||
|
client=client,
|
||||||
|
error=exc,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_handle_outbox_delivery_failure(
|
||||||
|
session,
|
||||||
|
operation=operation,
|
||||||
|
source=source,
|
||||||
|
client=client,
|
||||||
|
error=exc,
|
||||||
|
)
|
||||||
return operation
|
return operation
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ from defusedxml import ElementTree as SafeElementTree
|
|||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.security.outbound_http import (
|
||||||
|
OutboundHttpError,
|
||||||
|
bounded_response_bytes,
|
||||||
|
build_outbound_http_opener,
|
||||||
|
validate_outbound_http_url,
|
||||||
|
)
|
||||||
|
|
||||||
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
|
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
|
||||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
||||||
from govoplan_calendar.backend.ical import expand_event_occurrences, parse_vevent, parse_vevents
|
from govoplan_calendar.backend.ical import expand_event_occurrences, parse_vevent, parse_vevents
|
||||||
@@ -198,11 +205,12 @@ class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|||||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
||||||
try:
|
try:
|
||||||
candidate = validate_http_url(newurl, label="Calendar source redirect URL")
|
candidate = validate_http_url(newurl, label="Calendar source redirect URL")
|
||||||
|
candidate = validate_outbound_http_url(candidate, label="Calendar source redirect URL")
|
||||||
candidate_origin = _parsed_http_origin(
|
candidate_origin = _parsed_http_origin(
|
||||||
urllib.parse.urlparse(candidate),
|
urllib.parse.urlparse(candidate),
|
||||||
label="Calendar source redirect URL",
|
label="Calendar source redirect URL",
|
||||||
)
|
)
|
||||||
except CalendarError:
|
except (CalendarError, OutboundHttpError):
|
||||||
return None
|
return None
|
||||||
if candidate_origin != self._source_origin:
|
if candidate_origin != self._source_origin:
|
||||||
return None
|
return None
|
||||||
@@ -477,22 +485,23 @@ def update_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
|
|||||||
return calendar
|
return calendar
|
||||||
|
|
||||||
|
|
||||||
def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, payload: CalendarCollectionDeleteRequest | None = None) -> None:
|
def _lock_calendar_move_context(
|
||||||
payload = payload or CalendarCollectionDeleteRequest()
|
session: Session,
|
||||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
|
*,
|
||||||
deleted_at = utcnow()
|
tenant_id: str,
|
||||||
if payload.event_action == "move":
|
calendar: CalendarCollection,
|
||||||
if not payload.target_calendar_id:
|
target_calendar: CalendarCollection,
|
||||||
raise CalendarError("Target calendar is required when moving events")
|
) -> tuple[
|
||||||
if payload.target_calendar_id == calendar_id:
|
CalendarCollection,
|
||||||
raise CalendarError("Target calendar must be different from the deleted calendar")
|
CalendarCollection,
|
||||||
target_calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.target_calendar_id)
|
CalendarSyncSource | None,
|
||||||
source = None
|
CalendarSyncSource | None,
|
||||||
target_source = None
|
]:
|
||||||
if hasattr(session, "query"):
|
if not hasattr(session, "query"):
|
||||||
# Source creation takes the collection row lock before linking a
|
return calendar, target_calendar, None, None
|
||||||
# source. Lock both collections in stable order so the mode cannot
|
# Source creation takes the collection row lock before linking a source.
|
||||||
# change between validation and the local/outbox mutation.
|
# Lock both collections in stable order so their modes cannot change
|
||||||
|
# between validation and the local/outbox mutation.
|
||||||
locked_calendars = (
|
locked_calendars = (
|
||||||
session.query(CalendarCollection)
|
session.query(CalendarCollection)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -525,7 +534,15 @@ def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
|
|||||||
source = locked_sources[source.id]
|
source = locked_sources[source.id]
|
||||||
if target_source is not None:
|
if target_source is not None:
|
||||||
target_source = locked_sources[target_source.id]
|
target_source = locked_sources[target_source.id]
|
||||||
|
return calendar, target_calendar, source, target_source
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_calendar_move_source_pair(
|
||||||
|
payload: CalendarCollectionDeleteRequest,
|
||||||
|
*,
|
||||||
|
source: CalendarSyncSource | None,
|
||||||
|
target_source: CalendarSyncSource | None,
|
||||||
|
) -> None:
|
||||||
if payload.external_action == "remote_move":
|
if payload.external_action == "remote_move":
|
||||||
raise CalendarError(
|
raise CalendarError(
|
||||||
"external_action='remote_move' is not supported; no destructive remote move was performed"
|
"external_action='remote_move' is not supported; no destructive remote move was performed"
|
||||||
@@ -535,15 +552,17 @@ def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
|
|||||||
"Events cannot be bulk-moved between synchronized calendars; "
|
"Events cannot be bulk-moved between synchronized calendars; "
|
||||||
"external_action='remote_move' remains unsupported"
|
"external_action='remote_move' remains unsupported"
|
||||||
)
|
)
|
||||||
active_events = [event for event in calendar.events if event.deleted_at is None]
|
|
||||||
previous_event_states = (
|
|
||||||
{
|
def _prepare_calendar_move_external_action(
|
||||||
event.id: calendar_event_change_payload(event, prefix="previous_")
|
session: Session,
|
||||||
for event in active_events
|
*,
|
||||||
}
|
tenant_id: str,
|
||||||
if hasattr(session, "query")
|
payload: CalendarCollectionDeleteRequest,
|
||||||
else {}
|
source: CalendarSyncSource | None,
|
||||||
)
|
target_source: CalendarSyncSource | None,
|
||||||
|
deleted_at: datetime,
|
||||||
|
) -> None:
|
||||||
if source is not None:
|
if source is not None:
|
||||||
if payload.external_action != "detach_keep_remote":
|
if payload.external_action != "detach_keep_remote":
|
||||||
raise CalendarError(
|
raise CalendarError(
|
||||||
@@ -551,15 +570,16 @@ def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
|
|||||||
"external_action='detach_keep_remote'"
|
"external_action='detach_keep_remote'"
|
||||||
)
|
)
|
||||||
# Retirement rejects a live delivery lease before any local event
|
# Retirement rejects a live delivery lease before any local event
|
||||||
# projection is changed, and cancels all remaining undelivered
|
# projection is changed, and cancels all remaining undelivered desired
|
||||||
# desired state. It never creates a remote DELETE operation.
|
# state. It never creates a remote DELETE operation.
|
||||||
retire_sync_source(
|
retire_sync_source(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
source=source,
|
source=source,
|
||||||
deleted_at=deleted_at,
|
deleted_at=deleted_at,
|
||||||
)
|
)
|
||||||
elif target_source is not None:
|
return
|
||||||
|
if target_source is not None:
|
||||||
if payload.external_action != "copy_to_remote":
|
if payload.external_action != "copy_to_remote":
|
||||||
raise CalendarError(
|
raise CalendarError(
|
||||||
"Moving local events to a synchronized calendar requires "
|
"Moving local events to a synchronized calendar requires "
|
||||||
@@ -574,11 +594,40 @@ def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
|
|||||||
"external_action='copy_to_remote' requires an active two-way CalDAV target"
|
"external_action='copy_to_remote' requires an active two-way CalDAV target"
|
||||||
)
|
)
|
||||||
assert_sync_mutation_allowed(target_source)
|
assert_sync_mutation_allowed(target_source)
|
||||||
elif payload.external_action is not None:
|
return
|
||||||
|
if payload.external_action is not None:
|
||||||
raise CalendarError(
|
raise CalendarError(
|
||||||
"external_action is only valid when moving events to or from a synchronized calendar"
|
"external_action is only valid when moving events to or from a synchronized calendar"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _active_events_with_previous_state(
|
||||||
|
session: Session,
|
||||||
|
calendar: CalendarCollection,
|
||||||
|
) -> tuple[list[CalendarEvent], dict[str, dict[str, Any]]]:
|
||||||
|
active_events = [event for event in calendar.events if event.deleted_at is None]
|
||||||
|
previous_event_states = (
|
||||||
|
{
|
||||||
|
event.id: calendar_event_change_payload(event, prefix="previous_")
|
||||||
|
for event in active_events
|
||||||
|
}
|
||||||
|
if hasattr(session, "query")
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
return active_events, previous_event_states
|
||||||
|
|
||||||
|
|
||||||
|
def _move_calendar_events(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
payload: CalendarCollectionDeleteRequest,
|
||||||
|
target_calendar: CalendarCollection,
|
||||||
|
source: CalendarSyncSource | None,
|
||||||
|
target_source: CalendarSyncSource | None,
|
||||||
|
active_events: list[CalendarEvent],
|
||||||
|
previous_event_states: dict[str, dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
for event in active_events:
|
for event in active_events:
|
||||||
if source is not None or target_source is not None:
|
if source is not None or target_source is not None:
|
||||||
event.source_kind = "local"
|
event.source_kind = "local"
|
||||||
@@ -606,6 +655,73 @@ def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
|
|||||||
if payload.make_target_default:
|
if payload.make_target_default:
|
||||||
clear_default_calendar(session, tenant_id=tenant_id)
|
clear_default_calendar(session, tenant_id=tenant_id)
|
||||||
target_calendar.is_default = True
|
target_calendar.is_default = True
|
||||||
|
|
||||||
|
|
||||||
|
def _move_calendar_before_delete(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
calendar: CalendarCollection,
|
||||||
|
payload: CalendarCollectionDeleteRequest,
|
||||||
|
deleted_at: datetime,
|
||||||
|
) -> CalendarCollection:
|
||||||
|
if not payload.target_calendar_id:
|
||||||
|
raise CalendarError("Target calendar is required when moving events")
|
||||||
|
if payload.target_calendar_id == calendar.id:
|
||||||
|
raise CalendarError("Target calendar must be different from the deleted calendar")
|
||||||
|
target_calendar = get_calendar(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
calendar_id=payload.target_calendar_id,
|
||||||
|
)
|
||||||
|
calendar, target_calendar, source, target_source = _lock_calendar_move_context(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
calendar=calendar,
|
||||||
|
target_calendar=target_calendar,
|
||||||
|
)
|
||||||
|
_validate_calendar_move_source_pair(
|
||||||
|
payload,
|
||||||
|
source=source,
|
||||||
|
target_source=target_source,
|
||||||
|
)
|
||||||
|
active_events, previous_event_states = _active_events_with_previous_state(
|
||||||
|
session,
|
||||||
|
calendar,
|
||||||
|
)
|
||||||
|
_prepare_calendar_move_external_action(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload=payload,
|
||||||
|
source=source,
|
||||||
|
target_source=target_source,
|
||||||
|
deleted_at=deleted_at,
|
||||||
|
)
|
||||||
|
_move_calendar_events(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload=payload,
|
||||||
|
target_calendar=target_calendar,
|
||||||
|
source=source,
|
||||||
|
target_source=target_source,
|
||||||
|
active_events=active_events,
|
||||||
|
previous_event_states=previous_event_states,
|
||||||
|
)
|
||||||
|
return calendar
|
||||||
|
|
||||||
|
|
||||||
|
def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, payload: CalendarCollectionDeleteRequest | None = None) -> None:
|
||||||
|
payload = payload or CalendarCollectionDeleteRequest()
|
||||||
|
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
|
||||||
|
deleted_at = utcnow()
|
||||||
|
if payload.event_action == "move":
|
||||||
|
calendar = _move_calendar_before_delete(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
calendar=calendar,
|
||||||
|
payload=payload,
|
||||||
|
deleted_at=deleted_at,
|
||||||
|
)
|
||||||
elif payload.event_action != "delete":
|
elif payload.event_action != "delete":
|
||||||
raise CalendarError(f"Unsupported calendar delete event action: {payload.event_action}")
|
raise CalendarError(f"Unsupported calendar delete event action: {payload.event_action}")
|
||||||
elif payload.external_action is not None:
|
elif payload.external_action is not None:
|
||||||
@@ -696,10 +812,13 @@ def discover_caldav_calendars(session: Session, *, tenant_id: str, payload: Cale
|
|||||||
auth_type = payload.auth_type or (source.auth_type if source else "none")
|
auth_type = payload.auth_type or (source.auth_type if source else "none")
|
||||||
username = payload.username if payload.username is not None else (source.username if source else None)
|
username = payload.username if payload.username is not None else (source.username if source else None)
|
||||||
credential_ref = payload.credential_ref if payload.credential_ref is not None else (source.credential_ref if source else None)
|
credential_ref = payload.credential_ref if payload.credential_ref is not None else (source.credential_ref if source else None)
|
||||||
|
if payload.credential_ref is not None and (source is None or payload.credential_ref != source.credential_ref):
|
||||||
|
raise CalendarError(
|
||||||
|
"Caller-supplied credential references are not accepted; provide a password/token "
|
||||||
|
"or select an existing sync source"
|
||||||
|
)
|
||||||
secret = caldav_secret_from_payload(auth_type=auth_type, password=payload.password, bearer_token=payload.bearer_token)
|
secret = caldav_secret_from_payload(auth_type=auth_type, password=payload.password, bearer_token=payload.bearer_token)
|
||||||
if secret is None and credential_ref:
|
if secret is None and source is not None and credential_ref == source.credential_ref:
|
||||||
secret = resolve_caldav_credential_ref(session, tenant_id=tenant_id, credential_ref=credential_ref)
|
|
||||||
if secret is None and source is not None:
|
|
||||||
secret = resolve_caldav_secret(session, source=source)
|
secret = resolve_caldav_secret(session, source=source)
|
||||||
|
|
||||||
if auth_type == "basic":
|
if auth_type == "basic":
|
||||||
@@ -729,6 +848,10 @@ def discover_caldav_calendars(session: Session, *, tenant_id: str, payload: Cale
|
|||||||
|
|
||||||
|
|
||||||
def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarSyncSourceCreateRequest) -> CalendarSyncSource:
|
def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarSyncSourceCreateRequest) -> CalendarSyncSource:
|
||||||
|
if payload.credential_ref is not None:
|
||||||
|
raise CalendarError(
|
||||||
|
"Caller-supplied credential references are not accepted; provide a password or bearer token"
|
||||||
|
)
|
||||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
|
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
|
||||||
calendar = (
|
calendar = (
|
||||||
session.query(CalendarCollection)
|
session.query(CalendarCollection)
|
||||||
@@ -799,12 +922,16 @@ def create_caldav_source(session: Session, *, tenant_id: str, user_id: str | Non
|
|||||||
return create_sync_source(session, tenant_id=tenant_id, user_id=user_id, payload=payload)
|
return create_sync_source(session, tenant_id=tenant_id, user_id=user_id, payload=payload)
|
||||||
|
|
||||||
|
|
||||||
def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payload: CalendarSyncSourceUpdateRequest) -> CalendarSyncSource:
|
def _lock_sync_source_for_update(
|
||||||
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
|
session: Session,
|
||||||
source = (
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
source_id: str,
|
||||||
|
) -> CalendarSyncSource:
|
||||||
|
return (
|
||||||
session.query(CalendarSyncSource)
|
session.query(CalendarSyncSource)
|
||||||
.filter(
|
.filter(
|
||||||
CalendarSyncSource.id == source.id,
|
CalendarSyncSource.id == source_id,
|
||||||
CalendarSyncSource.tenant_id == tenant_id,
|
CalendarSyncSource.tenant_id == tenant_id,
|
||||||
CalendarSyncSource.deleted_at.is_(None),
|
CalendarSyncSource.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
@@ -812,28 +939,68 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
|
|||||||
.with_for_update()
|
.with_for_update()
|
||||||
.one()
|
.one()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_source_reconfiguration(
|
||||||
|
source: CalendarSyncSource,
|
||||||
|
payload: CalendarSyncSourceUpdateRequest,
|
||||||
|
) -> tuple[str, bool, bool]:
|
||||||
normalized_collection_url = (
|
normalized_collection_url = (
|
||||||
normalize_sync_source_url(source.source_kind, payload.collection_url)
|
normalize_sync_source_url(source.source_kind, payload.collection_url)
|
||||||
if payload.collection_url is not None
|
if payload.collection_url is not None
|
||||||
else source.collection_url
|
else source.collection_url
|
||||||
)
|
)
|
||||||
|
calendar_changed = bool(
|
||||||
|
payload.calendar_id is not None and payload.calendar_id != source.calendar_id
|
||||||
|
)
|
||||||
|
endpoint_changed = normalized_collection_url != source.collection_url
|
||||||
|
endpoint_or_calendar_changed = bool(
|
||||||
|
source.source_kind == "caldav" and (calendar_changed or endpoint_changed)
|
||||||
|
)
|
||||||
materially_reconfigured = bool(
|
materially_reconfigured = bool(
|
||||||
source.source_kind == "caldav"
|
source.source_kind == "caldav"
|
||||||
and (
|
and (
|
||||||
(payload.calendar_id is not None and payload.calendar_id != source.calendar_id)
|
calendar_changed
|
||||||
or normalized_collection_url != source.collection_url
|
or endpoint_changed
|
||||||
or payload.sync_enabled is False
|
or payload.sync_enabled is False
|
||||||
or payload.sync_direction == "inbound"
|
or payload.sync_direction == "inbound"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
endpoint_or_calendar_changed = bool(
|
return (
|
||||||
source.source_kind == "caldav"
|
normalized_collection_url,
|
||||||
and (
|
materially_reconfigured,
|
||||||
(payload.calendar_id is not None and payload.calendar_id != source.calendar_id)
|
endpoint_or_calendar_changed,
|
||||||
or normalized_collection_url != source.collection_url
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _caldav_source_has_linked_events(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
source: CalendarSyncSource,
|
||||||
|
) -> bool:
|
||||||
|
return (
|
||||||
|
session.query(CalendarEvent.id)
|
||||||
|
.filter(
|
||||||
|
CalendarEvent.tenant_id == tenant_id,
|
||||||
|
CalendarEvent.calendar_id == source.calendar_id,
|
||||||
|
CalendarEvent.source_kind == "caldav",
|
||||||
|
CalendarEvent.source_href.is_not(None),
|
||||||
|
CalendarEvent.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
if materially_reconfigured:
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_sync_source_reconfiguration(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
source: CalendarSyncSource,
|
||||||
|
payload: CalendarSyncSourceUpdateRequest,
|
||||||
|
endpoint_or_calendar_changed: bool,
|
||||||
|
) -> None:
|
||||||
from govoplan_calendar.backend.outbox import (
|
from govoplan_calendar.backend.outbox import (
|
||||||
calendar_outbox_has_live_lease,
|
calendar_outbox_has_live_lease,
|
||||||
calendar_outbox_has_unresolved_desired_state,
|
calendar_outbox_has_unresolved_desired_state,
|
||||||
@@ -844,7 +1011,6 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
|
|||||||
raise CalendarError(
|
raise CalendarError(
|
||||||
"CalDAV source cannot be materially changed while an outbound operation has an active lease"
|
"CalDAV source cannot be materially changed while an outbound operation has an active lease"
|
||||||
)
|
)
|
||||||
|
|
||||||
unresolved_desired_state = calendar_outbox_has_unresolved_desired_state(
|
unresolved_desired_state = calendar_outbox_has_unresolved_desired_state(
|
||||||
session,
|
session,
|
||||||
source_id=source.id,
|
source_id=source.id,
|
||||||
@@ -866,33 +1032,36 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
|
|||||||
"CalDAV source endpoint or calendar cannot change while unresolved local desired "
|
"CalDAV source endpoint or calendar cannot change while unresolved local desired "
|
||||||
"changes exist; deliver or explicitly discard them first"
|
"changes exist; deliver or explicitly discard them first"
|
||||||
)
|
)
|
||||||
|
if endpoint_or_calendar_changed and _caldav_source_has_linked_events(
|
||||||
if endpoint_or_calendar_changed:
|
session,
|
||||||
sourced_event_exists = (
|
tenant_id=tenant_id,
|
||||||
session.query(CalendarEvent.id)
|
source=source,
|
||||||
.filter(
|
):
|
||||||
CalendarEvent.tenant_id == tenant_id,
|
|
||||||
CalendarEvent.calendar_id == source.calendar_id,
|
|
||||||
CalendarEvent.source_kind == "caldav",
|
|
||||||
CalendarEvent.source_href.is_not(None),
|
|
||||||
CalendarEvent.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
is not None
|
|
||||||
)
|
|
||||||
if sourced_event_exists:
|
|
||||||
raise CalendarError(
|
raise CalendarError(
|
||||||
"CalDAV source endpoint or calendar cannot change while events still reference it; "
|
"CalDAV source endpoint or calendar cannot change while events still reference it; "
|
||||||
"detach or resync those events first"
|
"detach or resync those events first"
|
||||||
)
|
)
|
||||||
|
|
||||||
cancel_calendar_outbox_for_source(
|
cancel_calendar_outbox_for_source(
|
||||||
session,
|
session,
|
||||||
source_id=source.id,
|
source_id=source.id,
|
||||||
reason="CalDAV source endpoint/calendar changed, was disabled, or was made inbound-only",
|
reason="CalDAV source endpoint/calendar changed, was disabled, or was made inbound-only",
|
||||||
)
|
)
|
||||||
if payload.calendar_id is not None:
|
|
||||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
|
|
||||||
|
def _calendar_for_sync_source_update(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
source: CalendarSyncSource,
|
||||||
|
calendar_id: str | None,
|
||||||
|
) -> CalendarCollection:
|
||||||
|
if calendar_id is None:
|
||||||
|
return get_calendar(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
calendar_id=source.calendar_id,
|
||||||
|
)
|
||||||
|
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
|
||||||
calendar = (
|
calendar = (
|
||||||
session.query(CalendarCollection)
|
session.query(CalendarCollection)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -915,12 +1084,17 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if conflicting_source is not None:
|
if conflicting_source is not None:
|
||||||
raise CalendarError(
|
raise CalendarError("A calendar can have only one active synchronization source")
|
||||||
"A calendar can have only one active synchronization source"
|
|
||||||
)
|
|
||||||
source.calendar_id = calendar.id
|
source.calendar_id = calendar.id
|
||||||
else:
|
return calendar
|
||||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id)
|
|
||||||
|
|
||||||
|
def _apply_sync_source_values(
|
||||||
|
source: CalendarSyncSource,
|
||||||
|
*,
|
||||||
|
payload: CalendarSyncSourceUpdateRequest,
|
||||||
|
normalized_collection_url: str,
|
||||||
|
) -> None:
|
||||||
for field_name in (
|
for field_name in (
|
||||||
"display_name",
|
"display_name",
|
||||||
"auth_type",
|
"auth_type",
|
||||||
@@ -942,15 +1116,85 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
|
|||||||
source.metadata_ = payload.metadata
|
source.metadata_ = payload.metadata
|
||||||
if source.source_kind in READ_ONLY_SYNC_SOURCE_KINDS:
|
if source.source_kind in READ_ONLY_SYNC_SOURCE_KINDS:
|
||||||
source.sync_direction = "inbound"
|
source.sync_direction = "inbound"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_sync_source_auth(source: CalendarSyncSource) -> None:
|
||||||
if source.source_kind == "graph" and source.auth_type != "bearer":
|
if source.source_kind == "graph" and source.auth_type != "bearer":
|
||||||
raise CalendarError("Microsoft Graph calendar sync requires bearer token authentication")
|
raise CalendarError(
|
||||||
|
"Microsoft Graph calendar sync requires bearer token authentication"
|
||||||
|
)
|
||||||
if source.source_kind == "ews" and source.auth_type not in {"basic", "bearer"}:
|
if source.source_kind == "ews" and source.auth_type not in {"basic", "bearer"}:
|
||||||
raise CalendarError("Exchange Web Services sync requires basic or bearer authentication")
|
raise CalendarError(
|
||||||
credential_value = caldav_secret_from_payload(auth_type=source.auth_type, password=payload.password, bearer_token=payload.bearer_token)
|
"Exchange Web Services sync requires basic or bearer authentication"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_sync_source_credential_and_schedule(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
source: CalendarSyncSource,
|
||||||
|
payload: CalendarSyncSourceUpdateRequest,
|
||||||
|
) -> None:
|
||||||
|
credential_value = caldav_secret_from_payload(
|
||||||
|
auth_type=source.auth_type,
|
||||||
|
password=payload.password,
|
||||||
|
bearer_token=payload.bearer_token,
|
||||||
|
)
|
||||||
if credential_value is not None:
|
if credential_value is not None:
|
||||||
source.credential_ref = store_caldav_credential(session, tenant_id=tenant_id, user_id=None, source=source, secret=credential_value)
|
source.credential_ref = store_caldav_credential(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=None,
|
||||||
|
source=source,
|
||||||
|
secret=credential_value,
|
||||||
|
)
|
||||||
if payload.sync_enabled is not None or payload.sync_interval_seconds is not None:
|
if payload.sync_enabled is not None or payload.sync_interval_seconds is not None:
|
||||||
source.next_sync_at = utcnow() if source.sync_enabled else None
|
source.next_sync_at = utcnow() if source.sync_enabled else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payload: CalendarSyncSourceUpdateRequest) -> CalendarSyncSource:
|
||||||
|
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
|
||||||
|
source = _lock_sync_source_for_update(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_id=source.id,
|
||||||
|
)
|
||||||
|
if payload.credential_ref is not None and payload.credential_ref != source.credential_ref:
|
||||||
|
raise CalendarError(
|
||||||
|
"Caller-supplied credential references are not accepted; provide a replacement password or bearer token"
|
||||||
|
)
|
||||||
|
(
|
||||||
|
normalized_collection_url,
|
||||||
|
materially_reconfigured,
|
||||||
|
endpoint_or_calendar_changed,
|
||||||
|
) = _sync_source_reconfiguration(source, payload)
|
||||||
|
if materially_reconfigured:
|
||||||
|
_guard_sync_source_reconfiguration(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source=source,
|
||||||
|
payload=payload,
|
||||||
|
endpoint_or_calendar_changed=endpoint_or_calendar_changed,
|
||||||
|
)
|
||||||
|
calendar = _calendar_for_sync_source_update(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source=source,
|
||||||
|
calendar_id=payload.calendar_id,
|
||||||
|
)
|
||||||
|
_apply_sync_source_values(
|
||||||
|
source,
|
||||||
|
payload=payload,
|
||||||
|
normalized_collection_url=normalized_collection_url,
|
||||||
|
)
|
||||||
|
_validate_sync_source_auth(source)
|
||||||
|
_update_sync_source_credential_and_schedule(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source=source,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
mark_calendar_sync_source(calendar, source)
|
mark_calendar_sync_source(calendar, source)
|
||||||
session.flush()
|
session.flush()
|
||||||
return source
|
return source
|
||||||
@@ -1064,7 +1308,12 @@ def retire_sync_source(session: Session, *, tenant_id: str, source: CalendarSync
|
|||||||
reason="CalDAV source was retired before queued external changes were delivered",
|
reason="CalDAV source was retired before queued external changes were delivered",
|
||||||
)
|
)
|
||||||
source.deleted_at = deleted_at
|
source.deleted_at = deleted_at
|
||||||
delete_caldav_credential(session, tenant_id=tenant_id, credential_ref=source.credential_ref)
|
delete_caldav_credential(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_id=source.id,
|
||||||
|
credential_ref=source.credential_ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def retire_caldav_source(session: Session, *, tenant_id: str, source: CalendarSyncSource, deleted_at: datetime) -> None:
|
def retire_caldav_source(session: Session, *, tenant_id: str, source: CalendarSyncSource, deleted_at: datetime) -> None:
|
||||||
@@ -1095,12 +1344,12 @@ def store_caldav_credential(
|
|||||||
source: CalendarSyncSource,
|
source: CalendarSyncSource,
|
||||||
secret: str,
|
secret: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
provider = secret_provider()
|
existing = internal_caldav_credential(
|
||||||
name = f"caldav:{source.id}:{source.auth_type}"
|
session,
|
||||||
if provider is not None:
|
tenant_id=tenant_id,
|
||||||
return str(provider.store_secret(scope=f"calendar:{tenant_id}", name=name, value=secret))
|
source_id=source.id,
|
||||||
|
credential_ref=source.credential_ref,
|
||||||
existing = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=source.credential_ref)
|
)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
existing = CalendarSyncCredential(
|
existing = CalendarSyncCredential(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -1111,24 +1360,46 @@ def store_caldav_credential(
|
|||||||
)
|
)
|
||||||
session.add(existing)
|
session.add(existing)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
|
provider = secret_provider()
|
||||||
|
name = f"caldav:{source.id}:{source.auth_type}"
|
||||||
|
if provider is not None:
|
||||||
|
provider_ref = str(provider.store_secret(scope=f"calendar:{tenant_id}", name=name, value=secret))
|
||||||
|
existing.secret_encrypted = None
|
||||||
|
existing.metadata_ = {"source_id": source.id, "provider_ref": provider_ref}
|
||||||
|
else:
|
||||||
|
existing.secret_encrypted = encrypt_secret(secret)
|
||||||
|
existing.metadata_ = {"source_id": source.id}
|
||||||
existing.credential_kind = credential_kind_for_auth_type(source.auth_type)
|
existing.credential_kind = credential_kind_for_auth_type(source.auth_type)
|
||||||
existing.label = source.display_name or source.collection_url
|
existing.label = source.display_name or source.collection_url
|
||||||
existing.secret_encrypted = encrypt_secret(secret)
|
|
||||||
existing.deleted_at = None
|
existing.deleted_at = None
|
||||||
existing.metadata_ = {"source_id": source.id}
|
|
||||||
session.flush()
|
session.flush()
|
||||||
return f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{existing.id}"
|
return f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{existing.id}"
|
||||||
|
|
||||||
|
|
||||||
def delete_caldav_credential(session: Session, *, tenant_id: str, credential_ref: str | None) -> None:
|
def delete_caldav_credential(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
source_id: str,
|
||||||
|
credential_ref: str | None,
|
||||||
|
) -> None:
|
||||||
if not credential_ref:
|
if not credential_ref:
|
||||||
return
|
return
|
||||||
provider = secret_provider()
|
credential = internal_caldav_credential(
|
||||||
if provider is not None and not credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX) and not credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX):
|
session,
|
||||||
provider.delete_secret(credential_ref)
|
tenant_id=tenant_id,
|
||||||
|
source_id=source_id,
|
||||||
|
credential_ref=credential_ref,
|
||||||
|
)
|
||||||
|
if credential is None:
|
||||||
|
# Legacy external/env references have no locally provable ownership and
|
||||||
|
# must never be passed to a provider delete operation.
|
||||||
return
|
return
|
||||||
credential = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=credential_ref)
|
provider_ref = _credential_provider_ref(credential)
|
||||||
if credential is not None:
|
provider = secret_provider()
|
||||||
|
if provider is not None and provider_ref:
|
||||||
|
provider.delete_secret(provider_ref)
|
||||||
credential.deleted_at = utcnow()
|
credential.deleted_at = utcnow()
|
||||||
|
|
||||||
|
|
||||||
@@ -1147,27 +1418,59 @@ def resolve_caldav_secret(
|
|||||||
return bearer_token
|
return bearer_token
|
||||||
if not source.credential_ref:
|
if not source.credential_ref:
|
||||||
return None
|
return None
|
||||||
if source.credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX):
|
credential = internal_caldav_credential(
|
||||||
return os.environ.get(source.credential_ref.removeprefix(CALDAV_ENV_CREDENTIAL_PREFIX))
|
session,
|
||||||
|
tenant_id=source.tenant_id,
|
||||||
|
source_id=source.id,
|
||||||
|
credential_ref=source.credential_ref,
|
||||||
|
)
|
||||||
|
if credential is None:
|
||||||
|
raise CalendarError(
|
||||||
|
"The sync source credential reference is not a server-owned credential for this tenant/source"
|
||||||
|
)
|
||||||
|
provider_ref = _credential_provider_ref(credential)
|
||||||
|
if provider_ref:
|
||||||
provider = secret_provider()
|
provider = secret_provider()
|
||||||
if provider is not None and not source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX):
|
if provider is None:
|
||||||
secret = provider.read_secret(source.credential_ref)
|
return None
|
||||||
if secret:
|
return provider.read_secret(provider_ref)
|
||||||
return secret
|
return decrypt_secret(credential.secret_encrypted) if credential.secret_encrypted else None
|
||||||
credential = internal_caldav_credential(session, tenant_id=source.tenant_id, credential_ref=source.credential_ref)
|
|
||||||
return decrypt_secret(credential.secret_encrypted) if credential and credential.secret_encrypted else None
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_caldav_credential_ref(session: Session, *, tenant_id: str, credential_ref: str) -> str | None:
|
def resolve_caldav_credential_ref(
|
||||||
if credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX):
|
session: Session,
|
||||||
return os.environ.get(credential_ref.removeprefix(CALDAV_ENV_CREDENTIAL_PREFIX))
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
source_id: str,
|
||||||
|
credential_ref: str,
|
||||||
|
) -> str | None:
|
||||||
|
credential = internal_caldav_credential(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_id=source_id,
|
||||||
|
credential_ref=credential_ref,
|
||||||
|
)
|
||||||
|
if credential is None:
|
||||||
|
return None
|
||||||
|
provider_ref = _credential_provider_ref(credential)
|
||||||
|
if provider_ref:
|
||||||
provider = secret_provider()
|
provider = secret_provider()
|
||||||
if provider is not None and not credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX):
|
return provider.read_secret(provider_ref) if provider is not None else None
|
||||||
secret = provider.read_secret(credential_ref)
|
return decrypt_secret(credential.secret_encrypted) if credential.secret_encrypted else None
|
||||||
if secret:
|
|
||||||
return secret
|
|
||||||
credential = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=credential_ref)
|
def resolve_trusted_deployment_caldav_credential_ref(credential_ref: str) -> str | None:
|
||||||
return decrypt_secret(credential.secret_encrypted) if credential and credential.secret_encrypted else None
|
"""Resolve an env reference only for trusted, out-of-band deployment code.
|
||||||
|
|
||||||
|
HTTP/API source configuration deliberately never calls this function.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX):
|
||||||
|
raise CalendarError("Trusted deployment credential references must use the env: prefix")
|
||||||
|
env_name = credential_ref.removeprefix(CALDAV_ENV_CREDENTIAL_PREFIX)
|
||||||
|
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", env_name):
|
||||||
|
raise CalendarError("Trusted deployment credential reference contains an invalid environment variable name")
|
||||||
|
return os.environ.get(env_name)
|
||||||
|
|
||||||
|
|
||||||
def caldav_client_for_source(
|
def caldav_client_for_source(
|
||||||
@@ -1242,22 +1545,37 @@ def http_request(
|
|||||||
url,
|
url,
|
||||||
label="Calendar source request URL",
|
label="Calendar source request URL",
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
url = validate_outbound_http_url(url, label="Calendar source request URL")
|
||||||
|
except OutboundHttpError as exc:
|
||||||
|
raise CalendarError(str(exc)) from exc
|
||||||
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
|
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
|
||||||
url,
|
url,
|
||||||
data=data,
|
data=data,
|
||||||
method=method,
|
method=method,
|
||||||
headers=headers or {},
|
headers=headers or {},
|
||||||
)
|
)
|
||||||
opener = urllib.request.build_opener(_SameOriginRedirectHandler(credential_origin))
|
opener = build_outbound_http_opener(_SameOriginRedirectHandler(credential_origin))
|
||||||
try:
|
try:
|
||||||
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated, origin-confined Calendar URL. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated, origin-confined Calendar URL. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||||
response_headers = {key.lower(): value for key, value in response.headers.items()}
|
response_headers = {key.lower(): value for key, value in response.headers.items()}
|
||||||
payload = response.read().decode(response_headers.get("content-charset") or "utf-8", errors="replace")
|
payload = bounded_response_bytes(
|
||||||
|
response,
|
||||||
|
headers=response_headers,
|
||||||
|
label="Calendar source response",
|
||||||
|
).decode(response_headers.get("content-charset") or "utf-8", errors="replace")
|
||||||
return int(response.status), response_headers, payload
|
return int(response.status), response_headers, payload
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
if exc.code == 304:
|
if exc.code == 304:
|
||||||
return 304, {key.lower(): value for key, value in exc.headers.items()}, ""
|
return 304, {key.lower(): value for key, value in exc.headers.items()}, ""
|
||||||
detail = exc.read().decode("utf-8", errors="replace")
|
try:
|
||||||
|
detail = bounded_response_bytes(
|
||||||
|
exc,
|
||||||
|
headers={key.lower(): value for key, value in exc.headers.items()},
|
||||||
|
label="Calendar source error response",
|
||||||
|
).decode("utf-8", errors="replace")
|
||||||
|
except OutboundHttpError as policy_exc:
|
||||||
|
raise CalendarError(str(policy_exc)) from policy_exc
|
||||||
raise CalendarError(f"HTTP {exc.code} from calendar source: {detail or exc.reason}") from exc
|
raise CalendarError(f"HTTP {exc.code} from calendar source: {detail or exc.reason}") from exc
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
raise CalendarError(f"Calendar source request failed: {exc.reason}") from exc
|
raise CalendarError(f"Calendar source request failed: {exc.reason}") from exc
|
||||||
@@ -1287,11 +1605,17 @@ def source_auth_headers(
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def internal_caldav_credential(session: Session, *, tenant_id: str, credential_ref: str | None) -> CalendarSyncCredential | None:
|
def internal_caldav_credential(
|
||||||
if not credential_ref:
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
credential_ref: str | None,
|
||||||
|
source_id: str | None = None,
|
||||||
|
) -> CalendarSyncCredential | None:
|
||||||
|
if not credential_ref or not credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX):
|
||||||
return None
|
return None
|
||||||
credential_id = credential_ref.removeprefix(CALDAV_INTERNAL_CREDENTIAL_PREFIX)
|
credential_id = credential_ref.removeprefix(CALDAV_INTERNAL_CREDENTIAL_PREFIX)
|
||||||
return (
|
credential = (
|
||||||
session.query(CalendarSyncCredential)
|
session.query(CalendarSyncCredential)
|
||||||
.filter(
|
.filter(
|
||||||
CalendarSyncCredential.tenant_id == tenant_id,
|
CalendarSyncCredential.tenant_id == tenant_id,
|
||||||
@@ -1300,6 +1624,16 @@ def internal_caldav_credential(session: Session, *, tenant_id: str, credential_r
|
|||||||
)
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
if credential is None or source_id is None:
|
||||||
|
return credential
|
||||||
|
metadata = credential.metadata_ if isinstance(credential.metadata_, dict) else {}
|
||||||
|
return credential if metadata.get("source_id") == source_id else None
|
||||||
|
|
||||||
|
|
||||||
|
def _credential_provider_ref(credential: CalendarSyncCredential) -> str | None:
|
||||||
|
metadata = credential.metadata_ if isinstance(credential.metadata_, dict) else {}
|
||||||
|
value = metadata.get("provider_ref")
|
||||||
|
return str(value) if isinstance(value, str) and value.strip() else None
|
||||||
|
|
||||||
|
|
||||||
def credential_kind_for_auth_type(auth_type: str) -> str:
|
def credential_kind_for_auth_type(auth_type: str) -> str:
|
||||||
@@ -2390,6 +2724,9 @@ def mark_calendar_sync_source(calendar: CalendarCollection, source: CalendarSync
|
|||||||
|
|
||||||
|
|
||||||
def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]:
|
def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]:
|
||||||
|
has_server_owned_credential = bool(
|
||||||
|
source.credential_ref and source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX)
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"id": source.id,
|
"id": source.id,
|
||||||
"tenant_id": source.tenant_id,
|
"tenant_id": source.tenant_id,
|
||||||
@@ -2399,8 +2736,8 @@ def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]:
|
|||||||
"display_name": source.display_name,
|
"display_name": source.display_name,
|
||||||
"auth_type": source.auth_type,
|
"auth_type": source.auth_type,
|
||||||
"username": source.username,
|
"username": source.username,
|
||||||
"credential_ref": source.credential_ref,
|
"credential_ref": None,
|
||||||
"has_credential": bool(source.credential_ref),
|
"has_credential": has_server_owned_credential,
|
||||||
"sync_enabled": bool(source.sync_enabled),
|
"sync_enabled": bool(source.sync_enabled),
|
||||||
"sync_interval_seconds": int(source.sync_interval_seconds or CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS),
|
"sync_interval_seconds": int(source.sync_interval_seconds or CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS),
|
||||||
"sync_direction": source.sync_direction,
|
"sync_direction": source.sync_direction,
|
||||||
@@ -2581,7 +2918,13 @@ def create_event(session: Session, *, tenant_id: str, user_id: str | None, paylo
|
|||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
def update_event(session: Session, *, tenant_id: str, user_id: str | None, event_id: str, payload: CalendarEventUpdateRequest) -> CalendarEvent:
|
def _event_update_calendar_ids(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
event_id: str,
|
||||||
|
payload: CalendarEventUpdateRequest,
|
||||||
|
) -> tuple[str, str]:
|
||||||
event_locator = (
|
event_locator = (
|
||||||
session.query(CalendarEvent.calendar_id)
|
session.query(CalendarEvent.calendar_id)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -2597,6 +2940,16 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event
|
|||||||
target_calendar_id = payload.calendar_id or original_calendar_id
|
target_calendar_id = payload.calendar_id or original_calendar_id
|
||||||
if payload.calendar_id is not None:
|
if payload.calendar_id is not None:
|
||||||
get_calendar(session, tenant_id=tenant_id, calendar_id=target_calendar_id)
|
get_calendar(session, tenant_id=tenant_id, calendar_id=target_calendar_id)
|
||||||
|
return original_calendar_id, target_calendar_id
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_event_update_sources(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
original_calendar_id: str,
|
||||||
|
target_calendar_id: str,
|
||||||
|
) -> tuple[CalendarSyncSource | None, CalendarSyncSource | None]:
|
||||||
original_source = active_sync_source_for_calendar(
|
original_source = active_sync_source_for_calendar(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -2616,16 +2969,35 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event
|
|||||||
original_source = locked_sources[original_source.id]
|
original_source = locked_sources[original_source.id]
|
||||||
if target_source is not None:
|
if target_source is not None:
|
||||||
target_source = locked_sources[target_source.id]
|
target_source = locked_sources[target_source.id]
|
||||||
event = get_event(session, tenant_id=tenant_id, event_id=event_id)
|
return original_source, target_source
|
||||||
previous = calendar_event_change_payload(event, prefix="previous_")
|
|
||||||
original_caldav = event.source_kind == "caldav" and bool(event.source_href)
|
|
||||||
|
def _assert_event_update_sync_fields(
|
||||||
|
payload: CalendarEventUpdateRequest,
|
||||||
|
*,
|
||||||
|
original_source: CalendarSyncSource | None,
|
||||||
|
target_source: CalendarSyncSource | None,
|
||||||
|
) -> None:
|
||||||
supplied_sync_fields = {"source_kind", "source_href", "etag"} & payload.model_fields_set
|
supplied_sync_fields = {"source_kind", "source_href", "etag"} & payload.model_fields_set
|
||||||
if (original_source is not None or target_source is not None) and supplied_sync_fields:
|
if (original_source is not None or target_source is not None) and supplied_sync_fields:
|
||||||
raise CalendarError(
|
raise CalendarError(
|
||||||
"source_kind, source_href, and etag are sync-owned fields on synchronized calendars"
|
"source_kind, source_href, and etag are sync-owned fields on synchronized calendars"
|
||||||
)
|
)
|
||||||
if payload.calendar_id is not None:
|
|
||||||
|
|
||||||
|
def _move_event_for_update(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
event: CalendarEvent,
|
||||||
|
payload: CalendarEventUpdateRequest,
|
||||||
|
original_source: CalendarSyncSource | None,
|
||||||
|
target_source: CalendarSyncSource | None,
|
||||||
|
) -> None:
|
||||||
|
if payload.calendar_id is None:
|
||||||
|
assert_sync_mutation_allowed(original_source)
|
||||||
|
return
|
||||||
assert_sync_mutation_allowed(target_source)
|
assert_sync_mutation_allowed(target_source)
|
||||||
|
original_caldav = event.source_kind == "caldav" and bool(event.source_href)
|
||||||
if payload.calendar_id != event.calendar_id and original_source and original_caldav:
|
if payload.calendar_id != event.calendar_id and original_source and original_caldav:
|
||||||
assert_sync_mutation_allowed(original_source)
|
assert_sync_mutation_allowed(original_source)
|
||||||
from govoplan_calendar.backend.outbox import enqueue_caldav_delete
|
from govoplan_calendar.backend.outbox import enqueue_caldav_delete
|
||||||
@@ -2635,8 +3007,14 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event
|
|||||||
event.source_href = None
|
event.source_href = None
|
||||||
event.etag = None
|
event.etag = None
|
||||||
event.calendar_id = payload.calendar_id
|
event.calendar_id = payload.calendar_id
|
||||||
else:
|
|
||||||
assert_sync_mutation_allowed(original_source)
|
|
||||||
|
def _apply_event_update_values(
|
||||||
|
event: CalendarEvent,
|
||||||
|
*,
|
||||||
|
user_id: str | None,
|
||||||
|
payload: CalendarEventUpdateRequest,
|
||||||
|
) -> None:
|
||||||
scalar_fields = (
|
scalar_fields = (
|
||||||
"sequence",
|
"sequence",
|
||||||
"summary",
|
"summary",
|
||||||
@@ -2665,7 +3043,13 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event
|
|||||||
for attr in scalar_fields:
|
for attr in scalar_fields:
|
||||||
value = getattr(payload, attr)
|
value = getattr(payload, attr)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
setattr(event, attr, value.upper() if attr in {"status", "transparency", "classification"} and isinstance(value, str) else value)
|
value = (
|
||||||
|
value.upper()
|
||||||
|
if attr in {"status", "transparency", "classification"}
|
||||||
|
and isinstance(value, str)
|
||||||
|
else value
|
||||||
|
)
|
||||||
|
setattr(event, attr, value)
|
||||||
if payload.start_at is not None:
|
if payload.start_at is not None:
|
||||||
event.start_at = normalize_datetime(payload.start_at)
|
event.start_at = normalize_datetime(payload.start_at)
|
||||||
if "end_at" in payload.model_fields_set:
|
if "end_at" in payload.model_fields_set:
|
||||||
@@ -2675,6 +3059,36 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event
|
|||||||
event.updated_by_user_id = user_id
|
event.updated_by_user_id = user_id
|
||||||
if payload.sequence is None:
|
if payload.sequence is None:
|
||||||
event.sequence += 1
|
event.sequence += 1
|
||||||
|
|
||||||
|
|
||||||
|
def update_event(session: Session, *, tenant_id: str, user_id: str | None, event_id: str, payload: CalendarEventUpdateRequest) -> CalendarEvent:
|
||||||
|
original_calendar_id, target_calendar_id = _event_update_calendar_ids(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
event_id=event_id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
original_source, target_source = _lock_event_update_sources(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
original_calendar_id=original_calendar_id,
|
||||||
|
target_calendar_id=target_calendar_id,
|
||||||
|
)
|
||||||
|
event = get_event(session, tenant_id=tenant_id, event_id=event_id)
|
||||||
|
previous = calendar_event_change_payload(event, prefix="previous_")
|
||||||
|
_assert_event_update_sync_fields(
|
||||||
|
payload,
|
||||||
|
original_source=original_source,
|
||||||
|
target_source=target_source,
|
||||||
|
)
|
||||||
|
_move_event_for_update(
|
||||||
|
session,
|
||||||
|
event=event,
|
||||||
|
payload=payload,
|
||||||
|
original_source=original_source,
|
||||||
|
target_source=target_source,
|
||||||
|
)
|
||||||
|
_apply_event_update_values(event, user_id=user_id, payload=payload)
|
||||||
validate_event_time(event)
|
validate_event_time(event)
|
||||||
event.raw_ics = None
|
event.raw_ics = None
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|||||||
@@ -11,20 +11,32 @@ from govoplan_access.backend.db import models as access_models # noqa: F401 - p
|
|||||||
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
|
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
|
||||||
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential
|
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential
|
||||||
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
|
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
|
||||||
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
|
from govoplan_calendar.backend.schemas import (
|
||||||
|
CalendarCalDavDiscoveryRequest,
|
||||||
|
CalendarCalDavSourceCreateRequest,
|
||||||
|
CalendarCalDavSourceUpdateRequest,
|
||||||
|
CalendarCollectionCreateRequest,
|
||||||
|
CalendarEventCreateRequest,
|
||||||
|
CalendarEventUpdateRequest,
|
||||||
|
)
|
||||||
from govoplan_calendar.backend.service import (
|
from govoplan_calendar.backend.service import (
|
||||||
CALDAV_INTERNAL_CREDENTIAL_PREFIX,
|
CALDAV_INTERNAL_CREDENTIAL_PREFIX,
|
||||||
CalendarError,
|
CalendarError,
|
||||||
caldav_client_for_source,
|
caldav_client_for_source,
|
||||||
|
caldav_source_response,
|
||||||
create_calendar,
|
create_calendar,
|
||||||
create_caldav_source,
|
create_caldav_source,
|
||||||
create_event,
|
create_event,
|
||||||
delete_calendar,
|
delete_calendar,
|
||||||
delete_event,
|
delete_event,
|
||||||
|
discover_caldav_calendars,
|
||||||
list_caldav_sources,
|
list_caldav_sources,
|
||||||
list_freebusy,
|
list_freebusy,
|
||||||
|
resolve_caldav_credential_ref,
|
||||||
|
resolve_trusted_deployment_caldav_credential_ref,
|
||||||
sync_caldav_source,
|
sync_caldav_source,
|
||||||
sync_due_caldav_sources,
|
sync_due_caldav_sources,
|
||||||
|
update_caldav_source,
|
||||||
update_event,
|
update_event,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
@@ -286,6 +298,201 @@ class CalDAVSyncTests(unittest.TestCase):
|
|||||||
self.assertEqual(client.username, "ada")
|
self.assertEqual(client.username, "ada")
|
||||||
self.assertEqual(client.password, "secret")
|
self.assertEqual(client.password, "secret")
|
||||||
|
|
||||||
|
def test_api_source_and_discovery_reject_caller_selected_credential_references(self) -> None:
|
||||||
|
session = self.session()
|
||||||
|
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||||
|
calendar = create_calendar(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCollectionCreateRequest(name="Remote"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
|
||||||
|
create_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCalDavSourceCreateRequest(
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
collection_url="https://attacker.example.test/cal",
|
||||||
|
auth_type="bearer",
|
||||||
|
credential_ref="env:MASTER_KEY_B64",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
|
||||||
|
discover_caldav_calendars(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
payload=CalendarCalDavDiscoveryRequest(
|
||||||
|
url="https://attacker.example.test/cal",
|
||||||
|
auth_type="bearer",
|
||||||
|
credential_ref="vault:another-tenant",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
source = create_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCalDavSourceCreateRequest(
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
collection_url="https://dav.example.test/cal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
|
||||||
|
update_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
source_id=source.id,
|
||||||
|
payload=CalendarCalDavSourceUpdateRequest(
|
||||||
|
credential_ref="env:MASTER_KEY_B64",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_provider_credentials_are_wrapped_in_tenant_and_source_owned_rows(self) -> None:
|
||||||
|
class Provider:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.values: dict[str, str] = {}
|
||||||
|
self.deleted: list[str] = []
|
||||||
|
|
||||||
|
def store_secret(self, *, scope: str, name: str, value: str) -> str:
|
||||||
|
reference = f"vault:{scope}:{name}"
|
||||||
|
self.values[reference] = value
|
||||||
|
return reference
|
||||||
|
|
||||||
|
def read_secret(self, secret_ref: str) -> str | None:
|
||||||
|
return self.values.get(secret_ref)
|
||||||
|
|
||||||
|
def delete_secret(self, secret_ref: str) -> None:
|
||||||
|
self.deleted.append(secret_ref)
|
||||||
|
|
||||||
|
provider = Provider()
|
||||||
|
session = self.session()
|
||||||
|
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||||
|
calendar = create_calendar(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCollectionCreateRequest(name="Remote"),
|
||||||
|
)
|
||||||
|
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
|
||||||
|
source = create_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCalDavSourceCreateRequest(
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
collection_url="https://dav.example.test/cal",
|
||||||
|
auth_type="basic",
|
||||||
|
username="ada",
|
||||||
|
password="secret",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
credential = session.query(CalendarSyncCredential).one()
|
||||||
|
provider_ref = str((credential.metadata_ or {})["provider_ref"])
|
||||||
|
|
||||||
|
self.assertTrue(source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX))
|
||||||
|
self.assertNotEqual(source.credential_ref, provider_ref)
|
||||||
|
self.assertEqual(caldav_client_for_source(session, source).password, "secret")
|
||||||
|
self.assertIsNone(caldav_source_response(source)["credential_ref"])
|
||||||
|
self.assertTrue(caldav_source_response(source)["has_credential"])
|
||||||
|
self.assertIsNone(
|
||||||
|
resolve_caldav_credential_ref(
|
||||||
|
session,
|
||||||
|
tenant_id="another-tenant",
|
||||||
|
source_id=source.id,
|
||||||
|
credential_ref=source.credential_ref,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
resolve_caldav_credential_ref(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
source_id="another-source",
|
||||||
|
credential_ref=source.credential_ref,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
other_calendar = create_calendar(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCollectionCreateRequest(name="Other remote"),
|
||||||
|
)
|
||||||
|
other_source = create_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCalDavSourceCreateRequest(
|
||||||
|
calendar_id=other_calendar.id,
|
||||||
|
collection_url="https://dav.example.test/other-cal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
other_source.auth_type = "basic"
|
||||||
|
other_source.username = "mallory"
|
||||||
|
other_source.credential_ref = source.credential_ref
|
||||||
|
with self.assertRaisesRegex(CalendarError, "not a server-owned credential"):
|
||||||
|
caldav_client_for_source(session, other_source)
|
||||||
|
delete_calendar(session, tenant_id="tenant-1", calendar_id=other_calendar.id)
|
||||||
|
self.assertEqual(provider.deleted, [])
|
||||||
|
|
||||||
|
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
|
||||||
|
|
||||||
|
self.assertEqual(provider.deleted, [provider_ref])
|
||||||
|
|
||||||
|
def test_legacy_unowned_refs_are_neither_read_nor_deleted(self) -> None:
|
||||||
|
class Provider:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.read: list[str] = []
|
||||||
|
self.deleted: list[str] = []
|
||||||
|
|
||||||
|
def read_secret(self, secret_ref: str) -> str | None:
|
||||||
|
self.read.append(secret_ref)
|
||||||
|
return "other-tenant-secret"
|
||||||
|
|
||||||
|
def delete_secret(self, secret_ref: str) -> None:
|
||||||
|
self.deleted.append(secret_ref)
|
||||||
|
|
||||||
|
provider = Provider()
|
||||||
|
session = self.session()
|
||||||
|
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||||
|
calendar = create_calendar(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCollectionCreateRequest(name="Remote"),
|
||||||
|
)
|
||||||
|
source = create_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCalDavSourceCreateRequest(
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
collection_url="https://dav.example.test/cal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
source.auth_type = "bearer"
|
||||||
|
source.credential_ref = "vault:another-tenant"
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
|
||||||
|
with self.assertRaisesRegex(CalendarError, "not a server-owned credential"):
|
||||||
|
caldav_client_for_source(session, source)
|
||||||
|
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
|
||||||
|
|
||||||
|
self.assertEqual(provider.read, [])
|
||||||
|
self.assertEqual(provider.deleted, [])
|
||||||
|
|
||||||
|
def test_trusted_deployment_env_resolution_is_explicit_and_separate(self) -> None:
|
||||||
|
with patch.dict("os.environ", {"CALDAV_DEPLOYMENT_TOKEN": "trusted-token"}):
|
||||||
|
self.assertEqual(
|
||||||
|
resolve_trusted_deployment_caldav_credential_ref("env:CALDAV_DEPLOYMENT_TOKEN"),
|
||||||
|
"trusted-token",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(CalendarError, "must use the env: prefix"):
|
||||||
|
resolve_trusted_deployment_caldav_credential_ref("vault:token")
|
||||||
|
|
||||||
def test_delete_calendar_retires_caldav_source_and_credential(self) -> None:
|
def test_delete_calendar_retires_caldav_source_and_credential(self) -> None:
|
||||||
session = self.session()
|
session = self.session()
|
||||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import threading
|
|||||||
import unittest
|
import unittest
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from govoplan_calendar.backend.caldav import (
|
from govoplan_calendar.backend.caldav import (
|
||||||
CalDAVClient,
|
CalDAVClient,
|
||||||
@@ -29,6 +30,22 @@ def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
|
|||||||
|
|
||||||
|
|
||||||
class CalDAVUrlSecurityTests(unittest.TestCase):
|
class CalDAVUrlSecurityTests(unittest.TestCase):
|
||||||
|
def test_transport_revalidates_dns_at_connection_time(self) -> None:
|
||||||
|
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
|
||||||
|
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
|
||||||
|
with patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
|
||||||
|
), patch(
|
||||||
|
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||||
|
side_effect=(public, private),
|
||||||
|
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
|
||||||
|
CalDAVError,
|
||||||
|
"non-public network",
|
||||||
|
):
|
||||||
|
urllib_transport("GET", "https://dav.example.test/event.ics", {}, None, 2)
|
||||||
|
socket_factory.assert_not_called()
|
||||||
|
|
||||||
def test_discovery_href_must_remain_on_configured_origin(self) -> None:
|
def test_discovery_href_must_remain_on_configured_origin(self) -> None:
|
||||||
base_url = "https://dav.example.test/calendars/ada/"
|
base_url = "https://dav.example.test/calendars/ada/"
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs"
|
"test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.9",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ export type CalendarSyncSource = {
|
|||||||
display_name?: string | null;
|
display_name?: string | null;
|
||||||
auth_type: CalendarCalDavAuthType;
|
auth_type: CalendarCalDavAuthType;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
credential_ref?: string | null;
|
|
||||||
has_credential: boolean;
|
has_credential: boolean;
|
||||||
sync_enabled: boolean;
|
sync_enabled: boolean;
|
||||||
sync_interval_seconds: number;
|
sync_interval_seconds: number;
|
||||||
@@ -114,7 +113,6 @@ export type CalendarCalDavDiscoveryPayload = {
|
|||||||
source_id?: string | null;
|
source_id?: string | null;
|
||||||
auth_type?: CalendarCalDavAuthType | null;
|
auth_type?: CalendarCalDavAuthType | null;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
credential_ref?: string | null;
|
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
bearer_token?: string | null;
|
bearer_token?: string | null;
|
||||||
};
|
};
|
||||||
@@ -128,7 +126,6 @@ export type CalendarSyncSourceCreatePayload = {
|
|||||||
display_name?: string | null;
|
display_name?: string | null;
|
||||||
auth_type?: CalendarCalDavAuthType;
|
auth_type?: CalendarCalDavAuthType;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
credential_ref?: string | null;
|
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
bearer_token?: string | null;
|
bearer_token?: string | null;
|
||||||
sync_enabled?: boolean;
|
sync_enabled?: boolean;
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ import {
|
|||||||
"react";
|
"react";
|
||||||
import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
AdminIconButton,
|
||||||
Button,
|
Button,
|
||||||
ColorPickerField,
|
ColorPickerField,
|
||||||
DateField,
|
DateField,
|
||||||
Dialog,
|
Dialog,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
|
PasswordField,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
|
TableActionGroup,
|
||||||
TimeField,
|
TimeField,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
hasScope,
|
hasScope,
|
||||||
@@ -84,7 +87,6 @@ type CalendarCalDavFormPayload = {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
auth_type: CalendarCalDavAuthType;
|
auth_type: CalendarCalDavAuthType;
|
||||||
username: string;
|
username: string;
|
||||||
credential_ref: string;
|
|
||||||
password: string;
|
password: string;
|
||||||
bearer_token: string;
|
bearer_token: string;
|
||||||
sync_enabled: boolean;
|
sync_enabled: boolean;
|
||||||
@@ -684,31 +686,24 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
<button type="button" className="calendar-list-name" onClick={() => setEventCalendarId(calendar.id)}>
|
<button type="button" className="calendar-list-name" onClick={() => setEventCalendarId(calendar.id)}>
|
||||||
{calendar.name}
|
{calendar.name}
|
||||||
</button>
|
</button>
|
||||||
<div className="calendar-list-actions">
|
<TableActionGroup
|
||||||
{source && canSyncCalendars &&
|
className="calendar-list-actions"
|
||||||
<Button
|
actions={[
|
||||||
type="button"
|
source && canSyncCalendars && {
|
||||||
className={syncing ? "calendar-row-icon-button is-syncing" : "calendar-row-icon-button"}
|
id: "sync",
|
||||||
title={syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name })}
|
label: syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name }),
|
||||||
aria-label={syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name })}
|
icon: <RefreshCw size={15} className={syncing ? "calendar-sync-spin" : undefined} />,
|
||||||
onClick={() => void handleSyncSource(source)}
|
onClick: () => void handleSyncSource(source),
|
||||||
disabled={saving || syncing}>
|
disabled: saving || syncing
|
||||||
|
},
|
||||||
<RefreshCw size={15} className={syncing ? "calendar-sync-spin" : undefined} />
|
(canManageCalendars || canDeleteCalendars) && {
|
||||||
</Button>
|
id: "edit",
|
||||||
|
label: i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: calendar.name }),
|
||||||
|
icon: <Pencil size={15} />,
|
||||||
|
onClick: () => openCalendarEditor(calendar)
|
||||||
}
|
}
|
||||||
{(canManageCalendars || canDeleteCalendars) &&
|
]}
|
||||||
<Button
|
/>
|
||||||
type="button"
|
|
||||||
className="calendar-row-icon-button"
|
|
||||||
title={i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: calendar.name })}
|
|
||||||
aria-label={i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: calendar.name })}
|
|
||||||
onClick={() => openCalendarEditor(calendar)}>
|
|
||||||
|
|
||||||
<Pencil size={15} />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
})}
|
})}
|
||||||
@@ -764,13 +759,19 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
|
|
||||||
<div className="calendar-toolbar-center">
|
<div className="calendar-toolbar-center">
|
||||||
<div className="calendar-icon-group" aria-label="i18n:govoplan-calendar.calendar_navigation.7ba43cd2">
|
<div className="calendar-icon-group" aria-label="i18n:govoplan-calendar.calendar_navigation.7ba43cd2">
|
||||||
<Button type="button" className="calendar-icon-button" title="i18n:govoplan-calendar.previous.50f94286" aria-label="i18n:govoplan-calendar.previous.50f94286" onClick={() => moveFocus(-1)} disabled={mode === "continuous"}>
|
<AdminIconButton
|
||||||
<ChevronLeft size={18} />
|
label="i18n:govoplan-calendar.previous.50f94286"
|
||||||
</Button>
|
icon={<ChevronLeft size={18} />}
|
||||||
|
onClick={() => moveFocus(-1)}
|
||||||
|
disabled={mode === "continuous"}
|
||||||
|
/>
|
||||||
<Button type="button" onClick={handleToday}>i18n:govoplan-calendar.today.24345a14</Button>
|
<Button type="button" onClick={handleToday}>i18n:govoplan-calendar.today.24345a14</Button>
|
||||||
<Button type="button" className="calendar-icon-button" title="i18n:govoplan-calendar.next.bc981983" aria-label="i18n:govoplan-calendar.next.bc981983" onClick={() => moveFocus(1)} disabled={mode === "continuous"}>
|
<AdminIconButton
|
||||||
<ChevronRight size={18} />
|
label="i18n:govoplan-calendar.next.bc981983"
|
||||||
</Button>
|
icon={<ChevronRight size={18} />}
|
||||||
|
onClick={() => moveFocus(1)}
|
||||||
|
disabled={mode === "continuous"}
|
||||||
|
/>
|
||||||
<div className="calendar-range-label">
|
<div className="calendar-range-label">
|
||||||
<CalendarDays size={18} aria-hidden="true" />
|
<CalendarDays size={18} aria-hidden="true" />
|
||||||
<strong>{heading}</strong>
|
<strong>{heading}</strong>
|
||||||
@@ -779,9 +780,11 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="calendar-toolbar-right">
|
<div className="calendar-toolbar-right">
|
||||||
<Button type="button" className="calendar-icon-button" title="i18n:govoplan-calendar.refresh.56e3badc" aria-label="i18n:govoplan-calendar.refresh.56e3badc" onClick={() => void loadEvents()}>
|
<AdminIconButton
|
||||||
<RefreshCw size={18} />
|
label="i18n:govoplan-calendar.refresh.56e3badc"
|
||||||
</Button>
|
icon={<RefreshCw size={18} />}
|
||||||
|
onClick={() => void loadEvents()}
|
||||||
|
/>
|
||||||
{canWrite &&
|
{canWrite &&
|
||||||
<Button type="button" variant="primary" onClick={() => setEventDialog({ kind: "create" })} disabled={!targetCalendarId}>
|
<Button type="button" variant="primary" onClick={() => setEventDialog({ kind: "create" })} disabled={!targetCalendarId}>
|
||||||
<Plus size={17} /> i18n:govoplan-calendar.new.6403f2b7
|
<Plus size={17} /> i18n:govoplan-calendar.new.6403f2b7
|
||||||
@@ -1343,7 +1346,6 @@ function CalendarCollectionDialog({
|
|||||||
const [displayName, setDisplayName] = useState(source?.display_name ?? "");
|
const [displayName, setDisplayName] = useState(source?.display_name ?? "");
|
||||||
const [authType, setAuthType] = useState<CalendarCalDavAuthType>(source?.auth_type ?? "basic");
|
const [authType, setAuthType] = useState<CalendarCalDavAuthType>(source?.auth_type ?? "basic");
|
||||||
const [username, setUsername] = useState(source?.username ?? "");
|
const [username, setUsername] = useState(source?.username ?? "");
|
||||||
const [credentialRef, setCredentialRef] = useState(source?.credential_ref ?? "");
|
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [bearerToken, setBearerToken] = useState("");
|
const [bearerToken, setBearerToken] = useState("");
|
||||||
const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true);
|
const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true);
|
||||||
@@ -1361,8 +1363,8 @@ function CalendarCollectionDialog({
|
|||||||
const effectiveCollectionUrl = (collectionUrl || davUrl).trim();
|
const effectiveCollectionUrl = (collectionUrl || davUrl).trim();
|
||||||
const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType;
|
const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType;
|
||||||
const needsSourceSecret = sourceMode !== "local" && (
|
const needsSourceSecret = sourceMode !== "local" && (
|
||||||
effectiveAuthType === "basic" && !source?.has_credential && !credentialRef.trim() && !password.trim() ||
|
effectiveAuthType === "basic" && !source?.has_credential && !password.trim() ||
|
||||||
effectiveAuthType === "bearer" && !source?.has_credential && !credentialRef.trim() && !bearerToken.trim());
|
effectiveAuthType === "bearer" && !source?.has_credential && !bearerToken.trim());
|
||||||
|
|
||||||
const sourceDetailsInvalid = sourceMode !== "local" && (
|
const sourceDetailsInvalid = sourceMode !== "local" && (
|
||||||
!isExistingSyncSource && !canEditSource ||
|
!isExistingSyncSource && !canEditSource ||
|
||||||
@@ -1385,7 +1387,6 @@ function CalendarCollectionDialog({
|
|||||||
displayName,
|
displayName,
|
||||||
authType,
|
authType,
|
||||||
username,
|
username,
|
||||||
credentialRef,
|
|
||||||
password,
|
password,
|
||||||
bearerToken,
|
bearerToken,
|
||||||
syncEnabled,
|
syncEnabled,
|
||||||
@@ -1413,7 +1414,6 @@ function CalendarCollectionDialog({
|
|||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
auth_type: effectiveAuthType,
|
auth_type: effectiveAuthType,
|
||||||
username,
|
username,
|
||||||
credential_ref: credentialRef,
|
|
||||||
password,
|
password,
|
||||||
bearer_token: bearerToken,
|
bearer_token: bearerToken,
|
||||||
sync_enabled: syncEnabled,
|
sync_enabled: syncEnabled,
|
||||||
@@ -1494,8 +1494,7 @@ function CalendarCollectionDialog({
|
|||||||
url,
|
url,
|
||||||
source_id: source?.id ?? null,
|
source_id: source?.id ?? null,
|
||||||
auth_type: authType,
|
auth_type: authType,
|
||||||
username: authType === "basic" ? username.trim() || null : null,
|
username: authType === "basic" ? username.trim() || null : null
|
||||||
credential_ref: credentialRef.trim() || null
|
|
||||||
};
|
};
|
||||||
if (authType === "basic" && password.trim()) payload.password = password;
|
if (authType === "basic" && password.trim()) payload.password = password;
|
||||||
if (authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken;
|
if (authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken;
|
||||||
@@ -1613,15 +1612,15 @@ function CalendarCollectionDialog({
|
|||||||
<input value={username} onChange={(item) => setUsername(item.target.value)} required={sourceMode !== "local" && effectiveAuthType === "basic"} maxLength={255} disabled={saving || !canEditSource} />
|
<input value={username} onChange={(item) => setUsername(item.target.value)} required={sourceMode !== "local" && effectiveAuthType === "basic"} maxLength={255} disabled={saving || !canEditSource} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>{source?.has_credential || credentialRef.trim() ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span>
|
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span>
|
||||||
<input type="password" value={password} onChange={(item) => setPassword(item.target.value)} disabled={saving || !canEditSource} autoComplete="new-password" />
|
<PasswordField value={password} onValueChange={setPassword} disabled={saving || !canEditSource} autoComplete="new-password" />
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
{effectiveAuthType === "bearer" &&
|
{effectiveAuthType === "bearer" &&
|
||||||
<label>
|
<label>
|
||||||
<span>{source?.has_credential || credentialRef.trim() ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span>
|
<span>{source?.has_credential ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span>
|
||||||
<input type="password" value={bearerToken} onChange={(item) => setBearerToken(item.target.value)} disabled={saving || !canEditSource} autoComplete="new-password" />
|
<PasswordField value={bearerToken} onValueChange={setBearerToken} disabled={saving || !canEditSource} autoComplete="new-password" />
|
||||||
</label>
|
</label>
|
||||||
}
|
}
|
||||||
<div className={sourceMode === "caldav" ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}>
|
<div className={sourceMode === "caldav" ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}>
|
||||||
@@ -1653,10 +1652,6 @@ function CalendarCollectionDialog({
|
|||||||
<span>i18n:govoplan-calendar.display_name.c7874aaa</span>
|
<span>i18n:govoplan-calendar.display_name.c7874aaa</span>
|
||||||
<input value={displayName} onChange={(item) => setDisplayName(item.target.value)} maxLength={255} disabled={saving || !canEditMutableSourceSettings} />
|
<input value={displayName} onChange={(item) => setDisplayName(item.target.value)} maxLength={255} disabled={saving || !canEditMutableSourceSettings} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
|
||||||
<span>i18n:govoplan-calendar.credential_reference.a7e92de5</span>
|
|
||||||
<input value={credentialRef} onChange={(item) => setCredentialRef(item.target.value)} placeholder="env:CALDAV_PASSWORD" maxLength={255} disabled={saving || !canEditSource} />
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="calendar-sync-settings">
|
<div className="calendar-sync-settings">
|
||||||
<ToggleSwitch label="i18n:govoplan-calendar.automatic_sync.084644b2" checked={syncEnabled} disabled={saving || !canEditMutableSourceSettings} onChange={setSyncEnabled} />
|
<ToggleSwitch label="i18n:govoplan-calendar.automatic_sync.084644b2" checked={syncEnabled} disabled={saving || !canEditMutableSourceSettings} onChange={setSyncEnabled} />
|
||||||
@@ -1686,7 +1681,7 @@ function CalendarCollectionDialog({
|
|||||||
<div><dt>i18n:govoplan-calendar.last_attempt.82aee111</dt><dd>{source.last_attempt_at ? dateTimeLabel(new Date(source.last_attempt_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
|
<div><dt>i18n:govoplan-calendar.last_attempt.82aee111</dt><dd>{source.last_attempt_at ? dateTimeLabel(new Date(source.last_attempt_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
|
||||||
<div><dt>i18n:govoplan-calendar.last_sync.ef0ef267</dt><dd>{source.last_synced_at ? dateTimeLabel(new Date(source.last_synced_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
|
<div><dt>i18n:govoplan-calendar.last_sync.ef0ef267</dt><dd>{source.last_synced_at ? dateTimeLabel(new Date(source.last_synced_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
|
||||||
<div><dt>i18n:govoplan-calendar.next_sync.88c7af72</dt><dd>{source.next_sync_at && source.sync_enabled ? dateTimeLabel(new Date(source.next_sync_at)) : "i18n:govoplan-calendar.not_scheduled.9c367369"}</dd></div>
|
<div><dt>i18n:govoplan-calendar.next_sync.88c7af72</dt><dd>{source.next_sync_at && source.sync_enabled ? dateTimeLabel(new Date(source.next_sync_at)) : "i18n:govoplan-calendar.not_scheduled.9c367369"}</dd></div>
|
||||||
<div><dt>i18n:govoplan-calendar.credential.8bede3ea</dt><dd>{source.has_credential || source.credential_ref ? "i18n:govoplan-calendar.configured.668c5fff" : "i18n:govoplan-calendar.not_configured.811931bb"}</dd></div>
|
<div><dt>i18n:govoplan-calendar.credential.8bede3ea</dt><dd>{source.has_credential ? "i18n:govoplan-calendar.configured.668c5fff" : "i18n:govoplan-calendar.not_configured.811931bb"}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
{source.last_error && <p className="calendar-form-error">{source.last_error}</p>}
|
{source.last_error && <p className="calendar-form-error">{source.last_error}</p>}
|
||||||
<div className="calendar-sync-actions">
|
<div className="calendar-sync-actions">
|
||||||
@@ -1704,7 +1699,7 @@ function CalendarCollectionDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{sourceMode !== "local" && effectiveAuthType !== "none" && !source?.has_credential && !credentialRef.trim() && <p className="calendar-form-note">i18n:govoplan-calendar.enter_a_secret_now_or_use_an_environment_referen.6db0e6ce <code>env:CALENDAR_SYNC_SECRET</code>.</p>}
|
{needsSourceSecret && <p className="calendar-form-note">i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54</p>}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
@@ -1812,10 +1807,7 @@ function CalendarCollectionDeleteDialog({
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{calendar.is_default &&
|
{calendar.is_default &&
|
||||||
<label className="calendar-checkbox-row">
|
<ToggleSwitch label="i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b" checked={makeTargetDefault} onChange={setMakeTargetDefault} />
|
||||||
<input type="checkbox" checked={makeTargetDefault} onChange={(item) => setMakeTargetDefault(item.target.checked)} />
|
|
||||||
<span>i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b</span>
|
|
||||||
</label>
|
|
||||||
}
|
}
|
||||||
<p className="calendar-form-note">{calendarBulkMoveConsequence(externalAction)}</p>
|
<p className="calendar-form-note">{calendarBulkMoveConsequence(externalAction)}</p>
|
||||||
</>
|
</>
|
||||||
@@ -2420,7 +2412,6 @@ function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload: Calend
|
|||||||
display_name: payload.display_name.trim() || null,
|
display_name: payload.display_name.trim() || null,
|
||||||
auth_type: sourceKind === "graph" ? "bearer" : payload.auth_type,
|
auth_type: sourceKind === "graph" ? "bearer" : payload.auth_type,
|
||||||
username: sourceKind !== "graph" && payload.auth_type === "basic" ? payload.username.trim() || null : null,
|
username: sourceKind !== "graph" && payload.auth_type === "basic" ? payload.username.trim() || null : null,
|
||||||
credential_ref: payload.credential_ref.trim() || null,
|
|
||||||
sync_enabled: payload.sync_enabled,
|
sync_enabled: payload.sync_enabled,
|
||||||
sync_interval_seconds: Math.max(60, payload.sync_interval_seconds),
|
sync_interval_seconds: Math.max(60, payload.sync_interval_seconds),
|
||||||
sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound",
|
sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound",
|
||||||
@@ -2436,8 +2427,7 @@ function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload):
|
|||||||
const result: CalendarSyncSourceUpdatePayload = {
|
const result: CalendarSyncSourceUpdatePayload = {
|
||||||
collection_url: payload.collection_url.trim(),
|
collection_url: payload.collection_url.trim(),
|
||||||
auth_type: payload.auth_type,
|
auth_type: payload.auth_type,
|
||||||
username: payload.auth_type === "basic" ? payload.username.trim() || null : null,
|
username: payload.auth_type === "basic" ? payload.username.trim() || null : null
|
||||||
credential_ref: payload.credential_ref.trim() || null
|
|
||||||
};
|
};
|
||||||
if (payload.auth_type === "basic" && payload.password.trim()) result.password = payload.password;
|
if (payload.auth_type === "basic" && payload.password.trim()) result.password = payload.password;
|
||||||
if (payload.auth_type === "bearer" && payload.bearer_token.trim()) result.bearer_token = payload.bearer_token;
|
if (payload.auth_type === "bearer" && payload.bearer_token.trim()) result.bearer_token = payload.bearer_token;
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
|
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
|
||||||
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
|
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
|
||||||
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
|
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
|
||||||
"i18n:govoplan-calendar.credential_reference.a7e92de5": "Credential reference",
|
|
||||||
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
|
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
|
||||||
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
|
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
|
||||||
"i18n:govoplan-calendar.day.987b9ced": "Day",
|
"i18n:govoplan-calendar.day.987b9ced": "Day",
|
||||||
@@ -63,7 +62,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-calendar.end_date.89d10cd6": "End date",
|
"i18n:govoplan-calendar.end_date.89d10cd6": "End date",
|
||||||
"i18n:govoplan-calendar.end_mode.5a06de37": "End mode",
|
"i18n:govoplan-calendar.end_mode.5a06de37": "End mode",
|
||||||
"i18n:govoplan-calendar.end_time.cd7800da": "End time",
|
"i18n:govoplan-calendar.end_time.cd7800da": "End time",
|
||||||
"i18n:govoplan-calendar.enter_a_secret_now_or_use_an_environment_referen.6db0e6ce": "Enter a secret now or use an environment reference such as",
|
"i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54": "Enter a password or token for this source.",
|
||||||
"i18n:govoplan-calendar.etag.11d00f6e": "ETag",
|
"i18n:govoplan-calendar.etag.11d00f6e": "ETag",
|
||||||
"i18n:govoplan-calendar.event": "event",
|
"i18n:govoplan-calendar.event": "event",
|
||||||
"i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.",
|
"i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.",
|
||||||
@@ -228,7 +227,6 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
|
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
|
||||||
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
|
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
|
||||||
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
|
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
|
||||||
"i18n:govoplan-calendar.credential_reference.a7e92de5": "Credential reference",
|
|
||||||
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
|
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
|
||||||
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
|
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
|
||||||
"i18n:govoplan-calendar.day.987b9ced": "Tag",
|
"i18n:govoplan-calendar.day.987b9ced": "Tag",
|
||||||
@@ -252,7 +250,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-calendar.end_date.89d10cd6": "End date",
|
"i18n:govoplan-calendar.end_date.89d10cd6": "End date",
|
||||||
"i18n:govoplan-calendar.end_mode.5a06de37": "End mode",
|
"i18n:govoplan-calendar.end_mode.5a06de37": "End mode",
|
||||||
"i18n:govoplan-calendar.end_time.cd7800da": "End time",
|
"i18n:govoplan-calendar.end_time.cd7800da": "End time",
|
||||||
"i18n:govoplan-calendar.enter_a_secret_now_or_use_an_environment_referen.6db0e6ce": "Enter a secret now or use an environment reference such as",
|
"i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54": "Geben Sie ein Passwort oder Token für diese Quelle ein.",
|
||||||
"i18n:govoplan-calendar.etag.11d00f6e": "ETag",
|
"i18n:govoplan-calendar.etag.11d00f6e": "ETag",
|
||||||
"i18n:govoplan-calendar.event": "event",
|
"i18n:govoplan-calendar.event": "event",
|
||||||
"i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.",
|
"i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.",
|
||||||
|
|||||||
@@ -89,21 +89,6 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-icon-button.btn {
|
|
||||||
width: 36px;
|
|
||||||
min-width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-icon-button.btn:disabled {
|
|
||||||
opacity: .42;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-mode-switch {
|
.calendar-mode-switch {
|
||||||
flex: 0 1 520px;
|
flex: 0 1 520px;
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
@@ -250,40 +235,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-list-actions {
|
.calendar-list-actions {
|
||||||
display: flex;
|
width: auto;
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-row-icon-button.btn {
|
|
||||||
width: 28px;
|
|
||||||
min-width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
opacity: .72;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-row-icon-button.btn:hover,
|
|
||||||
.calendar-row-icon-button.btn:focus-visible {
|
|
||||||
border-color: var(--line-dark);
|
|
||||||
background: var(--surface);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-row-icon-button.btn.is-syncing,
|
|
||||||
.calendar-row-icon-button.btn.is-syncing:disabled {
|
|
||||||
border-color: var(--green);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--green);
|
|
||||||
opacity: 1;
|
|
||||||
cursor: progress;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-sync-spin {
|
.calendar-sync-spin {
|
||||||
|
|||||||
Reference in New Issue
Block a user