diff --git a/.docker/selfhost/schema.json b/.docker/selfhost/schema.json index b74f13fcb..f9eece72a 100644 --- a/.docker/selfhost/schema.json +++ b/.docker/selfhost/schema.json @@ -868,6 +868,72 @@ "externalWebhookUrl": "", "webhookVerificationToken": "" } + }, + "caldav": { + "type": "object", + "description": "CalDAV integration config\n@default {\"enabled\":false,\"allowCustomProvider\":false,\"providers\":[],\"allowInsecureHttp\":false,\"allowedHosts\":[],\"blockPrivateNetwork\":true,\"requestTimeoutMs\":10000,\"maxRedirects\":5}", + "properties": { + "enabled": { + "type": "boolean" + }, + "allowCustomProvider": { + "type": "boolean" + }, + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "serverUrl": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "requiresAppPassword": { + "type": "boolean" + }, + "docsUrl": { + "type": "string" + } + } + } + }, + "allowInsecureHttp": { + "type": "boolean" + }, + "allowedHosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "blockPrivateNetwork": { + "type": "boolean" + }, + "requestTimeoutMs": { + "type": "number" + }, + "maxRedirects": { + "type": "number" + } + }, + "default": { + "enabled": false, + "allowCustomProvider": false, + "providers": [], + "allowInsecureHttp": false, + "allowedHosts": [], + "blockPrivateNetwork": true, + "requestTimeoutMs": 10000, + "maxRedirects": 5 + } } } }, diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index 1d0973ad3..c636a824d 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -45,8 +45,6 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }} - MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }} - GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} - name: Upload web artifact uses: actions/upload-artifact@v4 with: @@ -79,8 +77,6 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }} - MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }} - GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} - name: Upload admin artifact uses: actions/upload-artifact@v4 with: @@ -113,8 +109,6 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }} - MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }} - GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} - name: Upload mobile artifact uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 3a0b85fc1..715e54ab6 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -1016,21 +1016,12 @@ jobs: fail-fast: false matrix: tests: - - name: 'Cloud E2E Test 1/10' + - name: 'Cloud E2E Test 1/2' shard: 1 - script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=1/10 - - name: 'Cloud E2E Test 2/10' + script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=1/2 + - name: 'Cloud E2E Test 2/2' shard: 2 - script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=2/10 - - name: 'Cloud E2E Test 3/10' - shard: 3 - script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=3/10 - - name: 'Cloud E2E Test 4/10' - shard: 4 - script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=4/10 - - name: 'Cloud E2E Test 5/10' - shard: 5 - script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=5/10 + script: yarn affine @affine-test/affine-cloud e2e --forbid-only --shard=2/2 - name: 'Cloud Desktop E2E Test' shard: desktop script: | diff --git a/.github/workflows/release-desktop-platform.yml b/.github/workflows/release-desktop-platform.yml index 358d55e17..93ab1fe36 100644 --- a/.github/workflows/release-desktop-platform.yml +++ b/.github/workflows/release-desktop-platform.yml @@ -68,8 +68,6 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_RELEASE: ${{ inputs.app_version }} - MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }} - GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 6a34fd491..63a9dc51d 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -66,8 +66,6 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_RELEASE: ${{ inputs.app-version }} RELEASE_VERSION: ${{ inputs.app-version }} - MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }} - GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} - name: Upload web artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release-mobile.yml b/.github/workflows/release-mobile.yml index 8d52bad46..8ff3fed9e 100644 --- a/.github/workflows/release-mobile.yml +++ b/.github/workflows/release-mobile.yml @@ -39,8 +39,6 @@ jobs: run: yarn affine @affine/ios build env: PUBLIC_PATH: '/' - MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }} - GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: 'affine' SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -69,8 +67,6 @@ jobs: run: yarn affine @affine/android build env: PUBLIC_PATH: '/' - MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }} - GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: 'affine' SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/packages/backend/server/migrations/20260110095744_caldav_fields/migration.sql b/packages/backend/server/migrations/20260110095744_caldav_fields/migration.sql new file mode 100644 index 000000000..9f5cb8164 --- /dev/null +++ b/packages/backend/server/migrations/20260110095744_caldav_fields/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "calendar_accounts" ADD COLUMN "auth_type" VARCHAR, +ADD COLUMN "calendar_home_url" VARCHAR, +ADD COLUMN "principal_url" VARCHAR, +ADD COLUMN "provider_preset_id" VARCHAR, +ADD COLUMN "server_url" VARCHAR, +ADD COLUMN "username" VARCHAR; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 17174d0e1..94a7b020d 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -937,6 +937,12 @@ model CalendarAccount { providerAccountId String @map("provider_account_id") @db.VarChar displayName String? @map("display_name") @db.VarChar email String? @db.VarChar + providerPresetId String? @map("provider_preset_id") @db.VarChar + serverUrl String? @map("server_url") @db.VarChar + principalUrl String? @map("principal_url") @db.VarChar + calendarHomeUrl String? @map("calendar_home_url") @db.VarChar + username String? @db.VarChar + authType String? @map("auth_type") @db.VarChar accessToken String? @map("access_token") @db.Text refreshToken String? @map("refresh_token") @db.Text expiresAt DateTime? @map("expires_at") @db.Timestamptz(3) diff --git a/packages/backend/server/src/models/calendar-account.ts b/packages/backend/server/src/models/calendar-account.ts index abb17bb70..09993b056 100644 --- a/packages/backend/server/src/models/calendar-account.ts +++ b/packages/backend/server/src/models/calendar-account.ts @@ -17,6 +17,12 @@ export interface UpsertCalendarAccountInput extends CalendarAccountTokens { providerAccountId: string; displayName?: string | null; email?: string | null; + providerPresetId?: string | null; + serverUrl?: string | null; + principalUrl?: string | null; + calendarHomeUrl?: string | null; + username?: string | null; + authType?: string | null; status?: string | null; lastError?: string | null; refreshIntervalMinutes?: number | null; @@ -73,6 +79,12 @@ export class CalendarAccountModel extends BaseModel { providerAccountId: input.providerAccountId, displayName: input.displayName ?? null, email: input.email ?? null, + providerPresetId: input.providerPresetId ?? null, + serverUrl: input.serverUrl ?? null, + principalUrl: input.principalUrl ?? null, + calendarHomeUrl: input.calendarHomeUrl ?? null, + username: input.username ?? null, + authType: input.authType ?? null, accessToken: accessToken ?? null, refreshToken: refreshToken ?? null, expiresAt: input.expiresAt ?? null, @@ -85,6 +97,12 @@ export class CalendarAccountModel extends BaseModel { const updateData: Prisma.CalendarAccountUncheckedUpdateInput = { displayName: data.displayName, email: data.email, + providerPresetId: data.providerPresetId, + serverUrl: data.serverUrl, + principalUrl: data.principalUrl, + calendarHomeUrl: data.calendarHomeUrl, + username: data.username, + authType: data.authType, expiresAt: data.expiresAt, scope: data.scope, status: data.status, diff --git a/packages/backend/server/src/plugins/calendar/__tests__/caldav.spec.ts b/packages/backend/server/src/plugins/calendar/__tests__/caldav.spec.ts new file mode 100644 index 000000000..8ef84c170 --- /dev/null +++ b/packages/backend/server/src/plugins/calendar/__tests__/caldav.spec.ts @@ -0,0 +1,531 @@ +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import test from 'ava'; + +import { createModule } from '../../../__tests__/create-module'; +import { Mockers } from '../../../__tests__/mocks'; +import { CryptoHelper, GraphqlBadRequest, Mutex } from '../../../base'; +import { ConfigModule } from '../../../base/config'; +import { ServerConfigModule } from '../../../core/config'; +import { Models } from '../../../models'; +import { CalendarModule } from '..'; +import { + CalDAVProvider, + CalendarProviderFactory, + CalendarProviderName, +} from '../providers'; +import { CalendarService } from '../service'; + +const USERNAME = 'caldav-user@example.com'; +const PASSWORD = 'caldav-pass'; +const AUTH_HEADER = `Basic ${Buffer.from(`${USERNAME}:${PASSWORD}`).toString('base64')}`; + +const buildVCalendar = (lines: string[]) => + ['BEGIN:VCALENDAR', 'VERSION:2.0', ...lines, 'END:VCALENDAR'].join('\r\n'); + +const allDayEvent = buildVCalendar([ + 'BEGIN:VEVENT', + 'UID:all-day', + 'DTSTART;VALUE=DATE:20250101', + 'DTEND;VALUE=DATE:20250102', + 'SUMMARY:All Day Event', + 'END:VEVENT', +]); + +const timezoneEvent = buildVCalendar([ + 'BEGIN:VEVENT', + 'UID:tz-event', + 'DTSTART;TZID=America/Los_Angeles:20250103T090000', + 'DTEND;TZID=America/Los_Angeles:20250103T100000', + 'SUMMARY:Timezone Event', + 'END:VEVENT', +]); + +const recurrenceEvent = buildVCalendar([ + 'BEGIN:VEVENT', + 'UID:recurrence-event', + 'RECURRENCE-ID;TZID=UTC:20250104T090000', + 'DTSTART;TZID=UTC:20250104T100000', + 'DTEND;TZID=UTC:20250104T110000', + 'SUMMARY:Recurring Instance', + 'END:VEVENT', +]); + +const principalResponse = ` + + + /caldav/ + + + + /principals/user/ + + + HTTP/1.1 200 OK + + +`; + +const homeSetResponse = ` + + + /principals/user/ + + + + /calendars/user/ + + + HTTP/1.1 200 OK + + +`; + +const calendarListResponse = ` + + + /calendars/user/ + + + + + + + HTTP/1.1 200 OK + + + + /calendars/user/home/ + + + Home + + + + + BEGIN:VTIMEZONE\nTZID:UTC\nEND:VTIMEZONE + #ff0000 + + HTTP/1.1 200 OK + + +`; + +const calendarQueryResponse = ` + + + /calendars/user/home/all-day.ics + + + "1" + ${allDayEvent} + + HTTP/1.1 200 OK + + + + /calendars/user/home/timezone.ics + + + "2" + ${timezoneEvent} + + HTTP/1.1 200 OK + + + + /calendars/user/home/recurrence.ics + + + "3" + ${recurrenceEvent} + + HTTP/1.1 200 OK + + +`; + +const createCalDAVServer = async (options?: { + syncCollectionStatus?: number; +}) => { + const requests: Array<{ method: string; url: string; body: string }> = []; + const server = createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.from(chunk)); + } + const body = Buffer.concat(chunks).toString('utf-8'); + requests.push({ + method: req.method ?? '', + url: req.url ?? '', + body, + }); + + if (req.headers.authorization !== AUTH_HEADER) { + res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="CalDAV"' }); + res.end(); + return; + } + + if (req.url === '/.well-known/caldav') { + res.writeHead(302, { Location: '/caldav/' }); + res.end(); + return; + } + + if (req.method === 'PROPFIND' && req.url === '/caldav/') { + res.writeHead(207, { 'Content-Type': 'application/xml' }); + res.end(principalResponse); + return; + } + + if (req.method === 'PROPFIND' && req.url === '/principals/user/') { + res.writeHead(207, { 'Content-Type': 'application/xml' }); + res.end(homeSetResponse); + return; + } + + if (req.method === 'PROPFIND' && req.url === '/calendars/user/') { + res.writeHead(207, { 'Content-Type': 'application/xml' }); + res.end(calendarListResponse); + return; + } + + if (req.method === 'REPORT' && req.url === '/calendars/user/home/') { + if (body.includes('sync-collection')) { + const status = options?.syncCollectionStatus ?? 207; + if (status !== 207) { + res.writeHead(status); + res.end(); + return; + } + } + res.writeHead(207, { 'Content-Type': 'application/xml' }); + res.end(calendarQueryResponse); + return; + } + + res.writeHead(404); + res.end(); + }); + + await new Promise(resolve => server.listen(0, resolve)); + const port = (server.address() as AddressInfo).port; + return { + server, + baseUrl: `http://127.0.0.1:${port}`, + requests, + }; +}; + +const createRedirectServer = async () => { + const server = createServer((req, res) => { + if (req.url === '/.well-known/caldav') { + res.writeHead(302, { Location: '/.well-known/caldav' }); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + await new Promise(resolve => server.listen(0, resolve)); + const port = (server.address() as AddressInfo).port; + return { + server, + baseUrl: `http://127.0.0.1:${port}`, + }; +}; + +const createCalendarModule = async (caldavConfig: Record) => { + const module = await createModule({ + imports: [ + ServerConfigModule, + CalendarModule, + ConfigModule.override({ + calendar: { + google: { + enabled: false, + clientId: '', + clientSecret: '', + externalWebhookUrl: '', + webhookVerificationToken: '', + }, + caldav: caldavConfig, + }, + }), + ], + tapModule: builder => { + const testLock = { + fromTest: true, + async [Symbol.asyncDispose]() {}, + }; + builder.overrideProvider(Mutex).useValue({ + acquire: async () => testLock, + }); + }, + }); + module.get(CryptoHelper).onConfigInit(); + const caldavProvider = module.get(CalDAVProvider); + caldavProvider.onConfigInit(); + module.get(CalendarProviderFactory).register(caldavProvider); + return module; +}; + +test('linkCalDAVAccount discovers calendars and parses events', async t => { + const server = await createCalDAVServer(); + t.teardown(() => server.server.close()); + + const module = await createCalendarModule({ + enabled: true, + allowInsecureHttp: true, + blockPrivateNetwork: false, + providers: [ + { + id: 'test', + label: 'Test CalDAV', + serverUrl: `${server.baseUrl}/caldav/`, + authType: 'basic', + }, + ], + }); + t.teardown(() => module.close()); + + const calendarService = module.get(CalendarService); + const models = module.get(Models) as any; + const user = await module.create(Mockers.User); + + const account = await calendarService.linkCalDAVAccount({ + userId: user.id, + input: { + providerPresetId: 'test', + username: USERNAME, + password: PASSWORD, + displayName: 'Test CalDAV', + }, + }); + + const subscriptions = await models.calendarSubscription.listByAccount( + account.id + ); + t.is(subscriptions.length, 1); + + const events = await models.calendarEvent.listBySubscriptionsInRange( + [subscriptions[0].id], + new Date('2020-01-01T00:00:00.000Z'), + new Date('2030-01-01T00:00:00.000Z') + ); + t.is(events.length, 3); + + const allDay = events.find( + (event: (typeof events)[number]) => event.title === 'All Day Event' + ); + t.truthy(allDay); + t.is(allDay?.allDay, true); + t.is(allDay?.startAtUtc.toISOString(), '2025-01-01T00:00:00.000Z'); + t.is(allDay?.endAtUtc.toISOString(), '2025-01-02T00:00:00.000Z'); + t.is(allDay?.originalTimezone, 'UTC'); + + const tzEvent = events.find( + (event: (typeof events)[number]) => event.title === 'Timezone Event' + ); + t.truthy(tzEvent); + t.is(tzEvent?.originalTimezone, 'America/Los_Angeles'); + t.is(tzEvent?.startAtUtc.toISOString(), '2025-01-03T17:00:00.000Z'); + t.is(tzEvent?.endAtUtc.toISOString(), '2025-01-03T18:00:00.000Z'); + + const recurrence = events.find( + (event: (typeof events)[number]) => event.title === 'Recurring Instance' + ); + t.truthy(recurrence); + t.is(recurrence?.recurrenceId, '2025-01-04T09:00:00.000Z'); +}); + +test('syncSubscription falls back when sync-collection is rejected', async t => { + const server = await createCalDAVServer({ syncCollectionStatus: 403 }); + t.teardown(() => server.server.close()); + + const module = await createCalendarModule({ + enabled: true, + allowInsecureHttp: true, + blockPrivateNetwork: false, + providers: [ + { + id: 'test', + label: 'Test CalDAV', + serverUrl: `${server.baseUrl}/caldav/`, + authType: 'basic', + }, + ], + }); + t.teardown(() => module.close()); + + const calendarService = module.get(CalendarService); + const models = module.get(Models) as any; + const user = await module.create(Mockers.User); + + const account = await models.calendarAccount.upsert({ + userId: user.id, + provider: CalendarProviderName.CalDAV, + providerAccountId: `${server.baseUrl}/principals/user/`, + displayName: 'Test', + email: USERNAME, + accessToken: PASSWORD, + refreshToken: null, + expiresAt: null, + scope: null, + status: 'active', + lastError: null, + providerPresetId: 'test', + serverUrl: server.baseUrl, + principalUrl: `${server.baseUrl}/principals/user/`, + calendarHomeUrl: `${server.baseUrl}/calendars/user/`, + username: USERNAME, + authType: 'basic', + }); + + const subscription = await models.calendarSubscription.upsert({ + accountId: account.id, + provider: CalendarProviderName.CalDAV, + externalCalendarId: `${server.baseUrl}/calendars/user/home/`, + displayName: 'Home', + timezone: 'UTC', + color: null, + enabled: true, + }); + + await models.calendarSubscription.updateSync(subscription.id, { + syncToken: 'stale-token', + }); + + await calendarService.syncSubscription(subscription.id); + + t.true( + server.requests.some( + request => + request.method === 'REPORT' && + request.url === '/calendars/user/home/' && + request.body.includes('sync-collection') + ) + ); + + const updatedSubscription = await models.calendarSubscription.get( + subscription.id + ); + t.is(updatedSubscription?.syncToken, null); + + const events = await models.calendarEvent.listBySubscriptionsInRange( + [subscription.id], + new Date('2020-01-01T00:00:00.000Z'), + new Date('2030-01-01T00:00:00.000Z') + ); + t.is(events.length, 3); +}); + +test('linkCalDAVAccount blocks private network hosts', async t => { + const module = await createCalendarModule({ + enabled: true, + allowInsecureHttp: true, + blockPrivateNetwork: true, + providers: [ + { + id: 'blocked', + label: 'Blocked CalDAV', + serverUrl: 'http://127.0.0.1:1/caldav/', + }, + ], + }); + t.teardown(() => module.close()); + + const calendarService = module.get(CalendarService); + const user = await module.create(Mockers.User); + + const error = await t.throwsAsync(async () => { + await calendarService.linkCalDAVAccount({ + userId: user.id, + input: { + providerPresetId: 'blocked', + username: USERNAME, + password: PASSWORD, + displayName: null, + }, + }); + }); + + t.true(error instanceof GraphqlBadRequest); + t.is((error as GraphqlBadRequest).data?.code, 'caldav_private_network'); +}); + +test('linkCalDAVAccount enforces allowed hosts', async t => { + const module = await createCalendarModule({ + enabled: true, + providers: [ + { + id: 'blocked', + label: 'Blocked CalDAV', + serverUrl: 'https://blocked.example.com/caldav/', + }, + ], + allowedHosts: ['allowed.com'], + }); + t.teardown(() => module.close()); + + const calendarService = module.get(CalendarService); + const user = await module.create(Mockers.User); + + const error = await t.throwsAsync(async () => { + await calendarService.linkCalDAVAccount({ + userId: user.id, + input: { + providerPresetId: 'blocked', + username: USERNAME, + password: PASSWORD, + displayName: null, + }, + }); + }); + + t.true(error instanceof GraphqlBadRequest); + t.is((error as GraphqlBadRequest).data?.code, 'caldav_host_blocked'); +}); + +test('linkCalDAVAccount enforces redirect limits', async t => { + const server = await createRedirectServer(); + t.teardown(() => server.server.close()); + + const module = await createCalendarModule({ + enabled: true, + allowInsecureHttp: true, + blockPrivateNetwork: false, + maxRedirects: 0, + providers: [ + { + id: 'redirect', + label: 'Redirect CalDAV', + serverUrl: `${server.baseUrl}/caldav/`, + authType: 'basic', + }, + ], + }); + t.teardown(() => module.close()); + + const calendarService = module.get(CalendarService); + const user = await module.create(Mockers.User); + + const error = await t.throwsAsync(async () => { + await calendarService.linkCalDAVAccount({ + userId: user.id, + input: { + providerPresetId: 'redirect', + username: USERNAME, + password: PASSWORD, + displayName: null, + }, + }); + }); + + t.true(error instanceof GraphqlBadRequest); + t.is((error as GraphqlBadRequest).data?.code, 'caldav_max_redirects'); +}); diff --git a/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts b/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts index 8564e74f2..b08f266cc 100644 --- a/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts +++ b/packages/backend/server/src/plugins/calendar/__tests__/service.spec.ts @@ -21,6 +21,7 @@ import { CalendarSyncTokenInvalid, } from '../providers'; import type { + CalendarProviderListCalendarsParams, CalendarProviderListEventsParams, CalendarProviderStopParams, CalendarProviderWatchParams, @@ -46,7 +47,7 @@ class MockCalendarProvider extends CalendarProvider { return { providerAccountId: 'mock-account' }; } - override async listCalendars(_accessToken: string) { + override async listCalendars(_params: CalendarProviderListCalendarsParams) { return []; } diff --git a/packages/backend/server/src/plugins/calendar/config.ts b/packages/backend/server/src/plugins/calendar/config.ts index ab4253685..8ee887201 100644 --- a/packages/backend/server/src/plugins/calendar/config.ts +++ b/packages/backend/server/src/plugins/calendar/config.ts @@ -10,10 +10,33 @@ export interface CalendarGoogleConfig { webhookVerificationToken?: string; } +export type CalendarCalDAVAuthType = 'auto' | 'basic' | 'digest'; + +export interface CalendarCalDAVProviderPreset { + id: string; + label: string; + serverUrl: string; + authType?: CalendarCalDAVAuthType; + requiresAppPassword?: boolean; + docsUrl?: string; +} + +export interface CalendarCalDAVConfig { + enabled: boolean; + allowCustomProvider?: boolean; + providers: CalendarCalDAVProviderPreset[]; + allowInsecureHttp?: boolean; + allowedHosts?: string[]; + blockPrivateNetwork?: boolean; + requestTimeoutMs?: number; + maxRedirects?: number; +} + declare global { interface AppConfigSchema { calendar: { google: ConfigItem; + caldav: ConfigItem; }; } } @@ -29,6 +52,33 @@ const schema: JSONSchema = { }, }; +const caldavSchema: JSONSchema = { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + allowCustomProvider: { type: 'boolean' }, + providers: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + label: { type: 'string' }, + serverUrl: { type: 'string' }, + authType: { type: 'string' }, + requiresAppPassword: { type: 'boolean' }, + docsUrl: { type: 'string' }, + }, + }, + }, + allowInsecureHttp: { type: 'boolean' }, + allowedHosts: { type: 'array', items: { type: 'string' } }, + blockPrivateNetwork: { type: 'boolean' }, + requestTimeoutMs: { type: 'number' }, + maxRedirects: { type: 'number' }, + }, +}; + defineModuleConfig('calendar', { google: { desc: 'Google Calendar integration config', @@ -54,4 +104,37 @@ defineModuleConfig('calendar', { }), link: 'https://developers.google.com/calendar/api/guides/push', }, + caldav: { + desc: 'CalDAV integration config', + default: { + enabled: false, + allowCustomProvider: false, + providers: [], + allowInsecureHttp: false, + allowedHosts: [], + blockPrivateNetwork: true, + requestTimeoutMs: 10_000, + maxRedirects: 5, + }, + schema: caldavSchema, + shape: z.object({ + enabled: z.boolean(), + allowCustomProvider: z.boolean().optional(), + providers: z.array( + z.object({ + id: z.string(), + label: z.string(), + serverUrl: z.string().url(), + authType: z.enum(['auto', 'basic', 'digest']).optional(), + requiresAppPassword: z.boolean().optional(), + docsUrl: z.string().url().optional(), + }) + ), + allowInsecureHttp: z.boolean().optional(), + allowedHosts: z.array(z.string()).optional(), + blockPrivateNetwork: z.boolean().optional(), + requestTimeoutMs: z.number().int().positive().optional(), + maxRedirects: z.number().int().nonnegative().optional(), + }), + }, }); diff --git a/packages/backend/server/src/plugins/calendar/controller.ts b/packages/backend/server/src/plugins/calendar/controller.ts index 2977cae97..3ae2dceb8 100644 --- a/packages/backend/server/src/plugins/calendar/controller.ts +++ b/packages/backend/server/src/plugins/calendar/controller.ts @@ -42,7 +42,7 @@ export class CalendarController { throw new MissingOauthQueryParameter({ name: 'provider' }); } - if (!this.calendar.isProviderAvailable(providerName)) { + if (!this.calendar.isProviderAvailableFor(providerName, { oauth: true })) { throw new UnknownOauthProvider({ name: providerName }); } @@ -157,7 +157,8 @@ export class CalendarController { private getCallbackErrorMessage(error: unknown) { if (error instanceof CalendarProviderRequestError) { - if (error.status === 403) { + const status = error.data?.status ?? error.status; + if (status === 403) { return 'Calendar authorization failed: insufficient permissions. Please reauthorize and allow Calendar access.'; } return 'Calendar authorization failed. Please try again.'; diff --git a/packages/backend/server/src/plugins/calendar/providers/caldav.ts b/packages/backend/server/src/plugins/calendar/providers/caldav.ts new file mode 100644 index 000000000..dc9c305c0 --- /dev/null +++ b/packages/backend/server/src/plugins/calendar/providers/caldav.ts @@ -0,0 +1,1256 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { Injectable } from '@nestjs/common'; +import { XMLParser } from 'fast-xml-parser'; +import { escape } from 'lodash-es'; + +import { + assertSsrFSafeUrl, + CalendarProviderRequestError, + GraphqlBadRequest, + SsrfBlockedError, +} from '../../../base'; +import type { + CalendarCalDAVAuthType, + CalendarCalDAVProviderPreset, +} from '../config'; +import { + CalendarProvider, + CalendarProviderCalendar, + CalendarProviderEvent, + CalendarProviderEventTime, + CalendarProviderListCalendarsParams, + CalendarProviderListEventsParams, + CalendarProviderListEventsResult, + CalendarProviderName, +} from './def'; +import { CalendarSyncTokenInvalid } from './google'; + +const XML_PARSER = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '', + removeNSPrefix: true, + textNodeName: 'text', +}); + +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_REDIRECTS = 5; +const CALDAV_ALLOWED_PROTOCOLS = new Set(['https:']); +const CALDAV_ALLOWED_PROTOCOLS_INSECURE = new Set(['http:', 'https:']); + +type CalDAVCredentials = { + username: string; + password: string; +}; + +type CalDAVDiscoveryResult = { + providerAccountId: string; + serverUrl: string; + principalUrl: string; + calendarHomeUrl: string; + authType?: CalendarCalDAVAuthType | null; +}; + +type DigestChallenge = { + realm?: string; + nonce?: string; + qop?: string; + algorithm?: string; + opaque?: string; +}; + +const toArray = (value?: T | T[]): T[] => { + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; +}; + +const readText = (value: unknown): string | null => { + if (typeof value === 'string') { + return value; + } + if (value && typeof value === 'object' && 'text' in value) { + return String((value as { text?: unknown }).text ?? ''); + } + return null; +}; + +const parseStatusCode = (value: unknown): number | null => { + const text = readText(value ?? null) ?? (value ? String(value) : null); + if (!text) { + return null; + } + const match = text.match(/\s(\d{3})\s/); + return match ? Number(match[1]) : null; +}; + +const resolveHref = (href: string, baseUrl: string) => { + try { + return new URL(href, baseUrl).toString(); + } catch { + return href; + } +}; + +const formatUtcForIcal = (iso: string) => { + const date = new Date(iso); + const pad = (value: number) => String(value).padStart(2, '0'); + return [ + String(date.getUTCFullYear()), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + 'T', + pad(date.getUTCHours()), + pad(date.getUTCMinutes()), + pad(date.getUTCSeconds()), + 'Z', + ].join(''); +}; + +const isRedirectStatus = (status: number) => + [301, 302, 303, 307, 308].includes(status); + +const splitHeaderTokens = (value: string) => + value + .split(/,(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/) + .map(token => token.trim()) + .filter(Boolean); + +const parseDigestChallenge = ( + header: string | null +): DigestChallenge | null => { + if (!header) { + return null; + } + const digestMatch = header.match(/Digest\s+(.+)/i); + if (!digestMatch) { + return null; + } + const params: Record = {}; + for (const part of splitHeaderTokens(digestMatch[1])) { + const [key, ...rest] = part.split('='); + if (!key) { + continue; + } + const rawValue = rest.join('=').trim(); + const value = rawValue.replace(/^"|"$/g, ''); + params[key.toLowerCase()] = value; + } + return { + realm: params.realm, + nonce: params.nonce, + qop: params.qop, + algorithm: params.algorithm, + opaque: params.opaque, + }; +}; + +const hashString = (algorithm: string, value: string) => + createHash(algorithm).update(value).digest('hex'); + +const buildDigestAuthHeader = (params: { + challenge: DigestChallenge; + method: string; + uri: string; + username: string; + password: string; + nonceCount: number; +}) => { + const realm = params.challenge.realm ?? ''; + const nonce = params.challenge.nonce ?? ''; + const algorithmRaw = params.challenge.algorithm?.toLowerCase() ?? 'md5'; + const isSess = algorithmRaw.endsWith('-sess'); + const algorithm = algorithmRaw.startsWith('sha-256') ? 'sha256' : 'md5'; + const qopValues = params.challenge.qop + ? params.challenge.qop.split(',').map(item => item.trim()) + : []; + const qop = qopValues.includes('auth') ? 'auth' : qopValues[0]; + const cnonce = randomBytes(8).toString('hex'); + const nc = String(params.nonceCount).padStart(8, '0'); + + const ha1Raw = `${params.username}:${realm}:${params.password}`; + const ha1 = isSess + ? hashString( + algorithm, + `${hashString(algorithm, ha1Raw)}:${nonce}:${cnonce}` + ) + : hashString(algorithm, ha1Raw); + const ha2 = hashString(algorithm, `${params.method}:${params.uri}`); + + const response = qop + ? hashString(algorithm, `${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`) + : hashString(algorithm, `${ha1}:${nonce}:${ha2}`); + + const parts = [ + `username="${params.username}"`, + `realm="${realm}"`, + `nonce="${nonce}"`, + `uri="${params.uri}"`, + `response="${response}"`, + ]; + + if (params.challenge.opaque) { + parts.push(`opaque="${params.challenge.opaque}"`); + } + if (params.challenge.algorithm) { + parts.push(`algorithm=${params.challenge.algorithm}`); + } + if (qop) { + parts.push(`qop=${qop}`, `nc=${nc}`, `cnonce="${cnonce}"`); + } + + return `Digest ${parts.join(', ')}`; +}; + +const unescapeIcalText = (value: string) => + value + .replace(/\\n/gi, '\n') + .replace(/\\,/g, ',') + .replace(/\\;/g, ';') + .replace(/\\\\/g, '\\'); + +const unfoldIcalLines = (content: string) => { + const lines = content.split(/\r\n|\n|\r/); + const unfolded: string[] = []; + for (const line of lines) { + if (!line) { + continue; + } + if (line.startsWith(' ') || line.startsWith('\t')) { + const prev = unfolded.pop() ?? ''; + unfolded.push(prev + line.slice(1)); + } else { + unfolded.push(line); + } + } + return unfolded; +}; + +const parseIcalDate = (value: string) => { + const match = value.match(/^(\d{4})(\d{2})(\d{2})$/); + if (!match) { + return null; + } + return `${match[1]}-${match[2]}-${match[3]}`; +}; + +const parseIcalDateTime = (value: string) => { + const match = value.match( + /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z|[+-]\d{4})?$/ + ); + if (!match) { + return null; + } + const [, year, month, day, hour, minute, second, suffix] = match; + const date = `${year}-${month}-${day}T${hour}:${minute}:${second}`; + if (suffix === 'Z') { + return new Date(`${date}Z`); + } + if (suffix && suffix !== 'Z') { + const offset = `${suffix.slice(0, 3)}:${suffix.slice(3)}`; + return new Date(`${date}${offset}`); + } + return { date, naive: true } as const; +}; + +const getTimeZoneOffset = (date: Date, timeZone: string) => { + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + const parts = formatter.formatToParts(date); + const lookup = (type: string) => { + const part = parts.find(item => item.type === type); + return part ? Number(part.value) : 0; + }; + const asUtc = Date.UTC( + lookup('year'), + lookup('month') - 1, + lookup('day'), + lookup('hour'), + lookup('minute'), + lookup('second') + ); + return (asUtc - date.getTime()) / 60000; +}; + +const zonedTimeToUtc = (value: string, timeZone: string) => { + const match = value.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/); + if (!match) { + return null; + } + const [, year, month, day, hour, minute, second] = match; + const utcGuess = new Date( + Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second) + ) + ); + const offsetMinutes = getTimeZoneOffset(utcGuess, timeZone); + return new Date(utcGuess.getTime() - offsetMinutes * 60 * 1000); +}; + +const parseIcalTime = (value: string, tzid?: string) => { + if (value.length === 8 && /^\d{8}$/.test(value)) { + const date = parseIcalDate(value); + return date + ? ({ + date, + timeZone: tzid, + } satisfies CalendarProviderEventTime) + : null; + } + + const parsed = parseIcalDateTime(value); + if (!parsed) { + return null; + } + + if (parsed instanceof Date) { + return { + dateTime: parsed.toISOString(), + timeZone: tzid, + } satisfies CalendarProviderEventTime; + } + + if (parsed.naive && tzid) { + const utcDate = zonedTimeToUtc(parsed.date.replace(/[-:]/g, ''), tzid); + if (utcDate) { + return { + dateTime: utcDate.toISOString(), + timeZone: tzid, + } satisfies CalendarProviderEventTime; + } + } + + return { + dateTime: `${parsed.date}Z`, + timeZone: tzid, + } satisfies CalendarProviderEventTime; +}; + +const parseIcalEvents = (params: { + ical: string; + href: string; + etag?: string | null; +}) => { + const events: CalendarProviderEvent[] = []; + const lines = unfoldIcalLines(params.ical); + let current: { + props: Map< + string, + Array<{ value: string; params: Record }> + >; + } | null = null; + + const pushProp = ( + name: string, + value: string, + rawParams: Record + ) => { + if (!current) { + return; + } + const key = name.toUpperCase(); + const params = Object.fromEntries( + Object.entries(rawParams).map(([paramKey, paramValue]) => [ + paramKey.toUpperCase(), + paramValue, + ]) + ); + const list = current.props.get(key) ?? []; + list.push({ value, params }); + current.props.set(key, list); + }; + + const readProp = (name: string) => { + if (!current) { + return null; + } + return current.props.get(name.toUpperCase())?.[0] ?? null; + }; + + for (const line of lines) { + if (line === 'BEGIN:VEVENT') { + current = { props: new Map() }; + continue; + } + if (line === 'END:VEVENT') { + if (!current) { + continue; + } + const uid = readProp('UID')?.value ?? params.href; + const summary = readProp('SUMMARY')?.value; + const description = readProp('DESCRIPTION')?.value; + const location = readProp('LOCATION')?.value; + const status = readProp('STATUS')?.value?.toLowerCase(); + const dtstart = readProp('DTSTART'); + const dtend = readProp('DTEND'); + const recurrenceId = readProp('RECURRENCE-ID'); + const lastModified = readProp('LAST-MODIFIED') ?? readProp('DTSTAMP'); + + const start = dtstart + ? parseIcalTime(dtstart.value, dtstart.params.TZID) + : null; + const end = dtend ? parseIcalTime(dtend.value, dtend.params.TZID) : start; + const originalStartTime = recurrenceId + ? parseIcalTime(recurrenceId.value, recurrenceId.params.TZID) + : undefined; + const updated = lastModified + ? parseIcalDateTime(lastModified.value) + : null; + + if (start && end) { + events.push({ + id: params.href, + status: status ?? undefined, + etag: params.etag ?? undefined, + summary: summary ? unescapeIcalText(summary) : undefined, + description: description ? unescapeIcalText(description) : undefined, + location: location ? unescapeIcalText(location) : undefined, + updated: + updated && updated instanceof Date + ? updated.toISOString() + : updated && 'date' in updated + ? `${updated.date}Z` + : undefined, + recurringEventId: uid, + originalStartTime: originalStartTime ?? undefined, + start, + end, + raw: { + ical: params.ical, + href: params.href, + etag: params.etag ?? undefined, + uid, + }, + }); + } + current = null; + continue; + } + + if (!current) { + continue; + } + + const separatorIndex = line.indexOf(':'); + if (separatorIndex === -1) { + continue; + } + + const namePart = line.slice(0, separatorIndex); + const value = line.slice(separatorIndex + 1); + const [rawName, ...paramParts] = namePart.split(';'); + const lineParams = paramParts.reduce>( + (acc, part) => { + const [key, ...rest] = part.split('='); + if (!key) { + return acc; + } + acc[key] = rest.join('='); + return acc; + }, + {} + ); + + pushProp(rawName, value, lineParams); + } + + return events; +}; + +const extractCalendarTimezone = (value: unknown) => { + const text = readText(value ?? null); + if (!text) { + return undefined; + } + const match = text.match(/TZID:([^\r\n]+)/); + return match ? match[1].trim() : undefined; +}; + +const hasCalendarResourceType = (value: unknown): boolean => { + if (!value) { + return false; + } + if (Array.isArray(value)) { + return value.some(item => hasCalendarResourceType(item)); + } + if (typeof value === 'string') { + return value.toLowerCase() === 'calendar'; + } + if (typeof value === 'object') { + return 'calendar' in value; + } + return false; +}; + +const isAllowedHost = (host: string, allowedHosts: string[]) => { + const normalizedHost = host.toLowerCase(); + return allowedHosts.some(entry => { + const normalized = entry.toLowerCase(); + if (!normalized) { + return false; + } + if (normalized.startsWith('*.')) { + const suffix = normalized.slice(2); + return normalizedHost === suffix || normalizedHost.endsWith(`.${suffix}`); + } + return ( + normalizedHost === normalized || normalizedHost.endsWith(`.${normalized}`) + ); + }); +}; + +class CalDAVRequestPolicy { + constructor(private readonly config: { calendar: { caldav: any } }) {} + + private get allowInsecureHttp() { + return this.config.calendar.caldav.allowInsecureHttp ?? false; + } + + private get allowedHosts() { + return this.config.calendar.caldav.allowedHosts ?? []; + } + + private get blockPrivateNetwork() { + return this.config.calendar.caldav.blockPrivateNetwork ?? true; + } + + private get timeoutMs() { + return ( + this.config.calendar.caldav.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS + ); + } + + private get maxRedirects() { + return this.config.calendar.caldav.maxRedirects ?? DEFAULT_MAX_REDIRECTS; + } + + async fetch( + url: string, + init: RequestInit, + redirects = 0 + ): Promise { + await this.assertAllowedUrl(url); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + const response = await fetch(url, { + ...init, + redirect: 'manual', + signal: controller.signal, + }).finally(() => clearTimeout(timer)); + + if (isRedirectStatus(response.status)) { + const location = response.headers.get('location'); + if (location) { + if (redirects >= this.maxRedirects) { + throw new GraphqlBadRequest({ + code: 'caldav_max_redirects', + message: 'CalDAV request exceeded redirect limit.', + }); + } + const nextUrl = resolveHref(location, url); + return this.fetch(nextUrl, init, redirects + 1); + } + } + + return response; + } + + private async assertAllowedUrl(urlValue: string) { + let url: URL; + try { + url = new URL(urlValue); + } catch { + throw new GraphqlBadRequest({ + code: 'caldav_invalid_url', + message: 'CalDAV URL is invalid.', + }); + } + + if ( + url.protocol !== 'https:' && + !(url.protocol === 'http:' && this.allowInsecureHttp) + ) { + throw new GraphqlBadRequest({ + code: 'caldav_insecure_url', + message: 'CalDAV URL must use https.', + }); + } + + const hostname = url.hostname.toLowerCase(); + if ( + this.allowedHosts.length && + !isAllowedHost(hostname, this.allowedHosts) + ) { + throw new GraphqlBadRequest({ + code: 'caldav_host_blocked', + message: 'CalDAV host is not allowed.', + }); + } + + if (!this.blockPrivateNetwork) { + return; + } + + try { + await assertSsrFSafeUrl(url, { + allowedProtocols: this.allowInsecureHttp + ? CALDAV_ALLOWED_PROTOCOLS_INSECURE + : CALDAV_ALLOWED_PROTOCOLS, + }); + } catch (error) { + if (error instanceof SsrfBlockedError) { + const reason = String(error.data?.reason ?? ''); + + if (reason === 'blocked_ip') { + throw new GraphqlBadRequest({ + code: 'caldav_private_network', + message: 'CalDAV host is in a private network.', + }); + } + + if (reason === 'unresolvable_hostname') { + throw new GraphqlBadRequest({ + code: 'caldav_dns_failed', + message: 'Unable to resolve CalDAV host.', + }); + } + + if (reason === 'disallowed_protocol') { + throw new GraphqlBadRequest({ + code: 'caldav_insecure_url', + message: 'CalDAV URL must use https.', + }); + } + + if ( + reason === 'invalid_url' || + reason === 'blocked_hostname' || + reason === 'url_has_credentials' + ) { + throw new GraphqlBadRequest({ + code: 'caldav_invalid_url', + message: 'CalDAV URL is invalid.', + }); + } + } + + throw error; + } + } +} + +class CalDAVClient { + private nonceCount = 1; + private resolvedAuthType?: CalendarCalDAVAuthType; + + constructor( + private readonly policy: CalDAVRequestPolicy, + private readonly credentials: CalDAVCredentials, + private readonly preferredAuthType: CalendarCalDAVAuthType + ) {} + + get authType() { + return this.resolvedAuthType ?? this.preferredAuthType; + } + + async request(url: string, init: RequestInit): Promise { + const headers = new Headers(init.headers); + if (!headers.has('Authorization')) { + if (this.preferredAuthType === 'digest') { + return this.requestDigest(url, init); + } + const authType = + this.preferredAuthType === 'auto' ? 'basic' : this.preferredAuthType; + if (authType === 'basic') { + headers.set( + 'Authorization', + `Basic ${Buffer.from( + `${this.credentials.username}:${this.credentials.password}` + ).toString('base64')}` + ); + } + const response = await this.policy.fetch(url, { ...init, headers }); + if (response.status === 401 && this.preferredAuthType === 'auto') { + const challenge = parseDigestChallenge( + response.headers.get('www-authenticate') + ); + if (challenge?.nonce) { + return this.retryDigest(url, init, challenge); + } + } + if (response.ok) { + this.resolvedAuthType = authType; + } + return response; + } + + return this.policy.fetch(url, init); + } + + async requestDigest(url: string, init: RequestInit): Promise { + const response = await this.policy.fetch(url, init); + if (response.status !== 401) { + if (response.ok) { + this.resolvedAuthType = 'digest'; + } + return response; + } + + const challenge = parseDigestChallenge( + response.headers.get('www-authenticate') + ); + if (!challenge?.nonce) { + return response; + } + + return this.retryDigest(url, init, challenge); + } + + private async retryDigest( + url: string, + init: RequestInit, + challenge: DigestChallenge + ) { + const target = new URL(url); + const uri = `${target.pathname}${target.search}`; + const header = buildDigestAuthHeader({ + challenge, + method: init.method ?? 'GET', + uri, + username: this.credentials.username, + password: this.credentials.password, + nonceCount: this.nonceCount++, + }); + + const headers = new Headers(init.headers); + headers.set('Authorization', header); + const response = await this.policy.fetch(url, { ...init, headers }); + if (response.ok) { + this.resolvedAuthType = 'digest'; + } + return response; + } +} + +const parseMultistatus = (xml: string) => { + const parsed = XML_PARSER.parse(xml); + const multistatus = parsed.multistatus ?? parsed['D:multistatus'] ?? parsed; + const responses = toArray(multistatus?.response); + const syncToken = readText(multistatus?.['sync-token']) ?? undefined; + return { responses, syncToken }; +}; + +const extractHrefValue = (value: unknown, baseUrl: string) => { + if (!value) { + return null; + } + if (typeof value === 'string') { + return resolveHref(value, baseUrl); + } + if (Array.isArray(value)) { + const first = value.find(item => readText(item)); + const href = readText(first); + return href ? resolveHref(href, baseUrl) : null; + } + if (typeof value === 'object' && 'href' in (value as object)) { + const href = readText((value as { href?: unknown }).href ?? null); + return href ? resolveHref(href, baseUrl) : null; + } + const text = readText(value); + return text ? resolveHref(text, baseUrl) : null; +}; + +const getPropstat = (response: any) => { + const propstats = toArray(response?.propstat); + if (!propstats.length) { + return null; + } + for (const propstat of propstats) { + const statusCode = parseStatusCode(propstat.status); + if (!statusCode || (statusCode >= 200 && statusCode < 300)) { + return propstat.prop ?? null; + } + } + return propstats[0]?.prop ?? null; +}; + +const isNotFoundResponse = (response: any) => { + const statusCode = parseStatusCode(response?.status); + if (statusCode === 404) { + return true; + } + const propstats = toArray(response?.propstat); + return propstats.some(propstat => parseStatusCode(propstat.status) === 404); +}; + +@Injectable() +export class CalDAVProvider extends CalendarProvider { + override provider = CalendarProviderName.CalDAV; + + override get configured() { + return !!this.config?.enabled && !!this.config?.providers?.length; + } + + override get supportsOAuth() { + return false; + } + + override watchCalendar = undefined; + override stopChannel = undefined; + + override getAuthUrl(): string { + throw new GraphqlBadRequest({ + code: 'caldav_oauth_unsupported', + message: 'CalDAV does not support OAuth authorization.', + }); + } + + // eslint-disable-next-line sonarjs/no-identical-functions + override async exchangeCode(): Promise { + throw new GraphqlBadRequest({ + code: 'caldav_oauth_unsupported', + message: 'CalDAV does not support OAuth authorization.', + }); + } + + // eslint-disable-next-line sonarjs/no-identical-functions + override async refreshTokens(): Promise { + throw new GraphqlBadRequest({ + code: 'caldav_oauth_unsupported', + message: 'CalDAV does not support OAuth authorization.', + }); + } + + // eslint-disable-next-line sonarjs/no-identical-functions + override async getAccountProfile(): Promise { + throw new GraphqlBadRequest({ + code: 'caldav_oauth_unsupported', + message: 'CalDAV does not support OAuth authorization.', + }); + } + + async discoverAccount(params: { + preset: CalendarCalDAVProviderPreset; + username: string; + password: string; + }): Promise { + const policy = new CalDAVRequestPolicy({ + calendar: { caldav: this.config }, + }); + const client = new CalDAVClient( + policy, + { username: params.username, password: params.password }, + params.preset.authType ?? 'auto' + ); + + const discoveryUrl = await this.resolveDiscoveryUrl( + client, + params.preset.serverUrl + ); + const principalUrl = await this.fetchCurrentUserPrincipal( + client, + discoveryUrl + ); + const calendarHomeUrl = await this.fetchCalendarHomeSet( + client, + principalUrl, + discoveryUrl + ); + const providerAccountId = + principalUrl || `${params.username}@${new URL(discoveryUrl).hostname}`; + + return { + providerAccountId, + serverUrl: discoveryUrl, + principalUrl, + calendarHomeUrl, + authType: client.authType === 'auto' ? null : client.authType, + }; + } + + override async listCalendars( + params: CalendarProviderListCalendarsParams + ): Promise { + if (!params.account?.calendarHomeUrl) { + throw new GraphqlBadRequest({ + code: 'caldav_account_missing', + message: 'CalDAV account metadata is missing.', + }); + } + + const policy = new CalDAVRequestPolicy({ + calendar: { caldav: this.config }, + }); + const client = new CalDAVClient( + policy, + { + username: params.account.username ?? params.account.email ?? '', + password: params.accessToken, + }, + (params.account.authType as CalendarCalDAVAuthType) ?? 'auto' + ); + + const url = resolveHref( + params.account.calendarHomeUrl, + params.account.serverUrl ?? params.account.calendarHomeUrl + ); + + const body = ` + + + + + + + +`; + + const response = await client.request(url, { + method: 'PROPFIND', + headers: { + Depth: '1', + 'Content-Type': 'application/xml; charset=utf-8', + }, + body, + }); + + const text = await response.text(); + if (!response.ok) { + throw new CalendarProviderRequestError({ + status: response.status, + message: text, + }); + } + + const { responses } = parseMultistatus(text); + const calendars: CalendarProviderCalendar[] = []; + for (const item of responses) { + const href = readText(item.href ?? null); + if (!href) { + continue; + } + const prop = getPropstat(item); + const resourcetype = prop?.resourcetype; + if (!hasCalendarResourceType(resourcetype)) { + continue; + } + calendars.push({ + id: resolveHref(href, url), + summary: readText(prop?.displayname ?? null) ?? undefined, + timeZone: + extractCalendarTimezone(prop?.['calendar-timezone']) ?? undefined, + colorId: readText(prop?.['calendar-color'] ?? null) ?? undefined, + }); + } + + return calendars; + } + + override async listEvents( + params: CalendarProviderListEventsParams + ): Promise { + if (!params.account?.serverUrl) { + throw new GraphqlBadRequest({ + code: 'caldav_account_missing', + message: 'CalDAV account metadata is missing.', + }); + } + + const policy = new CalDAVRequestPolicy({ + calendar: { caldav: this.config }, + }); + const client = new CalDAVClient( + policy, + { + username: params.account.username ?? params.account.email ?? '', + password: params.accessToken, + }, + (params.account.authType as CalendarCalDAVAuthType) ?? 'auto' + ); + + const calendarUrl = resolveHref( + params.calendarId, + params.account.serverUrl + ); + + if (params.syncToken) { + try { + return await this.syncCollection(client, calendarUrl, params.syncToken); + } catch (error) { + if (error instanceof CalendarProviderRequestError) { + const status = error.data?.status ?? error.status; + if (this.shouldResetSyncToken(status)) { + throw new CalendarSyncTokenInvalid(error.message); + } + } + throw error; + } + } + + return await this.calendarQuery( + client, + calendarUrl, + params.timeMin, + params.timeMax + ); + } + + private shouldResetSyncToken(status: number) { + return [403, 404, 409, 410, 501].includes(status); + } + + private async resolveDiscoveryUrl(client: CalDAVClient, serverUrl: string) { + const wellKnownUrl = new URL('/.well-known/caldav', serverUrl).toString(); + const response = await client.request(wellKnownUrl, { method: 'GET' }); + if (response.ok) { + return response.url; + } + if ([404, 405].includes(response.status)) { + return serverUrl; + } + const text = await response.text(); + throw new CalendarProviderRequestError({ + status: response.status, + message: text, + }); + } + + private async fetchCurrentUserPrincipal( + client: CalDAVClient, + baseUrl: string + ) { + const body = ` + + + + +`; + + const response = await client.request(baseUrl, { + method: 'PROPFIND', + headers: { + Depth: '0', + 'Content-Type': 'application/xml; charset=utf-8', + }, + body, + }); + + const text = await response.text(); + if (!response.ok) { + throw new CalendarProviderRequestError({ + status: response.status, + message: text, + }); + } + + const { responses } = parseMultistatus(text); + const first = responses[0]; + const prop = getPropstat(first); + const principal = extractHrefValue( + prop?.['current-user-principal'] ?? prop?.['principal-url'], + baseUrl + ); + if (!principal) { + throw new CalendarProviderRequestError({ + status: 500, + message: 'CalDAV principal not found.', + }); + } + return principal; + } + + private async fetchCalendarHomeSet( + client: CalDAVClient, + principalUrl: string, + baseUrl: string + ) { + const body = ` + + + + +`; + + const response = await client.request(principalUrl, { + method: 'PROPFIND', + headers: { + Depth: '0', + 'Content-Type': 'application/xml; charset=utf-8', + }, + body, + }); + + const text = await response.text(); + if (!response.ok) { + throw new CalendarProviderRequestError({ + status: response.status, + message: text, + }); + } + + const { responses } = parseMultistatus(text); + const first = responses[0]; + const prop = getPropstat(first); + const home = extractHrefValue(prop?.['calendar-home-set'], baseUrl); + if (!home) { + throw new CalendarProviderRequestError({ + status: 500, + message: 'CalDAV calendar home not found.', + }); + } + return home; + } + + private async syncCollection( + client: CalDAVClient, + calendarUrl: string, + syncToken: string + ): Promise { + const body = ` + + ${escape(syncToken)} + 1 + + + + +`; + + const response = await client.request(calendarUrl, { + method: 'REPORT', + headers: { + Depth: '1', + 'Content-Type': 'application/xml; charset=utf-8', + }, + body, + }); + + const text = await response.text(); + if (!response.ok) { + throw new CalendarProviderRequestError({ + status: response.status, + message: text, + }); + } + + const { responses, syncToken: nextSyncToken } = parseMultistatus(text); + const events: CalendarProviderEvent[] = []; + + for (const item of responses) { + const href = readText(item.href ?? null); + if (!href) { + continue; + } + if (isNotFoundResponse(item)) { + events.push({ + id: resolveHref(href, calendarUrl), + status: 'cancelled', + start: { dateTime: new Date(0).toISOString() }, + end: { dateTime: new Date(0).toISOString() }, + raw: { href }, + }); + continue; + } + + const prop = getPropstat(item); + const calendarData = readText(prop?.['calendar-data'] ?? null); + if (!calendarData) { + continue; + } + const etag = readText(prop?.getetag ?? null); + events.push( + ...parseIcalEvents({ + ical: calendarData, + href: resolveHref(href, calendarUrl), + etag, + }) + ); + } + + return { events, nextSyncToken }; + } + + private async calendarQuery( + client: CalDAVClient, + calendarUrl: string, + timeMin?: string, + timeMax?: string + ): Promise { + const timeRange = + timeMin && timeMax + ? `` + : ''; + + const body = ` + + + + + + + + + ${timeRange} + + + +`; + + const response = await client.request(calendarUrl, { + method: 'REPORT', + headers: { + Depth: '1', + 'Content-Type': 'application/xml; charset=utf-8', + }, + body, + }); + + const text = await response.text(); + if (!response.ok) { + throw new CalendarProviderRequestError({ + status: response.status, + message: text, + }); + } + + const { responses } = parseMultistatus(text); + const events: CalendarProviderEvent[] = []; + + for (const item of responses) { + const href = readText(item.href ?? null); + if (!href) { + continue; + } + const prop = getPropstat(item); + const calendarData = readText(prop?.['calendar-data'] ?? null); + if (!calendarData) { + continue; + } + const etag = readText(prop?.getetag ?? null); + events.push( + ...parseIcalEvents({ + ical: calendarData, + href: resolveHref(href, calendarUrl), + etag, + }) + ); + } + + return { events }; + } +} diff --git a/packages/backend/server/src/plugins/calendar/providers/def.ts b/packages/backend/server/src/plugins/calendar/providers/def.ts index f04779849..d20995dfe 100644 --- a/packages/backend/server/src/plugins/calendar/providers/def.ts +++ b/packages/backend/server/src/plugins/calendar/providers/def.ts @@ -1,4 +1,5 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; +import type { CalendarAccount } from '@prisma/client'; import { CalendarProviderRequestError, Config, OnEvent } from '../../../base'; import { CalendarProviderFactory } from './factory'; @@ -54,11 +55,17 @@ export interface CalendarProviderEvent { export interface CalendarProviderListEventsParams { accessToken: string; calendarId: string; + account?: CalendarAccount; timeMin?: string; timeMax?: string; syncToken?: string; } +export interface CalendarProviderListCalendarsParams { + accessToken: string; + account?: CalendarAccount; +} + export interface CalendarProviderListEventsResult { events: CalendarProviderEvent[]; nextSyncToken?: string; @@ -97,7 +104,7 @@ export abstract class CalendarProvider { accessToken: string ): Promise; abstract listCalendars( - accessToken: string + params: CalendarProviderListCalendarsParams ): Promise; abstract listEvents( params: CalendarProviderListEventsParams @@ -117,12 +124,17 @@ export abstract class CalendarProvider { } get configured() { - return ( - !!this.config && - !!this.config.enabled && - !!this.config.clientId && - !!this.config.clientSecret - ); + if (!this.config || !this.config.enabled) { + return false; + } + if ('clientId' in this.config || 'clientSecret' in this.config) { + return Boolean(this.config.clientId && this.config.clientSecret); + } + return true; + } + + get supportsOAuth() { + return true; } @OnEvent('config.init') diff --git a/packages/backend/server/src/plugins/calendar/providers/google.ts b/packages/backend/server/src/plugins/calendar/providers/google.ts index ffabf5f60..3e387544c 100644 --- a/packages/backend/server/src/plugins/calendar/providers/google.ts +++ b/packages/backend/server/src/plugins/calendar/providers/google.ts @@ -4,6 +4,7 @@ import { CalendarProviderRequestError } from '../../../base'; import { CalendarProvider } from './def'; import { CalendarProviderEvent, + CalendarProviderListCalendarsParams, CalendarProviderListEventsParams, CalendarProviderListEventsResult, CalendarProviderName, @@ -171,7 +172,7 @@ export class GoogleCalendarProvider extends CalendarProvider { }; } - async listCalendars(accessToken: string) { + async listCalendars(params: CalendarProviderListCalendarsParams) { const calendars: GoogleCalendarListResponse['items'] = []; let pageToken: string | undefined; @@ -188,7 +189,7 @@ export class GoogleCalendarProvider extends CalendarProvider { url.toString(), { headers: { - Authorization: `Bearer ${accessToken}`, + Authorization: `Bearer ${params.accessToken}`, }, } ); diff --git a/packages/backend/server/src/plugins/calendar/providers/index.ts b/packages/backend/server/src/plugins/calendar/providers/index.ts index ee6d14e6c..0300c1c75 100644 --- a/packages/backend/server/src/plugins/calendar/providers/index.ts +++ b/packages/backend/server/src/plugins/calendar/providers/index.ts @@ -1,10 +1,13 @@ +import { CalDAVProvider } from './caldav'; import { GoogleCalendarProvider } from './google'; +export { CalDAVProvider } from './caldav'; export type { CalendarAccountProfile, CalendarProviderCalendar, CalendarProviderEvent, CalendarProviderEventTime, + CalendarProviderListCalendarsParams, CalendarProviderListEventsParams, CalendarProviderListEventsResult, CalendarProviderTokens, @@ -16,4 +19,4 @@ export { CalendarProvider } from './def'; export { CalendarProviderFactory } from './factory'; export { CalendarSyncTokenInvalid, GoogleCalendarProvider } from './google'; -export const CalendarProviders = [GoogleCalendarProvider]; +export const CalendarProviders = [GoogleCalendarProvider, CalDAVProvider]; diff --git a/packages/backend/server/src/plugins/calendar/resolver.ts b/packages/backend/server/src/plugins/calendar/resolver.ts index 6cad77a0b..dbba85fac 100644 --- a/packages/backend/server/src/plugins/calendar/resolver.ts +++ b/packages/backend/server/src/plugins/calendar/resolver.ts @@ -7,7 +7,7 @@ import { Resolver, } from '@nestjs/graphql'; -import { ActionForbidden, AuthenticationRequired } from '../../base'; +import { ActionForbidden, AuthenticationRequired, Config } from '../../base'; import { CurrentUser } from '../../core/auth'; import { ServerConfigType } from '../../core/config/types'; import { AccessController } from '../../core/permission'; @@ -19,8 +19,10 @@ import { CalendarProviderFactory, CalendarProviderName } from './providers'; import { CalendarService } from './service'; import { CalendarAccountObjectType, + CalendarCalDAVProviderPresetObjectType, CalendarEventObjectType, CalendarSubscriptionObjectType, + LinkCalDAVAccountInput, LinkCalendarAccountInput, UpdateWorkspaceCalendarsInput, WorkspaceCalendarObjectType, @@ -28,12 +30,29 @@ import { @Resolver(() => ServerConfigType) export class CalendarServerConfigResolver { - constructor(private readonly providerFactory: CalendarProviderFactory) {} + constructor( + private readonly providerFactory: CalendarProviderFactory, + private readonly config: Config + ) {} @ResolveField(() => [CalendarProviderName]) calendarProviders() { return this.providerFactory.providers; } + + @ResolveField(() => [CalendarCalDAVProviderPresetObjectType]) + calendarCalDAVProviders() { + const caldavConfig = this.config.calendar.caldav; + if (!caldavConfig?.enabled) { + return []; + } + return caldavConfig.providers.map(provider => ({ + id: provider.id, + label: provider.label, + requiresAppPassword: provider.requiresAppPassword ?? null, + docsUrl: provider.docsUrl ?? null, + })); + } } @Resolver(() => UserType) @@ -140,6 +159,21 @@ export class CalendarMutationResolver { return this.calendar.getAuthUrl(input.provider, state, callbackUrl); } + @Mutation(() => CalendarAccountObjectType) + async linkCalDAVAccount( + @CurrentUser() user: CurrentUser | null, + @Args('input') input: LinkCalDAVAccountInput + ) { + if (!user) { + throw new AuthenticationRequired(); + } + + return await this.calendar.linkCalDAVAccount({ + userId: user.id, + input, + }); + } + @Mutation(() => CalendarAccountObjectType, { nullable: true }) async updateCalendarAccount( @CurrentUser() user: CurrentUser, diff --git a/packages/backend/server/src/plugins/calendar/service.ts b/packages/backend/server/src/plugins/calendar/service.ts index 937abc3eb..6c4fdd687 100644 --- a/packages/backend/server/src/plugins/calendar/service.ts +++ b/packages/backend/server/src/plugins/calendar/service.ts @@ -8,10 +8,12 @@ import { addDays, subDays } from 'date-fns'; import { CalendarProviderRequestError, Config, + GraphqlBadRequest, Mutex, URLHelper, } from '../../base'; import { Models } from '../../models'; +import type { CalendarCalDAVProviderPreset } from './config'; import { CalendarProvider, CalendarProviderEvent, @@ -20,6 +22,7 @@ import { CalendarSyncTokenInvalid, } from './providers'; import { CalendarProviderFactory } from './providers'; +import type { LinkCalDAVAccountInput } from './types'; const TOKEN_REFRESH_SKEW_MS = 60 * 1000; const DEFAULT_PAST_DAYS = 90; @@ -159,6 +162,92 @@ export class CalendarService { return account; } + async linkCalDAVAccount(params: { + userId: string; + input: LinkCalDAVAccountInput; + }) { + const caldavConfig = this.config.calendar.caldav; + if (!caldavConfig?.enabled) { + throw new GraphqlBadRequest({ + code: 'caldav_disabled', + message: 'CalDAV integration is not enabled.', + }); + } + + const preset = caldavConfig.providers.find( + provider => provider.id === params.input.providerPresetId + ); + if (!preset) { + throw new GraphqlBadRequest({ + code: 'caldav_provider_not_found', + message: 'CalDAV provider is not available.', + }); + } + + const provider = this.requireProvider(CalendarProviderName.CalDAV); + if (!('discoverAccount' in provider)) { + throw new GraphqlBadRequest({ + code: 'caldav_provider_unavailable', + message: 'CalDAV provider is not configured.', + }); + } + + const discovery = await ( + provider as CalendarProvider & { + discoverAccount: (input: { + preset: CalendarCalDAVProviderPreset; + username: string; + password: string; + }) => Promise<{ + providerAccountId: string; + serverUrl: string; + principalUrl: string; + calendarHomeUrl: string; + authType?: string | null; + }>; + } + ).discoverAccount({ + preset, + username: params.input.username, + password: params.input.password, + }); + + const account = await this.models.calendarAccount.upsert({ + userId: params.userId, + provider: CalendarProviderName.CalDAV, + providerAccountId: discovery.providerAccountId, + displayName: params.input.displayName ?? null, + email: params.input.username, + accessToken: params.input.password, + refreshToken: null, + expiresAt: null, + scope: null, + status: 'active', + lastError: null, + providerPresetId: params.input.providerPresetId, + serverUrl: discovery.serverUrl, + principalUrl: discovery.principalUrl, + calendarHomeUrl: discovery.calendarHomeUrl, + username: params.input.username, + authType: discovery.authType ?? null, + }); + + try { + await this.syncAccountCalendars(account.id); + } catch (error) { + if (error instanceof CalendarProviderRequestError) { + await this.models.calendarAccount.updateStatus( + account.id, + 'invalid', + error.message + ); + } + throw error; + } + + return account; + } + async syncAccountCalendars(accountId: string) { const account = await this.models.calendarAccount.get(accountId); if (!account) { @@ -177,7 +266,10 @@ export class CalendarService { return; } - const calendars = await provider.listCalendars(accessToken); + const calendars = await provider.listCalendars({ + accessToken, + account, + }); const upserted = []; for (const calendar of calendars) { upserted.push( @@ -245,6 +337,7 @@ export class CalendarService { subscriptionId: subscription.id, calendarId: subscription.externalCalendarId, accessToken, + account, syncToken: shouldUseSyncToken ? (subscription.syncToken ?? undefined) : undefined, @@ -264,6 +357,7 @@ export class CalendarService { subscriptionId: subscription.id, calendarId: subscription.externalCalendarId, accessToken, + account, timeMin, timeMax, subscriptionTimezone: subscription.timezone ?? undefined, @@ -410,7 +504,21 @@ export class CalendarService { } isProviderAvailable(provider: CalendarProviderName) { - return !!this.providerFactory.get(provider); + return this.isProviderAvailableFor(provider); + } + + isProviderAvailableFor( + provider: CalendarProviderName, + options?: { oauth?: boolean } + ) { + const instance = this.providerFactory.get(provider); + if (!instance) { + return false; + } + if (options?.oauth) { + return instance.supportsOAuth; + } + return true; } getAuthUrl( @@ -418,7 +526,14 @@ export class CalendarService { state: string, redirectUri: string ) { - return this.requireProvider(provider).getAuthUrl(state, redirectUri); + const instance = this.requireProvider(provider); + if (!instance.supportsOAuth) { + throw new GraphqlBadRequest({ + code: 'calendar_provider_oauth_unsupported', + message: 'Selected calendar provider does not support OAuth.', + }); + } + return instance.getAuthUrl(state, redirectUri); } private async syncWithProvider(params: { @@ -426,6 +541,7 @@ export class CalendarService { subscriptionId: string; calendarId: string; accessToken: string; + account: CalendarAccount; syncToken?: string; timeMin?: string; timeMax?: string; @@ -434,6 +550,7 @@ export class CalendarService { const response = await params.provider.listEvents({ accessToken: params.accessToken, calendarId: params.calendarId, + account: params.account, syncToken: params.syncToken, timeMin: params.timeMin, timeMax: params.timeMax, @@ -632,7 +749,8 @@ export class CalendarService { private isTokenInvalidError(error: unknown) { if (error instanceof CalendarProviderRequestError) { - if (error.status === 401) { + const status = error.data?.status ?? error.status; + if (status === 401) { return true; } return error.message.includes('invalid_grant'); diff --git a/packages/backend/server/src/plugins/calendar/types.ts b/packages/backend/server/src/plugins/calendar/types.ts index 8b34a5f99..acd3e529f 100644 --- a/packages/backend/server/src/plugins/calendar/types.ts +++ b/packages/backend/server/src/plugins/calendar/types.ts @@ -76,6 +76,21 @@ export class CalendarSubscriptionObjectType { lastSyncAt?: Date | null; } +@ObjectType() +export class CalendarCalDAVProviderPresetObjectType { + @Field() + id!: string; + + @Field() + label!: string; + + @Field(() => Boolean, { nullable: true }) + requiresAppPassword?: boolean | null; + + @Field(() => String, { nullable: true }) + docsUrl?: string | null; +} + @ObjectType() export class WorkspaceCalendarItemObjectType { @Field() @@ -186,3 +201,18 @@ export class LinkCalendarAccountInput { @Field(() => String, { nullable: true }) redirectUri?: string | null; } + +@InputType() +export class LinkCalDAVAccountInput { + @Field() + providerPresetId!: string; + + @Field() + username!: string; + + @Field() + password!: string; + + @Field(() => String, { nullable: true }) + displayName?: string | null; +} diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index b12b42630..883d3eb9d 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -206,6 +206,13 @@ type CalendarAccountObjectType { updatedAt: DateTime! } +type CalendarCalDAVProviderPresetObjectType { + docsUrl: String + id: String! + label: String! + requiresAppPassword: Boolean +} + type CalendarEventObjectType { allDay: Boolean! description: String @@ -1274,6 +1281,13 @@ type LimitedUserType { hasPassword: Boolean } +input LinkCalDAVAccountInput { + displayName: String + password: String! + providerPresetId: String! + username: String! +} + input LinkCalendarAccountInput { provider: CalendarProviderType! redirectUri: String @@ -1462,6 +1476,7 @@ type Mutation { installLicense(license: Upload!, workspaceId: String!): License! inviteMembers(emails: [String!]!, workspaceId: String!): [InviteResult!]! leaveWorkspace(sendLeaveMail: Boolean @deprecated(reason: "no used anymore"), workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean! + linkCalDAVAccount(input: LinkCalDAVAccountInput!): CalendarAccountObjectType! linkCalendarAccount(input: LinkCalendarAccountInput!): String! """mention user in a doc""" @@ -2039,6 +2054,7 @@ type ServerConfigType { """server base url""" baseUrl: String! + calendarCalDAVProviders: [CalendarCalDAVProviderPresetObjectType!]! calendarProviders: [CalendarProviderType!]! """credentials requirement""" diff --git a/packages/common/graphql/src/graphql/calendar/calendar-providers.gql b/packages/common/graphql/src/graphql/calendar/calendar-providers.gql index f66017c63..2f2820024 100644 --- a/packages/common/graphql/src/graphql/calendar/calendar-providers.gql +++ b/packages/common/graphql/src/graphql/calendar/calendar-providers.gql @@ -1,5 +1,11 @@ query calendarProviders { serverConfig { + calendarCalDAVProviders { + id + label + requiresAppPassword + docsUrl + } calendarProviders } } diff --git a/packages/common/graphql/src/graphql/calendar/link-caldav-account.gql b/packages/common/graphql/src/graphql/calendar/link-caldav-account.gql new file mode 100644 index 000000000..45f8f391e --- /dev/null +++ b/packages/common/graphql/src/graphql/calendar/link-caldav-account.gql @@ -0,0 +1,15 @@ +mutation linkCalDavAccount($input: LinkCalDAVAccountInput!) { + linkCalDAVAccount(input: $input) { + id + provider + providerAccountId + displayName + email + status + lastError + refreshIntervalMinutes + calendarsCount + createdAt + updatedAt + } +} diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 1ace1d24b..0824a0751 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -656,11 +656,37 @@ export const calendarProvidersQuery = { op: 'calendarProviders', query: `query calendarProviders { serverConfig { + calendarCalDAVProviders { + id + label + requiresAppPassword + docsUrl + } calendarProviders } }`, }; +export const linkCalDavAccountMutation = { + id: 'linkCalDavAccountMutation' as const, + op: 'linkCalDavAccount', + query: `mutation linkCalDavAccount($input: LinkCalDAVAccountInput!) { + linkCalDAVAccount(input: $input) { + id + provider + providerAccountId + displayName + email + status + lastError + refreshIntervalMinutes + calendarsCount + createdAt + updatedAt + } +}`, +}; + export const linkCalendarAccountMutation = { id: 'linkCalendarAccountMutation' as const, op: 'linkCalendarAccount', diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 22a24059c..4b1f44460 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -259,6 +259,14 @@ export interface CalendarAccountObjectType { updatedAt: Scalars['DateTime']['output']; } +export interface CalendarCalDavProviderPresetObjectType { + __typename?: 'CalendarCalDAVProviderPresetObjectType'; + docsUrl: Maybe; + id: Scalars['String']['output']; + label: Scalars['String']['output']; + requiresAppPassword: Maybe; +} + export interface CalendarEventObjectType { __typename?: 'CalendarEventObjectType'; allDay: Scalars['Boolean']['output']; @@ -1466,6 +1474,13 @@ export interface LimitedUserType { hasPassword: Maybe; } +export interface LinkCalDavAccountInput { + displayName?: InputMaybe; + password: Scalars['String']['input']; + providerPresetId: Scalars['String']['input']; + username: Scalars['String']['input']; +} + export interface LinkCalendarAccountInput { provider: CalendarProviderType; redirectUri?: InputMaybe; @@ -1632,6 +1647,7 @@ export interface Mutation { installLicense: License; inviteMembers: Array; leaveWorkspace: Scalars['Boolean']['output']; + linkCalDAVAccount: CalendarAccountObjectType; linkCalendarAccount: Scalars['String']['output']; /** mention user in a doc */ mentionUser: Scalars['ID']['output']; @@ -1943,6 +1959,10 @@ export interface MutationLeaveWorkspaceArgs { workspaceName?: InputMaybe; } +export interface MutationLinkCalDavAccountArgs { + input: LinkCalDavAccountInput; +} + export interface MutationLinkCalendarAccountArgs { input: LinkCalendarAccountInput; } @@ -2699,6 +2719,7 @@ export interface ServerConfigType { availableWorkspaceFeatures: Array; /** server base url */ baseUrl: Scalars['String']['output']; + calendarCalDAVProviders: Array; calendarProviders: Array; /** credentials requirement */ credentialsRequirement: CredentialsRequirementType; @@ -3956,6 +3977,35 @@ export type CalendarProvidersQuery = { serverConfig: { __typename?: 'ServerConfigType'; calendarProviders: Array; + calendarCalDAVProviders: Array<{ + __typename?: 'CalendarCalDAVProviderPresetObjectType'; + id: string; + label: string; + requiresAppPassword: boolean | null; + docsUrl: string | null; + }>; + }; +}; + +export type LinkCalDavAccountMutationVariables = Exact<{ + input: LinkCalDavAccountInput; +}>; + +export type LinkCalDavAccountMutation = { + __typename?: 'Mutation'; + linkCalDAVAccount: { + __typename?: 'CalendarAccountObjectType'; + id: string; + provider: CalendarProviderType; + providerAccountId: string; + displayName: string | null; + email: string | null; + status: string; + lastError: string | null; + refreshIntervalMinutes: number; + calendarsCount: number; + createdAt: string; + updatedAt: string; }; }; @@ -7656,6 +7706,11 @@ export type Mutations = variables: CreateBlobUploadMutationVariables; response: CreateBlobUploadMutation; } + | { + name: 'linkCalDavAccountMutation'; + variables: LinkCalDavAccountMutationVariables; + response: LinkCalDavAccountMutation; + } | { name: 'linkCalendarAccountMutation'; variables: LinkCalendarAccountMutationVariables; diff --git a/packages/frontend/admin/src/config.json b/packages/frontend/admin/src/config.json index c766093d6..f2a0b58ce 100644 --- a/packages/frontend/admin/src/config.json +++ b/packages/frontend/admin/src/config.json @@ -288,6 +288,10 @@ "type": "Object", "desc": "Google Calendar integration config", "link": "https://developers.google.com/calendar/api/guides/push" + }, + "caldav": { + "type": "Object", + "desc": "CalDAV integration config" } }, "captcha": { diff --git a/packages/frontend/core/src/components/hooks/use-query.ts b/packages/frontend/core/src/components/hooks/use-query.ts index 6a01329e7..4aa05f164 100644 --- a/packages/frontend/core/src/components/hooks/use-query.ts +++ b/packages/frontend/core/src/components/hooks/use-query.ts @@ -12,6 +12,15 @@ import useSWR from 'swr'; import useSWRImmutable from 'swr/immutable'; import useSWRInfinite from 'swr/infinite'; +export type UseQueryConfig = Omit< + SWRConfiguration< + QueryResponse, + GraphQLError, + (options: QueryOptions) => Promise> + >, + 'fetcher' +>; + /** * A `useSWR` wrapper for sending graphql queries * @@ -32,14 +41,7 @@ import useSWRInfinite from 'swr/infinite'; */ type useQueryFn = ( options?: QueryOptions, - config?: Omit< - SWRConfiguration< - QueryResponse, - GraphQLError, - (options: QueryOptions) => Promise> - >, - 'fetcher' - > + config?: UseQueryConfig ) => SWRResponse< QueryResponse, GraphQLError, diff --git a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/integrations-panel.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/integrations-panel.css.ts index feb990704..cd77db2f5 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/integrations-panel.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/integrations-panel.css.ts @@ -130,3 +130,53 @@ export const empty = style({ color: cssVarV2.text.secondary, padding: '12px 0', }); + +export const caldavDialog = style({ + display: 'flex', + flexDirection: 'column', + gap: 20, +}); + +export const caldavField = style({ + display: 'flex', + flexDirection: 'column', + gap: 10, +}); + +export const caldavLabel = style({ + fontSize: 12, + lineHeight: '18px', + color: cssVarV2.text.secondary, +}); + +export const caldavProviderButton = style({ + width: '100%', + justifyContent: 'space-between', +}); + +export const caldavHint = style({ + display: 'flex', + gap: 6, + flexWrap: 'wrap', + fontSize: 12, + lineHeight: '18px', + color: cssVarV2.text.secondary, +}); + +export const caldavLink = style({ + color: cssVarV2.text.primary, + textDecoration: 'underline', +}); + +export const caldavError = style({ + fontSize: 12, + lineHeight: '18px', + color: cssVarV2.status.error, +}); + +export const caldavFooter = style({ + display: 'flex', + justifyContent: 'flex-end', + gap: 12, + marginTop: 12, +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/integrations-panel.tsx b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/integrations-panel.tsx index f4525659e..f1f261bad 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/integrations-panel.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/integrations-panel.tsx @@ -1,12 +1,27 @@ -import { Button, Loading, Menu, MenuItem, notify } from '@affine/component'; +import { + Button, + Input, + Loading, + Menu, + MenuItem, + Modal, + notify, +} from '@affine/component'; +import { + useQuery, + type UseQueryConfig, +} from '@affine/core/components/hooks/use-query'; import { GraphQLService } from '@affine/core/modules/cloud'; import { UrlService } from '@affine/core/modules/url'; import { UserFriendlyError } from '@affine/error'; import { type CalendarAccountsQuery, calendarAccountsQuery, + type CalendarProvidersQuery, calendarProvidersQuery, CalendarProviderType, + type GraphQLQuery, + linkCalDavAccountMutation, linkCalendarAccountMutation, unlinkCalendarAccountMutation, } from '@affine/graphql'; @@ -14,6 +29,7 @@ import { useI18n } from '@affine/i18n'; import { GoogleIcon, LinkIcon, TodayIcon } from '@blocksuite/icons/rc'; import { useService } from '@toeverything/infra'; import { + type FormEvent, type ReactNode, useCallback, useEffect, @@ -28,6 +44,10 @@ type CalendarAccount = NonNullable< CalendarAccountsQuery['currentUser'] >['calendarAccounts'][number]; +type CalendarCalDAVProvider = NonNullable< + CalendarProvidersQuery['serverConfig'] +>['calendarCalDAVProviders'][number]; + const providerMeta = { [CalendarProviderType.Google]: { label: 'Google Calendar', @@ -41,68 +61,328 @@ const providerMeta = { Record >; +const CalDAVLinkDialog = ({ + open, + providers, + onClose, + onLinked, +}: { + open: boolean; + providers: CalendarCalDAVProvider[]; + onClose: () => void; + onLinked: () => void; +}) => { + const t = useI18n(); + const gqlService = useService(GraphQLService); + const [providerId, setProviderId] = useState(null); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [errors, setErrors] = useState<{ + provider?: string; + username?: string; + password?: string; + }>({}); + + const selectedProvider = useMemo(() => { + if (providerId) { + const match = providers.find(provider => provider.id === providerId); + if (match) { + return match; + } + } + return providers[0] ?? null; + }, [providerId, providers]); + + useEffect(() => { + if (!open) return; + setProviderId(providers[0]?.id ?? null); + setUsername(''); + setPassword(''); + setDisplayName(''); + setErrors({}); + }, [open, providers]); + + const handleProviderSelect = useCallback( + (provider: CalendarCalDAVProvider) => { + setProviderId(provider.id); + setErrors(prev => ({ ...prev, provider: undefined })); + }, + [] + ); + + const handleUsernameInput = useCallback( + (event: FormEvent) => { + setUsername(event.currentTarget.value); + setErrors(prev => ({ ...prev, username: undefined })); + }, + [] + ); + + const handlePasswordInput = useCallback( + (event: FormEvent) => { + setPassword(event.currentTarget.value); + setErrors(prev => ({ ...prev, password: undefined })); + }, + [] + ); + + const handleDisplayNameInput = useCallback( + (event: FormEvent) => { + setDisplayName(event.currentTarget.value); + }, + [] + ); + + const handleSubmit = useCallback(async () => { + const nextErrors: { + provider?: string; + username?: string; + password?: string; + } = {}; + if (!selectedProvider) { + nextErrors.provider = + t['com.affine.integration.calendar.caldav.field.provider.error'](); + } + if (!username.trim()) { + nextErrors.username = + t['com.affine.integration.calendar.caldav.field.username.error'](); + } + if (!password) { + nextErrors.password = + t['com.affine.integration.calendar.caldav.field.password.error'](); + } + if (Object.keys(nextErrors).length) { + setErrors(nextErrors); + return; + } + + setSubmitting(true); + try { + await gqlService.gql({ + query: linkCalDavAccountMutation, + variables: { + input: { + providerPresetId: selectedProvider!.id, + username: username.trim(), + password, + displayName: displayName.trim() || null, + }, + }, + }); + onLinked(); + onClose(); + } catch (error) { + const message = + error instanceof UserFriendlyError ? error.message : String(error); + notify.error({ + title: t['com.affine.integration.calendar.caldav.link.failed'](), + message: message || undefined, + }); + } finally { + setSubmitting(false); + } + }, [ + displayName, + gqlService, + onClose, + onLinked, + password, + selectedProvider, + t, + username, + ]); + + return ( + { + if (!nextOpen) onClose(); + }} + contentOptions={{ className: styles.caldavDialog }} + > +
+
+ {t['com.affine.integration.calendar.caldav.field.provider']()} +
+ ( + handleProviderSelect(provider)} + > + {provider.label} + + ))} + contentOptions={{ align: 'start' }} + > + + + {errors.provider ? ( +
{errors.provider}
+ ) : null} + {selectedProvider?.requiresAppPassword ? ( +
+ {t['com.affine.integration.calendar.caldav.hint.app-password']()} + {selectedProvider.docsUrl ? ( + + {t['com.affine.integration.calendar.caldav.hint.learn-more']()} + + ) : null} +
+ ) : selectedProvider?.docsUrl ? ( + + ) : null} +
+
+
+ {t['com.affine.integration.calendar.caldav.field.username']()} +
+ + {errors.username ? ( +
{errors.username}
+ ) : null} +
+
+
+ {t['com.affine.integration.calendar.caldav.field.password']()} +
+ + {errors.password ? ( +
{errors.password}
+ ) : null} +
+
+
+ {t['com.affine.integration.calendar.caldav.field.displayName']()} +
+ +
+
+ + +
+
+ ); +}; + export const IntegrationsPanel = () => { const t = useI18n(); const gqlService = useService(GraphQLService); const urlService = useService(UrlService); - const [accounts, setAccounts] = useState([]); - const [providers, setProviders] = useState([]); - const [loading, setLoading] = useState(true); const [linking, setLinking] = useState(false); const [unlinkingAccountId, setUnlinkingAccountId] = useState( null ); const [openedExternalWindow, setOpenedExternalWindow] = useState(false); - - const revalidate = useCallback( - async (signal?: AbortSignal) => { - setLoading(true); - try { - const [accountsData, providersData] = await Promise.all([ - gqlService.gql({ - query: calendarAccountsQuery, - context: { signal }, - }), - gqlService.gql({ - query: calendarProvidersQuery, - context: { signal }, - }), - ]); - setAccounts(accountsData.currentUser?.calendarAccounts ?? []); - setProviders(providersData.serverConfig.calendarProviders ?? []); - } catch (error) { - if ( - signal?.aborted || - (error instanceof UserFriendlyError && error.is('REQUEST_ABORTED')) - ) { - return; - } - - notify.error({ - title: 'Failed to load calendar accounts', - message: String(error) || undefined, - }); - } finally { - setLoading(false); - } - }, - [gqlService] + const [caldavDialogOpen, setCaldavDialogOpen] = useState(false); + const makeConfig: ( + title: string + ) => UseQueryConfig = useCallback( + title => ({ + suspense: false, + revalidateOnFocus: openedExternalWindow, + onError: error => { + notify.error({ title, message: String(error) || undefined }); + }, + }), + [openedExternalWindow] ); - useEffect(() => { - const controller = new AbortController(); - revalidate(controller.signal).catch(() => undefined); - return () => controller.abort(); - }, [revalidate]); + const { + data: accountsData, + isLoading: accountsLoading, + mutate: mutateAccounts, + } = useQuery( + { query: calendarAccountsQuery }, + useMemo( + () => + makeConfig(t['com.affine.integration.calendar.account.load-error']()), + [makeConfig, t] + ) + ); - useEffect(() => { - if (!openedExternalWindow) return; - const handleFocus = () => { - revalidate().catch(() => undefined); - }; - window.addEventListener('focus', handleFocus); - return () => window.removeEventListener('focus', handleFocus); - }, [openedExternalWindow, revalidate]); + const { + data: providersData, + isLoading: providersLoading, + mutate: mutateProviders, + } = useQuery( + { query: calendarProvidersQuery }, + + useMemo( + () => + makeConfig(t['com.affine.integration.calendar.provider.load-error']()), + [makeConfig, t] + ) + ); + + const accounts: CalendarAccount[] = + accountsData?.currentUser?.calendarAccounts ?? []; + const providers = useMemo( + () => providersData?.serverConfig.calendarProviders ?? [], + [providersData] + ); + const caldavProviders = + providersData?.serverConfig.calendarCalDAVProviders ?? []; + const loading = accountsLoading || providersLoading; const providerOptions = useMemo(() => { return providers.map(provider => { @@ -117,6 +397,11 @@ export const IntegrationsPanel = () => { const handleLink = useCallback( async (provider: CalendarProviderType) => { + if (provider === CalendarProviderType.CalDAV) { + setCaldavDialogOpen(true); + return; + } + setLinking(true); try { const data = await gqlService.gql({ @@ -131,12 +416,14 @@ export const IntegrationsPanel = () => { urlService.openExternal(data.linkCalendarAccount); setOpenedExternalWindow(true); } catch (error) { - notify.error({ title: 'Failed to start calendar authorization' }); + notify.error({ + title: t['com.affine.integration.calendar.auth.start-error'](), + }); } finally { setLinking(false); } }, - [gqlService, urlService] + [gqlService, t, urlService] ); const handleUnlink = useCallback( @@ -149,104 +436,147 @@ export const IntegrationsPanel = () => { accountId, }, }); - setAccounts(prev => prev.filter(account => account.id !== accountId)); + await mutateAccounts( + current => { + if (!current?.currentUser) { + return current; + } + return { + ...current, + currentUser: { + ...current.currentUser, + calendarAccounts: current.currentUser.calendarAccounts.filter( + account => account.id !== accountId + ), + }, + }; + }, + { + revalidate: false, + } + ); } catch (error) { - notify.error({ title: 'Failed to unlink calendar account' }); + notify.error({ + title: t['com.affine.integration.calendar.account.unlink-error'](), + }); } finally { setUnlinkingAccountId(null); } }, - [gqlService] + [gqlService, mutateAccounts, t] ); return ( - -
-
-
- - {t['com.affine.integration.calendar.name']()} -
- {providerOptions.length ? ( - ( - void handleLink(option.provider)} - > - {option.label} - - ))} - contentOptions={{ align: 'end' }} - > - + + ) : ( + - + )} +
+ + {loading ? ( +
+ +
+ ) : accounts.length ? ( +
+ {accounts.map(account => { + const meta = providerMeta[account.provider]; + const title = + account.displayName ?? account.email ?? account.id; + const subtitle = account.displayName ? account.email : null; + const showStatus = + account.status !== 'active' || Boolean(account.lastError); + const statusMessage = account.lastError + ? t['com.affine.integration.calendar.account.status.failed']({ + error: account.lastError, + }) + : t[ + 'com.affine.integration.calendar.account.status.failed-reconnect' + ](); + + return ( +
+
+
+ {meta?.icon ?? } +
+
+
{title}
+
+ {subtitle ? {subtitle} : null} + + {t['com.affine.integration.calendar.account.count']( + { count: String(account.calendarsCount) } + )} + +
+ {showStatus ? ( +
+ + {statusMessage} +
+ ) : null} +
+
+
+ +
+
+ ); + })} +
) : ( - +
+ {t['com.affine.integration.calendar.account.linked-empty']()} +
)}
- - {loading ? ( -
- -
- ) : accounts.length ? ( -
- {accounts.map(account => { - const meta = providerMeta[account.provider]; - const title = account.displayName ?? account.email ?? account.id; - const subtitle = account.displayName ? account.email : null; - const showStatus = - account.status !== 'active' || Boolean(account.lastError); - - return ( -
-
-
- {meta?.icon ?? } -
-
-
{title}
-
- {subtitle ? {subtitle} : null} - - {account.calendarsCount} calendar - {account.calendarsCount === 1 ? '' : 's'} - -
- {showStatus ? ( -
- - Authorization failed. Please reconnect your account. -
- ) : null} -
-
-
- -
-
- ); - })} -
- ) : ( -
No calendar accounts linked yet.
- )} - -
+ + ); }; diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 33851f051..913a21231 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -1,27 +1,27 @@ { - "ar": 98, - "ca": 99, + "ar": 97, + "ca": 98, "da": 4, - "de": 99, - "el-GR": 98, + "de": 98, + "el-GR": 97, "en": 100, - "es-AR": 98, - "es-CL": 99, - "es": 98, - "fa": 98, - "fr": 99, + "es-AR": 97, + "es-CL": 98, + "es": 97, + "fa": 97, + "fr": 98, "hi": 2, - "it-IT": 99, + "it-IT": 98, "it": 1, - "ja": 98, - "ko": 99, + "ja": 97, + "ko": 98, "nb-NO": 48, - "pl": 100, - "pt-BR": 98, - "ru": 99, - "sv-SE": 98, - "uk": 98, + "pl": 98, + "pt-BR": 97, + "ru": 98, + "sv-SE": 97, + "uk": 97, "ur": 2, - "zh-Hans": 100, - "zh-Hant": 98 + "zh-Hans": 99, + "zh-Hant": 97 } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index af1c1d9d6..790d5e730 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -8281,6 +8281,114 @@ export function useAFFiNEI18N(): { * `All day` */ ["com.affine.integration.calendar.all-day"](): string; + /** + * `Failed to load calendar accounts` + */ + ["com.affine.integration.calendar.account.load-error"](): string; + /** + * `Failed to load calendar providers` + */ + ["com.affine.integration.calendar.provider.load-error"](): string; + /** + * `Failed to start calendar authorization` + */ + ["com.affine.integration.calendar.auth.start-error"](): string; + /** + * `Failed to unlink calendar account` + */ + ["com.affine.integration.calendar.account.unlink-error"](): string; + /** + * `Unlink` + */ + ["com.affine.integration.calendar.account.unlink"](): string; + /** + * `Link` + */ + ["com.affine.integration.calendar.account.link"](): string; + /** + * `No calendar accounts linked yet.` + */ + ["com.affine.integration.calendar.account.linked-empty"](): string; + /** + * `Authorization failed: {{error}}` + */ + ["com.affine.integration.calendar.account.status.failed"](options: { + readonly error: string; + }): string; + /** + * `Authorization failed. Please reconnect your account.` + */ + ["com.affine.integration.calendar.account.status.failed-reconnect"](): string; + /** + * `{{count}} calendar` + */ + ["com.affine.integration.calendar.account.count"](options: { + readonly count: string; + }): string; + /** + * `Link CalDAV account` + */ + ["com.affine.integration.calendar.caldav.link.title"](): string; + /** + * `Failed to link CalDAV account` + */ + ["com.affine.integration.calendar.caldav.link.failed"](): string; + /** + * `Provider` + */ + ["com.affine.integration.calendar.caldav.field.provider"](): string; + /** + * `Select provider` + */ + ["com.affine.integration.calendar.caldav.field.provider.placeholder"](): string; + /** + * `Please select a provider.` + */ + ["com.affine.integration.calendar.caldav.field.provider.error"](): string; + /** + * `Username` + */ + ["com.affine.integration.calendar.caldav.field.username"](): string; + /** + * `email@example.com` + */ + ["com.affine.integration.calendar.caldav.field.username.placeholder"](): string; + /** + * `Username is required.` + */ + ["com.affine.integration.calendar.caldav.field.username.error"](): string; + /** + * `Password` + */ + ["com.affine.integration.calendar.caldav.field.password"](): string; + /** + * `Password or app-specific password` + */ + ["com.affine.integration.calendar.caldav.field.password.placeholder"](): string; + /** + * `Password is required.` + */ + ["com.affine.integration.calendar.caldav.field.password.error"](): string; + /** + * `Display name (optional)` + */ + ["com.affine.integration.calendar.caldav.field.displayName"](): string; + /** + * `My CalDAV` + */ + ["com.affine.integration.calendar.caldav.field.displayName.placeholder"](): string; + /** + * `App-specific password required.` + */ + ["com.affine.integration.calendar.caldav.hint.app-password"](): string; + /** + * `Learn more` + */ + ["com.affine.integration.calendar.caldav.hint.learn-more"](): string; + /** + * `Provider setup guide` + */ + ["com.affine.integration.calendar.caldav.hint.guide"](): string; /** * `New doc` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 8d5110535..63e3d6964 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -2078,6 +2078,32 @@ "com.affine.integration.calendar.new-url-label": "Calendar URL", "com.affine.integration.calendar.save-error": "An error occurred while saving the calendar settings", "com.affine.integration.calendar.all-day": "All day", + "com.affine.integration.calendar.account.load-error": "Failed to load calendar accounts", + "com.affine.integration.calendar.provider.load-error": "Failed to load calendar providers", + "com.affine.integration.calendar.auth.start-error": "Failed to start calendar authorization", + "com.affine.integration.calendar.account.unlink-error": "Failed to unlink calendar account", + "com.affine.integration.calendar.account.unlink": "Unlink", + "com.affine.integration.calendar.account.link": "Link", + "com.affine.integration.calendar.account.linked-empty": "No calendar accounts linked yet.", + "com.affine.integration.calendar.account.status.failed": "Authorization failed: {{error}}", + "com.affine.integration.calendar.account.status.failed-reconnect": "Authorization failed. Please reconnect your account.", + "com.affine.integration.calendar.account.count": "{{count}} calendar", + "com.affine.integration.calendar.caldav.link.title": "Link CalDAV account", + "com.affine.integration.calendar.caldav.link.failed": "Failed to link CalDAV account", + "com.affine.integration.calendar.caldav.field.provider": "Provider", + "com.affine.integration.calendar.caldav.field.provider.placeholder": "Select provider", + "com.affine.integration.calendar.caldav.field.provider.error": "Please select a provider.", + "com.affine.integration.calendar.caldav.field.username": "Username", + "com.affine.integration.calendar.caldav.field.username.placeholder": "email@example.com", + "com.affine.integration.calendar.caldav.field.username.error": "Username is required.", + "com.affine.integration.calendar.caldav.field.password": "Password", + "com.affine.integration.calendar.caldav.field.password.placeholder": "Password or app-specific password", + "com.affine.integration.calendar.caldav.field.password.error": "Password is required.", + "com.affine.integration.calendar.caldav.field.displayName": "Display name (optional)", + "com.affine.integration.calendar.caldav.field.displayName.placeholder": "My CalDAV", + "com.affine.integration.calendar.caldav.hint.app-password": "App-specific password required.", + "com.affine.integration.calendar.caldav.hint.learn-more": "Learn more", + "com.affine.integration.calendar.caldav.hint.guide": "Provider setup guide", "com.affine.integration.calendar.new-doc": "New doc", "com.affine.integration.calendar.show-events": "Show calendar events", "com.affine.integration.calendar.show-events-desc": "Enabling this setting allows you to connect your calendar events to your Journal in AFFiNE", diff --git a/tools/@types/build-config/__all.d.ts b/tools/@types/build-config/__all.d.ts index c03f816dd..d453e0430 100644 --- a/tools/@types/build-config/__all.d.ts +++ b/tools/@types/build-config/__all.d.ts @@ -37,8 +37,6 @@ declare interface BUILD_CONFIG_TYPE { CAPTCHA_SITE_KEY: string; SENTRY_DSN: string; - MIXPANEL_TOKEN: string; - GA4_MEASUREMENT_ID: string; } declare var BUILD_CONFIG: BUILD_CONFIG_TYPE; diff --git a/tools/utils/src/build-config.ts b/tools/utils/src/build-config.ts index ab4f9ffa6..fb832b588 100644 --- a/tools/utils/src/build-config.ts +++ b/tools/utils/src/build-config.ts @@ -53,8 +53,6 @@ export function getBuildConfig( linkPreviewUrl: '/api/worker/link-preview', CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY ?? '', SENTRY_DSN: process.env.SENTRY_DSN ?? '', - MIXPANEL_TOKEN: process.env.MIXPANEL_TOKEN ?? '', - GA4_MEASUREMENT_ID: process.env.GA4_MEASUREMENT_ID ?? '', }; }, get beta() {