📝(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:
320
openspec/changes/add-ical-subscription-export/design.md
Normal file
320
openspec/changes/add-ical-subscription-export/design.md
Normal 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)
|
||||
62
openspec/changes/add-ical-subscription-export/proposal.md
Normal file
62
openspec/changes/add-ical-subscription-export/proposal.md
Normal 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
|
||||
@@ -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
|
||||
127
openspec/changes/add-ical-subscription-export/tasks.md
Normal file
127
openspec/changes/add-ical-subscription-export/tasks.md
Normal 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
|
||||
Reference in New Issue
Block a user