📝(docs) add OpenSpec workflow

Add OpenSpec workflow for AI-assisted change proposals
including proposal templates, archive commands, and
project configuration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-01-25 20:36:05 +01:00
parent 40b7d66bff
commit a96be2c7b7
10 changed files with 1363 additions and 0 deletions

View File

@@ -0,0 +1,320 @@
# Design: iCal Subscription Export
## Context
La Suite Calendars uses SabreDAV as its CalDAV server, but the current
authentication model (API key + X-Forwarded-User headers) prevents direct
access from external calendar clients. Users need a way to subscribe to their
calendars from applications like Apple Calendar, Google Calendar, etc.
SabreDAV provides an `ICSExportPlugin` that generates RFC 5545 compliant iCal
files. We want to leverage this plugin while providing a clean, unauthenticated
URL for external calendar applications.
## Goals / Non-Goals
**Goals:**
- Allow users to subscribe to their calendars from external applications
- Per-calendar subscription URLs with private tokens
- Clean URL format similar to Google Calendar / Outlook
- Ability to revoke/regenerate tokens
- Reuse SabreDAV's ICSExportPlugin for ICS generation
- **Standalone tokens that don't require synchronizing CalDAV calendars with Django**
**Non-Goals:**
- Write access from external clients (read-only subscriptions)
- Full CalDAV protocol support for external clients
- Importing external calendars into La Suite Calendars (future feature)
- Real-time sync (clients poll at their own refresh rate)
## Decisions
### 1. URL Format
**Decision:** Use a short, clean URL with token in the path:
```
https://<domain>/ical/<uuid-token>.ics
```
**Examples from other services:**
- Google: `https://calendar.google.com/calendar/ical/<id>/public/basic.ics`
- Outlook: `https://outlook.office365.com/owa/calendar/<id>/<id>/calendar.ics`
**Rationale:**
- Industry standard format
- No authentication prompt in calendar apps (token IS the auth)
- Easy to copy/paste
- Token not exposed in query strings (cleaner logs)
### 2. Django Proxy to SabreDAV
**Decision:** Django handles the public endpoint and proxies to SabreDAV.
```
Apple Calendar
│ GET /ical/<token>.ics (no auth headers)
Django (public endpoint)
│ 1. Extract token from URL
│ 2. Lookup CalendarSubscriptionToken in DB
│ 3. Get caldav_path and owner.email directly from token
Django → SabreDAV (internal)
│ GET /calendars/<owner>/<calendar>?export
│ Headers: X-Api-Key, X-Forwarded-User
SabreDAV ICSExportPlugin
│ Generates RFC 5545 ICS
Django returns ICS to client
```
**Rationale:**
- No changes to SabreDAV authentication backend
- Clean separation: Django handles tokens, SabreDAV handles CalDAV
- Token validation logic stays in Python (easier to test/maintain)
- Reuses existing CalDAV proxy infrastructure
### 3. Token Storage - Standalone Model
**Decision:** Django model `CalendarSubscriptionToken` is **standalone** and stores the CalDAV path directly:
```python
class CalendarSubscriptionToken(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
# Owner of the calendar (for permission verification)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="subscription_tokens",
)
# CalDAV path stored directly (e.g., /calendars/user@example.com/uuid/)
caldav_path = models.CharField(max_length=512)
# Calendar display name (for UI and filename)
calendar_name = models.CharField(max_length=255, blank=True, default="")
token = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
is_active = models.BooleanField(default=True)
last_accessed_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["owner", "caldav_path"],
name="unique_token_per_owner_calendar",
)
]
```
**Rationale:**
- **No dependency on Django Calendar model** - tokens work directly with CalDAV paths
- No need to synchronize CalDAV calendars to Django before creating tokens
- Works for all calendars (not just those previously synced to Django)
- Avoids fragile name-matching when multiple calendars have the same name
- UUID provides 128 bits of entropy (secure)
- `is_active` allows soft-disable without deletion
- `last_accessed_at` for auditing
- Unique constraint ensures one token per user+calendar combination
### 4. Token Scope: Per Calendar Path
**Decision:** One token per user + CalDAV path combination.
**Rationale:**
- Users can share specific calendars without exposing all calendars
- Revoking one calendar's access doesn't affect others
- Permission verification via path: user's email must be in the CalDAV path
### 5. Permission Verification via CalDAV Path
**Decision:** Verify ownership by checking the user's email is in the CalDAV path.
```python
def _verify_caldav_access(self, user, caldav_path):
# Path format: /calendars/user@example.com/uuid/
parts = caldav_path.strip("/").split("/")
if len(parts) >= 2 and parts[0] == "calendars":
path_email = unquote(parts[1])
return path_email.lower() == user.email.lower()
return False
```
**Rationale:**
- CalDAV paths inherently contain the owner's email
- No need to query CalDAV server to check permissions
- Simple and fast verification
### 6. ICS Generation via SabreDAV
**Decision:** Use SabreDAV's `ICSExportPlugin` instead of generating ICS in
Django.
**Rationale:**
- ICSExportPlugin is battle-tested and RFC 5545 compliant
- Handles recurring events, timezones, and edge cases correctly
- No code duplication
- SabreDAV already has the calendar data
**Required change in `server.php`:**
```php
$server->addPlugin(new CalDAV\ICSExportPlugin());
```
## API Design
### Public Endpoint (no authentication)
```
GET /ical/<uuid>.ics
→ Validates token
→ Proxies to SabreDAV using token.caldav_path and token.owner.email
→ Returns ICS (Content-Type: text/calendar)
→ 404 if token invalid/inactive
```
### Token Management (authenticated Django API)
**New standalone endpoint:**
```
POST /api/v1.0/subscription-tokens/
Body: { caldav_path, calendar_name (optional) }
→ Creates token or returns existing (owner only)
→ Verifies user's email is in caldav_path
→ Returns: { token, url, caldav_path, calendar_name, created_at }
GET /api/v1.0/subscription-tokens/by-path/?caldav_path=...
→ Returns existing token or 404
DELETE /api/v1.0/subscription-tokens/by-path/?caldav_path=...
→ Deletes token (revokes access)
```
### Frontend Flow
1. User clicks "Get subscription URL" on a calendar
2. Frontend extracts CalDAV path from the calendar's URL
3. Frontend calls `POST /subscription-tokens/` with `{ caldav_path, calendar_name }`
4. Backend creates token (or returns existing) and returns subscription URL
5. Modal displays URL with copy button
## Security Considerations
### Token as Secret
- Token is a UUID (128 bits of entropy) - infeasible to brute force
- Knowledge of token = read access to calendar
- URL should be treated as confidential
### Mitigations
- Clear UI warning about URL privacy
- Easy token regeneration (delete + create)
- `last_accessed_at` tracking for auditing
- Rate limiting on `/ical/` endpoint (future)
### Attack Surface
- Token in URL may appear in:
- Server access logs → configure log rotation, mask tokens
- Browser history (if opened in browser) → minor concern
- Referrer headers → set `Referrer-Policy: no-referrer`
- No CSRF risk (read-only, no state changes via GET)
## Implementation Notes
### Django View for /ical/<token>.ics
```python
class ICalExportView(View):
def get(self, request, token):
# 1. Lookup token
subscription = CalendarSubscriptionToken.objects.filter(
token=token, is_active=True
).select_related('owner').first()
if not subscription:
raise Http404
# 2. Update last_accessed_at
subscription.last_accessed_at = timezone.now()
subscription.save(update_fields=['last_accessed_at'])
# 3. Proxy to SabreDAV using caldav_path and owner directly
caldav_path = subscription.caldav_path.lstrip("/")
caldav_url = f"{settings.CALDAV_URL}/api/v1.0/caldav/{caldav_path}?export"
response = requests.get(
caldav_url,
headers={
'X-Api-Key': settings.CALDAV_OUTBOUND_API_KEY,
'X-Forwarded-User': subscription.owner.email,
}
)
# 4. Return ICS
display_name = subscription.calendar_name or "calendar"
return HttpResponse(
response.content,
content_type='text/calendar',
headers={
'Content-Disposition': f'attachment; filename="{display_name}.ics"',
'Cache-Control': 'no-store, private',
'Referrer-Policy': 'no-referrer',
}
)
```
### URL Configuration
```python
# urls.py
urlpatterns = [
path('ical/<uuid:token>.ics', ICalExportView.as_view(), name='ical-export'),
]
```
## Risks / Trade-offs
### Trade-off: Extra HTTP Hop
Django proxies to SabreDAV (local network call).
- **Pro:** Clean architecture, no PHP changes
- **Con:** Slight latency (~1-5ms on localhost)
- **Verdict:** Acceptable for a polling use case (clients refresh hourly)
### Risk: Token Leakage
If URL is shared/leaked, anyone can read the calendar.
- **Mitigation:** Regenerate token feature, access logging, UI warnings
### Risk: Large Calendar Performance
Generating ICS for calendars with thousands of events.
- **Mitigation:** SabreDAV handles this efficiently
- **Future:** Add date range filtering (`?start=...&end=...`)
## Migration Plan
1. Add `CalendarSubscriptionToken` Django model with standalone fields
2. Create migration (adds owner, caldav_path, calendar_name fields)
3. Add `ICSExportPlugin` to SabreDAV `server.php`
4. Create Django `/ical/<token>.ics` endpoint
5. Add standalone `SubscriptionTokenViewSet` API
6. Update frontend to use caldav_path instead of calendar ID
7. No data migration needed (new feature)
## References
- [SabreDAV ICSExportPlugin](https://sabre.io/dav/ics-export-plugin/)
- [Google Calendar public URL format](https://support.google.com/calendar/answer/37083)
- [Outlook calendar publishing](https://support.microsoft.com/en-us/office/introduction-to-publishing-internet-calendars-a25e68d6-695a-41c6-a701-103d44ba151d)

View File

@@ -0,0 +1,62 @@
# Change: Add iCal Subscription Export
## Why
Users want to subscribe to their La Suite Calendars calendars from external
applications (Apple Calendar, Google Calendar, Thunderbird, Outlook, etc.)
using standard iCal URLs. Currently, external access is not supported due to
the API-key authentication model, preventing users from accessing their
calendars outside the web application.
## What Changes
- Add a standalone `CalendarSubscriptionToken` Django model that stores:
- Owner (user) reference
- CalDAV path directly (no FK to Calendar model)
- Calendar display name
- Token UUID and metadata
- Create a public Django endpoint `/ical/<token>.ics` that:
- Validates the token (no user authentication required)
- Proxies the request to SabreDAV using the stored caldav_path and owner email
- Returns the ICS generated by SabreDAV's `ICSExportPlugin`
- Enable SabreDAV's `ICSExportPlugin` in `server.php`
- Add standalone Django REST API endpoint `/api/v1.0/subscription-tokens/`:
- POST to create token with `{ caldav_path, calendar_name }`
- GET/DELETE by-path to manage existing tokens
- Permission verification via caldav_path (user's email must be in path)
- Add UI in the calendar context menu to obtain and copy the subscription URL
- Frontend extracts CalDAV path from calendar URL
- Modal auto-creates token on open
## Architecture Approach
**URL format:** `https://calendars.example.com/ical/<uuid-token>.ics`
**Key design decision:** The token model is **standalone** and stores the CalDAV
path directly, avoiding the need to synchronize CalDAV calendars with Django.
This follows the same pattern as Google Calendar and Outlook:
- Short, clean URL
- Token in URL path acts as authentication (no username/password)
- Reuses SabreDAV's ICSExportPlugin for RFC 5545 compliant ICS generation
See `design.md` for detailed technical flow.
## Impact
- **Affected specs**: New capability `ical-subscription-export`
- **Affected code**:
- `docker/sabredav/server.php` - Add ICSExportPlugin
- `src/backend/core/models.py` - New CalendarSubscriptionToken model (standalone)
- `src/backend/core/api/viewsets.py` - New SubscriptionTokenViewSet
- `src/backend/core/api/viewsets_ical.py` - New ICalExportView
- `src/frontend/apps/calendars/` - UI for subscription URL with caldavPath
- **Security**: Tokens are random UUIDs; URLs should be treated as secrets
- **Database**: New Django table for subscription tokens
- **No breaking changes** to existing functionality
## Out of Scope (Future Work)
- Subscribing TO external calendars from within La Suite Calendars (import)
- CalDAV access with HTTP Basic authentication
- Public (unauthenticated) calendar sharing without token

View File

@@ -0,0 +1,176 @@
## ADDED Requirements
### Requirement: Calendar Subscription Token Management
The system SHALL allow calendar owners to generate a private subscription token
for their calendars using CalDAV paths directly, enabling read-only access via
iCal URL from external calendar applications without requiring Django Calendar
model synchronization.
#### Scenario: Owner generates subscription token
- **GIVEN** a user owns a calendar with CalDAV path `/calendars/<email>/<uuid>/`
- **WHEN** the user requests a subscription token with that CalDAV path
- **THEN** the system verifies the user's email matches the path
- **AND** generates a unique UUID token stored with the CalDAV path
- **AND** returns the subscription URL in format `/ical/<token>.ics`
#### Scenario: Owner retrieves existing subscription token
- **GIVEN** a user owns a calendar with an existing subscription token
- **WHEN** the user requests the subscription token by CalDAV path
- **THEN** the system returns the existing token and URL
#### Scenario: Owner regenerates subscription token
- **GIVEN** a user owns a calendar with an existing subscription token
- **WHEN** the user deletes the token and creates a new one
- **THEN** the old token is invalidated
- **AND** a new unique token is generated
- **AND** the old subscription URL no longer works
#### Scenario: Owner revokes subscription token
- **GIVEN** a user owns a calendar with an existing subscription token
- **WHEN** the user requests to delete the token by CalDAV path
- **THEN** the system removes the token
- **AND** the subscription URL returns 404
#### Scenario: Non-owner cannot manage subscription token
- **GIVEN** a user attempts to create a token for a CalDAV path not containing their email
- **WHEN** the user sends a request with that CalDAV path
- **THEN** the system rejects the request with a 403 permission error
#### Scenario: One token per calendar path per owner
- **GIVEN** a user already has a subscription token for a CalDAV path
- **WHEN** the user requests to create another token for the same path
- **THEN** the system returns the existing token instead of creating a duplicate
---
### Requirement: Public iCal Export Endpoint
The system SHALL provide a public endpoint that serves calendar data in iCal
format when accessed with a valid subscription token, without requiring user
authentication, using the CalDAV path stored directly in the token.
#### Scenario: Valid token returns calendar data
- **GIVEN** a valid and active subscription token exists with a CalDAV path
- **WHEN** an HTTP GET request is made to `/ical/<token>.ics`
- **THEN** the system proxies to SabreDAV using the token's caldav_path and owner email
- **AND** returns the calendar events in iCal format
- **AND** the response Content-Type is `text/calendar`
- **AND** the response is RFC 5545 compliant
- **AND** no authentication headers are required
#### Scenario: Invalid token returns 404
- **GIVEN** a token that does not exist in the system
- **WHEN** an HTTP GET request is made to `/ical/<invalid-token>.ics`
- **THEN** the system returns HTTP 404 Not Found
#### Scenario: Deleted token returns 404
- **GIVEN** a subscription token that has been deleted
- **WHEN** an HTTP GET request is made to `/ical/<deleted-token>.ics`
- **THEN** the system returns HTTP 404 Not Found
#### Scenario: Access tracking
- **GIVEN** a valid subscription token
- **WHEN** the iCal endpoint is accessed successfully
- **THEN** the system updates the token's last accessed timestamp
#### Scenario: Security headers are set
- **GIVEN** a valid subscription URL
- **WHEN** the iCal endpoint returns a response
- **THEN** the response includes `Cache-Control: no-store, private`
- **AND** the response includes `Referrer-Policy: no-referrer`
#### Scenario: Compatible with external calendar apps
- **GIVEN** a valid subscription URL
- **WHEN** the URL is added to Apple Calendar as a subscription
- **THEN** Apple Calendar successfully subscribes and displays events
- **AND** events sync automatically on refresh
---
### Requirement: Subscription URL User Interface
The system SHALL provide a user interface for calendar owners to obtain and
manage subscription URLs using CalDAV paths extracted from calendar URLs.
#### Scenario: Access subscription URL from calendar menu
- **GIVEN** a user is viewing their calendars
- **WHEN** the user opens the context menu for a calendar they own
- **THEN** an option to get the subscription URL is available
#### Scenario: Subscription option hidden for non-owned calendars
- **GIVEN** a user has shared access to a calendar but is not the owner
- **WHEN** the user opens the context menu for that calendar
- **THEN** the subscription URL option is NOT displayed
#### Scenario: Display subscription URL modal
- **GIVEN** a user clicks the subscription URL option for their calendar
- **WHEN** the modal opens
- **THEN** the frontend extracts the CalDAV path from the calendar URL
- **AND** creates or retrieves the token using the CalDAV path
- **AND** the full subscription URL is displayed
- **AND** a "Copy to clipboard" button is available
- **AND** a warning about keeping the URL private is shown
- **AND** an option to regenerate the URL is available
#### Scenario: Copy URL to clipboard
- **GIVEN** the subscription URL modal is open
- **WHEN** the user clicks "Copy to clipboard"
- **THEN** the URL is copied to the system clipboard
- **AND** visual feedback confirms the copy was successful
#### Scenario: Regenerate token from modal
- **GIVEN** the subscription URL modal is open
- **WHEN** the user clicks to regenerate the URL
- **THEN** a confirmation dialog is shown
- **AND** upon confirmation, the old token is deleted
- **AND** a new token is generated
- **AND** the modal updates to show the new URL
#### Scenario: Error handling in modal
- **GIVEN** the subscription URL modal is open
- **WHEN** the initial token fetch returns 404 (no existing token)
- **THEN** the system automatically creates a new token
- **AND** no error message is displayed to the user
- **BUT** if token creation fails, an error message is displayed
---
### Requirement: Standalone Token Storage
The system SHALL store subscription tokens independently of the Django Calendar
model, using CalDAV paths directly to enable token management without requiring
CalDAV-to-Django synchronization.
#### Scenario: Token stores CalDAV path directly
- **GIVEN** a subscription token is created
- **THEN** the token record includes the full CalDAV path
- **AND** the token record includes the owner (user) reference
- **AND** the token record includes an optional calendar display name
- **AND** no foreign key to Django Calendar model is required
#### Scenario: Permission verification via path
- **GIVEN** a user requests a subscription token
- **WHEN** the system verifies permissions
- **THEN** it checks that the user's email appears in the CalDAV path
- **AND** does not require querying the CalDAV server

View File

@@ -0,0 +1,127 @@
# Tasks: iCal Subscription Export
## 1. Backend - Data Model
- [x] 1.1 Create `CalendarSubscriptionToken` model in `core/models.py`
- ForeignKey to User (owner, on_delete=CASCADE)
- `caldav_path` field (CharField, max_length=512) - stores CalDAV path directly
- `calendar_name` field (CharField, max_length=255, optional) - for display
- `token` field (UUID, unique, indexed, default=uuid4)
- `is_active` boolean (default=True)
- `last_accessed_at` DateTimeField (nullable)
- UniqueConstraint on (owner, caldav_path)
- [x] 1.2 Create and run database migration
- [x] 1.3 Add model to Django admin for debugging
## 2. SabreDAV - Enable ICSExportPlugin
- [x] 2.1 Add `ICSExportPlugin` to `server.php`:
```php
$server->addPlugin(new CalDAV\ICSExportPlugin());
```
- [x] 2.2 Test that `?export` works via existing CalDAV proxy
## 3. Backend - Public iCal Endpoint
- [x] 3.1 Create `ICalExportView` in `core/api/viewsets_ical.py`
- No authentication required (public endpoint)
- Extract token from URL path
- Lookup `CalendarSubscriptionToken` by token
- Return 404 if token invalid/inactive
- Update `last_accessed_at` on access
- Proxy request to SabreDAV using `token.caldav_path` and `token.owner.email`
- Return ICS response with `Content-Type: text/calendar`
- Set security headers (Cache-Control, Referrer-Policy)
- [x] 3.2 Add URL route: `path('ical/<uuid:token>.ics', ...)`
- [x] 3.3 Write tests for public endpoint (valid token, invalid token, inactive)
## 4. Backend - Standalone Token Management API
- [x] 4.1 Create serializers in `core/api/serializers.py`
- `CalendarSubscriptionTokenSerializer` - fields: token, url, caldav_path, calendar_name, etc.
- `CalendarSubscriptionTokenCreateSerializer` - for POST body validation
- [x] 4.2 Create standalone `SubscriptionTokenViewSet` in `core/api/viewsets.py`:
- `POST /subscription-tokens/` - create token with { caldav_path, calendar_name }
- `GET /subscription-tokens/by-path/?caldav_path=...` - get existing token
- `DELETE /subscription-tokens/by-path/?caldav_path=...` - revoke token
- Permission verification: user's email must be in caldav_path
- [x] 4.3 Register viewset in `core/urls.py`
- [x] 4.4 Write API tests for token management (create, get, delete, permissions)
## 5. Frontend - API Integration
- [x] 5.1 Add API functions in `features/calendar/api.ts`:
- `getSubscriptionToken(caldavPath)` - GET by-path
- `createSubscriptionToken({ caldavPath, calendarName })` - POST
- `deleteSubscriptionToken(caldavPath)` - DELETE by-path
- [x] 5.2 Update React Query hooks in `hooks/useCalendars.ts`
- Use caldavPath instead of calendarId
## 6. Frontend - UI Components
- [x] 6.1 Update `SubscriptionUrlModal` component
- Accept `caldavPath` prop instead of `calendarId`
- Extract caldavPath from calendar URL in parent component
- Display the subscription URL in a copyable field
- "Copy to clipboard" button with success feedback
- Warning text about URL being private
- "Regenerate URL" button with confirmation dialog
- Only show error alert for real errors (not for expected 404)
- [x] 6.2 Update `CalendarList.tsx`
- Extract CalDAV path from calendar URL
- Pass caldavPath to SubscriptionUrlModal
- [x] 6.3 Add translations (i18n) for new UI strings
## 7. Cleanup
- [x] 7.1 Remove old `subscription_token` action from CalendarViewSet
- [x] 7.2 Remove `sync-from-caldav` endpoint (no longer needed)
- [x] 7.3 Remove `syncFromCaldav` from frontend API
## 8. Testing & Validation
- [x] 8.1 Manual test: add URL to Apple Calendar
- [ ] 8.2 Manual test: add URL to Google Calendar
- [x] 8.3 Verify token regeneration invalidates old URL
- [ ] 8.4 E2E test for subscription workflow (optional)
## Dependencies
```
1 (Django model)
2 (ICSExportPlugin) ──────┐
↓ │
3 (Public endpoint) ──────┤ can run in parallel after 1
↓ │
4 (Token API) ────────────┘
5 (Frontend API)
6 (Frontend UI)
7 (Cleanup)
8 (Testing)
```
## Key Files Modified
### Backend
- `src/backend/core/models.py` - CalendarSubscriptionToken model (standalone)
- `src/backend/core/migrations/0002_calendarsubscriptiontoken.py`
- `src/backend/core/api/serializers.py` - Token serializers
- `src/backend/core/api/viewsets.py` - SubscriptionTokenViewSet
- `src/backend/core/api/viewsets_ical.py` - ICalExportView
- `src/backend/core/urls.py` - Route registration
- `src/backend/core/admin.py` - Admin configuration
- `src/backend/core/factories.py` - Test factory
### Frontend
- `src/features/calendar/api.ts` - API functions with caldavPath
- `src/features/calendar/hooks/useCalendars.ts` - React Query hooks
- `src/features/calendar/components/calendar-list/CalendarList.tsx`
- `src/features/calendar/components/calendar-list/SubscriptionUrlModal.tsx`
### SabreDAV
- `docker/sabredav/server.php` - ICSExportPlugin enabled