diff --git a/README.md b/README.md index 49021ae..752f469 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,14 @@ More screenshots are [available here](#screenshots). +--- + +## π Enjoying tududi? + +Help keep it free and actively developed by [buying me a coffee](https://coff.ee/chrisveleris) β, [becoming a sponsor](https://github.com/sponsors/chrisvel), or [supporting on Patreon](https://www.patreon.com/ChrisVeleris). You can also support the project by purchasing a **hosted subscription** for a hassle-free, managed solution. Every contribution helps maintain this project and build new features! + +--- + ## π How It Works This app allows users to manage their tasks, projects, areas, notes, and tags in an organized way. Users can create tasks, projects, areas (to group projects), notes, and tags. Each task can be associated with a project, and both tasks and notes can be tagged for better organization. Projects can belong to areas and can also have multiple notes and tags. This structure helps users categorize and track their work efficiently, whether theyβre managing individual tasks, larger projects, or keeping detailed notes. @@ -59,6 +67,13 @@ For the thinking behind tududi, read: - Account linking for hybrid authentication - Simple .env-based configuration perfect for self-hosters - Automatic admin role assignment based on email domains +- **CalDAV Synchronization**: Industry-standard CalDAV protocol support for seamless task syncing: + - Bidirectional sync with CalDAV servers (Nextcloud, Baikal, and more) + - Access tasks from popular clients (tasks.org, Apple Reminders, Thunderbird, Evolution) + - Full recurring task support with RRULE + - Conflict detection and resolution + - Background automatic synchronization + - HTTP Basic Authentication for CalDAV clients ## πΊοΈ Roadmap @@ -152,6 +167,39 @@ docker run \ **Documentation:** See [docs/10-oidc-sso.md](docs/10-oidc-sso.md) for detailed setup guides and provider-specific configuration. +### CalDAV Synchronization + +Tududi supports the industry-standard CalDAV protocol, enabling seamless task synchronization with popular CalDAV clients and servers. + +**Quick Setup:** + +```bash +docker run \ + -e CALDAV_ENABLED=true \ + -e ENCRYPTION_KEY=$(openssl rand -hex 32) \ + ... +``` + +**Supported Clients:** +- **tasks.org** (Android/iOS) - Full task management with recurring tasks +- **Apple Reminders** (iOS/macOS) - Native iOS/macOS integration +- **Thunderbird** (Desktop) - Advanced task features +- **Evolution** (Linux) - Full CalDAV compatibility + +**Sync with External Servers:** + +Connect Tududi to external CalDAV servers like Nextcloud, Baikal, or other CalDAV-compatible services for bidirectional synchronization. + +**Key Features:** +- Bidirectional sync (local β remote) +- Full recurring task support with RRULE +- Conflict detection and resolution +- Background automatic synchronization +- HTTP Basic Authentication +- Encrypted password storage (AES-256-GCM) + +**Documentation:** See [docs/11-caldav-sync.md](docs/11-caldav-sync.md) for client setup guides, server configuration, and troubleshooting. + ### π Documentation For detailed setup instructions, configuration options, and getting started guides, visit: @@ -243,19 +291,6 @@ Contributions to tududi are welcome! Whether it's bug fixes, new features, docum - Translation guidelines - Pull request checklist -## π Support the Project - -If you find tududi useful, consider supporting its development: - -
- -Your support helps keep tududi free, open-source, and actively maintained. Every contribution β big or small β makes a difference! - ## π License This project is licensed under the [MIT License](LICENSE). diff --git a/backend/.env.example b/backend/.env.example index 01c190b..416b23d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -28,6 +28,9 @@ DISABLE_SCHEDULER=false DISABLE_TELEGRAM=false # Feature Flags +FF_ENABLE_BACKUPS=false +FF_ENABLE_CALENDAR=false +FF_ENABLE_HABITS=false FF_ENABLE_MCP=false # Trust proxy (REQUIRED when behind reverse proxy - nginx, Traefik, etc.) @@ -44,6 +47,11 @@ FF_ENABLE_MCP=false OIDC_ENABLED=false # BASE_URL=https://your-domain.com # Required for OIDC callbacks +# Password Authentication +# Set to false to disable password login/registration (SSO-only mode) +# Default: true (password auth enabled) +PASSWORD_AUTH_ENABLED=true + # Single provider configuration # OIDC_PROVIDER_NAME=PocketID # OIDC_PROVIDER_SLUG=pocketid @@ -61,3 +69,25 @@ OIDC_ENABLED=false # OIDC_PROVIDER_1_CLIENT_ID=xxx.apps.googleusercontent.com # OIDC_PROVIDER_1_CLIENT_SECRET=xxx # OIDC_PROVIDER_1_AUTO_PROVISION=true + +# CalDAV Synchronization +# See docs/feature-plans/01-caldav-sync.md for detailed documentation +CALDAV_ENABLED=false + +# Encryption key for storing remote calendar passwords +# If not set, falls back to TUDUDI_SESSION_SECRET +# ENCRYPTION_KEY=your-256-bit-encryption-key + +# CalDAV defaults +# CALDAV_DEFAULT_SYNC_INTERVAL=15 # Minutes +# CALDAV_MAX_RECURRING_INSTANCES=365 # Number of future instances to expand +# CALDAV_CONFLICT_RESOLUTION=last_write_wins + +# Performance settings +# CALDAV_RATE_LIMIT=60 # Requests per minute +# CALDAV_MAX_SYNC_TASKS=1000 # Max tasks per sync +# CALDAV_REQUEST_TIMEOUT=30000 # Milliseconds + +# Debugging +# CALDAV_LOG_LEVEL=info +# CALDAV_LOG_REQUESTS=false diff --git a/backend/app.js b/backend/app.js index a50a65a..6173c9b 100644 --- a/backend/app.js +++ b/backend/app.js @@ -64,21 +64,59 @@ app.use( cors({ origin: config.allowedOrigins, credentials: true, - methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + methods: [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'OPTIONS', + 'PROPFIND', + 'REPORT', + ], allowedHeaders: [ 'Authorization', 'Content-Type', 'Accept', 'X-Requested-With', + 'Depth', + 'If-Match', + 'If-None-Match', ], - exposedHeaders: ['Content-Type'], + exposedHeaders: ['Content-Type', 'ETag', 'DAV', 'Allow'], maxAge: 1728000, + preflightContinue: true, }) ); -// Body parsing -app.use(express.json({ limit: '10mb' })); -app.use(express.urlencoded({ extended: true, limit: '10mb' })); +// Body parsing (skip for CalDAV routes which need raw body access) +app.use((req, res, next) => { + const isCalDAVPath = + req.path.startsWith('/caldav/') || + req.path.startsWith('/.well-known/caldav'); + + if (isCalDAVPath) { + return next(); + } + + express.json({ limit: '10mb' })(req, res, next); +}); + +app.use((req, res, next) => { + const isCalDAVPath = + req.path.startsWith('/caldav/') || + req.path.startsWith('/.well-known/caldav'); + + if (isCalDAVPath) { + return next(); + } + + express.urlencoded({ extended: true, limit: '10mb' })(req, res, next); +}); + +// CalDAV routes (registered after conditional body parsers) +const caldavRoutes = require('./modules/caldav/routes'); +app.use(caldavRoutes); // Session configuration const sessionMiddleware = session({ @@ -331,6 +369,14 @@ async function startServer() { // Initialize task scheduler await taskScheduler.initialize(); + // Initialize CalDAV sync scheduler + const caldavSyncScheduler = require('./modules/caldav/services/sync-scheduler'); + await caldavSyncScheduler.initialize(); + + // Validate authentication configuration + const { validateAuthConfiguration } = require('./config/authConfig'); + validateAuthConfiguration(); + const server = app.listen(config.port, config.host, () => { console.log(`Server running on port ${config.port}`); console.log(`Server listening on http://localhost:${config.port}`); diff --git a/backend/config/authConfig.js b/backend/config/authConfig.js new file mode 100644 index 0000000..efee31d --- /dev/null +++ b/backend/config/authConfig.js @@ -0,0 +1,30 @@ +'use strict'; + +function isPasswordAuthEnabled() { + return process.env.PASSWORD_AUTH_ENABLED !== 'false'; +} + +function validateAuthConfiguration() { + const passwordAuthEnabled = isPasswordAuthEnabled(); + const { isOidcEnabled } = require('../modules/oidc/providerConfig'); + + if (!passwordAuthEnabled && !isOidcEnabled()) { + const { logError } = require('../services/logService'); + logError( + new Error( + 'WARNING: Both password authentication and OIDC are disabled. Users will not be able to log in!' + ), + 'Authentication Configuration Warning' + ); + } + + return { + passwordAuthEnabled, + oidcEnabled: isOidcEnabled(), + }; +} + +module.exports = { + isPasswordAuthEnabled, + validateAuthConfiguration, +}; diff --git a/backend/migrations/20260420000001-create-caldav-calendars.js b/backend/migrations/20260420000001-create-caldav-calendars.js new file mode 100644 index 0000000..70ffaed --- /dev/null +++ b/backend/migrations/20260420000001-create-caldav-calendars.js @@ -0,0 +1,105 @@ +'use strict'; + +const { safeCreateTable, safeAddIndex } = require('../utils/migration-utils'); + +module.exports = { + async up(queryInterface, Sequelize) { + await safeCreateTable(queryInterface, 'caldav_calendars', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + uid: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }, + user_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + onDelete: 'CASCADE', + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + description: { + type: Sequelize.TEXT, + allowNull: true, + }, + color: { + type: Sequelize.STRING, + allowNull: true, + }, + ctag: { + type: Sequelize.STRING, + allowNull: true, + }, + sync_token: { + type: Sequelize.STRING, + allowNull: true, + }, + enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + sync_direction: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'bidirectional', + }, + sync_interval_minutes: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 15, + }, + last_sync_at: { + type: Sequelize.DATE, + allowNull: true, + }, + last_sync_status: { + type: Sequelize.STRING, + allowNull: true, + }, + conflict_resolution: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'last_write_wins', + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + + await safeAddIndex(queryInterface, 'caldav_calendars', ['user_id'], { + name: 'caldav_calendars_user_id_idx', + }); + + await safeAddIndex(queryInterface, 'caldav_calendars', ['uid'], { + name: 'caldav_calendars_uid_idx', + unique: true, + }); + + await safeAddIndex(queryInterface, 'caldav_calendars', ['enabled'], { + name: 'caldav_calendars_enabled_idx', + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('caldav_calendars'); + }, +}; diff --git a/backend/migrations/20260420000002-create-caldav-sync-state.js b/backend/migrations/20260420000002-create-caldav-sync-state.js new file mode 100644 index 0000000..e3c677c --- /dev/null +++ b/backend/migrations/20260420000002-create-caldav-sync-state.js @@ -0,0 +1,113 @@ +'use strict'; + +const { safeCreateTable, safeAddIndex } = require('../utils/migration-utils'); + +module.exports = { + async up(queryInterface, Sequelize) { + await safeCreateTable(queryInterface, 'caldav_sync_state', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + task_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'tasks', + key: 'id', + }, + onDelete: 'CASCADE', + }, + calendar_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'caldav_calendars', + key: 'id', + }, + onDelete: 'CASCADE', + }, + etag: { + type: Sequelize.STRING, + allowNull: false, + }, + last_modified: { + type: Sequelize.DATE, + allowNull: false, + }, + last_synced_at: { + type: Sequelize.DATE, + allowNull: true, + }, + sync_status: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'synced', + }, + conflict_local_version: { + type: Sequelize.JSON, + allowNull: true, + }, + conflict_remote_version: { + type: Sequelize.JSON, + allowNull: true, + }, + conflict_detected_at: { + type: Sequelize.DATE, + allowNull: true, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + + await safeAddIndex(queryInterface, 'caldav_sync_state', ['task_id'], { + name: 'caldav_sync_state_task_id_idx', + }); + + await safeAddIndex( + queryInterface, + 'caldav_sync_state', + ['calendar_id'], + { + name: 'caldav_sync_state_calendar_id_idx', + } + ); + + await safeAddIndex(queryInterface, 'caldav_sync_state', ['etag'], { + name: 'caldav_sync_state_etag_idx', + }); + + await safeAddIndex( + queryInterface, + 'caldav_sync_state', + ['sync_status'], + { + name: 'caldav_sync_state_sync_status_idx', + } + ); + + await safeAddIndex( + queryInterface, + 'caldav_sync_state', + ['task_id', 'calendar_id'], + { + name: 'caldav_sync_state_task_calendar_unique', + unique: true, + } + ); + }, + + async down(queryInterface) { + await queryInterface.dropTable('caldav_sync_state'); + }, +}; diff --git a/backend/migrations/20260420000003-create-caldav-occurrence-overrides.js b/backend/migrations/20260420000003-create-caldav-occurrence-overrides.js new file mode 100644 index 0000000..1f01a70 --- /dev/null +++ b/backend/migrations/20260420000003-create-caldav-occurrence-overrides.js @@ -0,0 +1,109 @@ +'use strict'; + +const { safeCreateTable, safeAddIndex } = require('../utils/migration-utils'); + +module.exports = { + async up(queryInterface, Sequelize) { + await safeCreateTable(queryInterface, 'caldav_occurrence_overrides', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + parent_task_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'tasks', + key: 'id', + }, + onDelete: 'CASCADE', + }, + calendar_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'caldav_calendars', + key: 'id', + }, + onDelete: 'CASCADE', + }, + recurrence_id: { + type: Sequelize.DATE, + allowNull: false, + }, + override_name: { + type: Sequelize.TEXT, + allowNull: true, + }, + override_due_date: { + type: Sequelize.DATE, + allowNull: true, + }, + override_status: { + type: Sequelize.INTEGER, + allowNull: true, + }, + override_priority: { + type: Sequelize.INTEGER, + allowNull: true, + }, + override_note: { + type: Sequelize.TEXT, + allowNull: true, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + + await safeAddIndex( + queryInterface, + 'caldav_occurrence_overrides', + ['parent_task_id'], + { + name: 'caldav_occurrence_overrides_parent_task_id_idx', + } + ); + + await safeAddIndex( + queryInterface, + 'caldav_occurrence_overrides', + ['calendar_id'], + { + name: 'caldav_occurrence_overrides_calendar_id_idx', + } + ); + + await safeAddIndex( + queryInterface, + 'caldav_occurrence_overrides', + ['recurrence_id'], + { + name: 'caldav_occurrence_overrides_recurrence_id_idx', + } + ); + + await safeAddIndex( + queryInterface, + 'caldav_occurrence_overrides', + ['parent_task_id', 'calendar_id', 'recurrence_id'], + { + name: 'caldav_occurrence_overrides_unique', + unique: true, + } + ); + }, + + async down(queryInterface) { + await queryInterface.dropTable('caldav_occurrence_overrides'); + }, +}; diff --git a/backend/migrations/20260420000004-create-caldav-remote-calendars.js b/backend/migrations/20260420000004-create-caldav-remote-calendars.js new file mode 100644 index 0000000..d25aaf7 --- /dev/null +++ b/backend/migrations/20260420000004-create-caldav-remote-calendars.js @@ -0,0 +1,130 @@ +'use strict'; + +const { safeCreateTable, safeAddIndex } = require('../utils/migration-utils'); + +module.exports = { + async up(queryInterface, Sequelize) { + await safeCreateTable(queryInterface, 'caldav_remote_calendars', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + user_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + onDelete: 'CASCADE', + }, + local_calendar_id: { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'caldav_calendars', + key: 'id', + }, + onDelete: 'SET NULL', + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + server_url: { + type: Sequelize.STRING, + allowNull: false, + }, + calendar_path: { + type: Sequelize.STRING, + allowNull: false, + }, + username: { + type: Sequelize.STRING, + allowNull: false, + }, + password_encrypted: { + type: Sequelize.TEXT, + allowNull: false, + }, + auth_type: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'basic', + }, + enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + sync_direction: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'bidirectional', + }, + last_sync_at: { + type: Sequelize.DATE, + allowNull: true, + }, + last_sync_status: { + type: Sequelize.STRING, + allowNull: true, + }, + last_sync_error: { + type: Sequelize.TEXT, + allowNull: true, + }, + server_ctag: { + type: Sequelize.STRING, + allowNull: true, + }, + server_sync_token: { + type: Sequelize.STRING, + allowNull: true, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + + await safeAddIndex( + queryInterface, + 'caldav_remote_calendars', + ['user_id'], + { + name: 'caldav_remote_calendars_user_id_idx', + } + ); + + await safeAddIndex( + queryInterface, + 'caldav_remote_calendars', + ['local_calendar_id'], + { + name: 'caldav_remote_calendars_local_calendar_id_idx', + } + ); + + await safeAddIndex( + queryInterface, + 'caldav_remote_calendars', + ['enabled'], + { + name: 'caldav_remote_calendars_enabled_idx', + } + ); + }, + + async down(queryInterface) { + await queryInterface.dropTable('caldav_remote_calendars'); + }, +}; diff --git a/backend/migrations/20260420000005-add-caldav-indexes.js b/backend/migrations/20260420000005-add-caldav-indexes.js new file mode 100644 index 0000000..384dd96 --- /dev/null +++ b/backend/migrations/20260420000005-add-caldav-indexes.js @@ -0,0 +1,147 @@ +const { DataTypes } = require('sequelize'); + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addIndex('caldav_calendars', ['user_id'], { + name: 'idx_caldav_calendars_user_id', + }); + + await queryInterface.addIndex('caldav_calendars', ['enabled'], { + name: 'idx_caldav_calendars_enabled', + }); + + await queryInterface.addIndex( + 'caldav_calendars', + ['user_id', 'enabled'], + { + name: 'idx_caldav_calendars_user_enabled', + } + ); + + await queryInterface.addIndex('caldav_sync_state', ['task_id'], { + name: 'idx_caldav_sync_state_task_id', + }); + + await queryInterface.addIndex('caldav_sync_state', ['calendar_id'], { + name: 'idx_caldav_sync_state_calendar_id', + }); + + await queryInterface.addIndex('caldav_sync_state', ['sync_status'], { + name: 'idx_caldav_sync_state_status', + }); + + await queryInterface.addIndex('caldav_sync_state', ['last_modified'], { + name: 'idx_caldav_sync_state_modified', + }); + + await queryInterface.addIndex( + 'caldav_occurrence_overrides', + ['parent_task_id'], + { + name: 'idx_caldav_overrides_parent', + } + ); + + await queryInterface.addIndex( + 'caldav_occurrence_overrides', + ['calendar_id'], + { + name: 'idx_caldav_overrides_calendar', + } + ); + + await queryInterface.addIndex( + 'caldav_occurrence_overrides', + ['recurrence_id'], + { + name: 'idx_caldav_overrides_recurrence', + } + ); + + await queryInterface.addIndex('caldav_remote_calendars', ['user_id'], { + name: 'idx_caldav_remote_user_id', + }); + + await queryInterface.addIndex('caldav_remote_calendars', ['enabled'], { + name: 'idx_caldav_remote_enabled', + }); + + await queryInterface.addIndex( + 'caldav_remote_calendars', + ['local_calendar_id'], + { + name: 'idx_caldav_remote_local_cal', + } + ); + + await queryInterface.addIndex('tasks', ['uid'], { + name: 'idx_tasks_uid', + unique: false, + }); + + await queryInterface.addIndex('tasks', ['updated_at'], { + name: 'idx_tasks_updated_at', + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.removeIndex( + 'caldav_calendars', + 'idx_caldav_calendars_user_id' + ); + await queryInterface.removeIndex( + 'caldav_calendars', + 'idx_caldav_calendars_enabled' + ); + await queryInterface.removeIndex( + 'caldav_calendars', + 'idx_caldav_calendars_user_enabled' + ); + + await queryInterface.removeIndex( + 'caldav_sync_state', + 'idx_caldav_sync_state_task_id' + ); + await queryInterface.removeIndex( + 'caldav_sync_state', + 'idx_caldav_sync_state_calendar_id' + ); + await queryInterface.removeIndex( + 'caldav_sync_state', + 'idx_caldav_sync_state_status' + ); + await queryInterface.removeIndex( + 'caldav_sync_state', + 'idx_caldav_sync_state_modified' + ); + + await queryInterface.removeIndex( + 'caldav_occurrence_overrides', + 'idx_caldav_overrides_parent' + ); + await queryInterface.removeIndex( + 'caldav_occurrence_overrides', + 'idx_caldav_overrides_calendar' + ); + await queryInterface.removeIndex( + 'caldav_occurrence_overrides', + 'idx_caldav_overrides_recurrence' + ); + + await queryInterface.removeIndex( + 'caldav_remote_calendars', + 'idx_caldav_remote_user_id' + ); + await queryInterface.removeIndex( + 'caldav_remote_calendars', + 'idx_caldav_remote_enabled' + ); + await queryInterface.removeIndex( + 'caldav_remote_calendars', + 'idx_caldav_remote_local_cal' + ); + + await queryInterface.removeIndex('tasks', 'idx_tasks_uid'); + await queryInterface.removeIndex('tasks', 'idx_tasks_updated_at'); + }, +}; diff --git a/backend/models/caldav_calendar.js b/backend/models/caldav_calendar.js new file mode 100644 index 0000000..9403ed0 --- /dev/null +++ b/backend/models/caldav_calendar.js @@ -0,0 +1,156 @@ +const { DataTypes } = require('sequelize'); +const { uid } = require('../utils/uid'); + +module.exports = (sequelize) => { + const CalDAVCalendar = sequelize.define( + 'CalDAVCalendar', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + uid: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + defaultValue: uid, + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + validate: { + notEmpty: { + msg: 'Calendar name is required', + }, + }, + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + }, + color: { + type: DataTypes.STRING, + allowNull: true, + }, + ctag: { + type: DataTypes.STRING, + allowNull: true, + }, + sync_token: { + type: DataTypes.STRING, + allowNull: true, + }, + enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + sync_direction: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'bidirectional', + validate: { + isIn: { + args: [['bidirectional', 'pull_only', 'push_only']], + msg: 'Sync direction must be bidirectional, pull_only, or push_only', + }, + }, + }, + sync_interval_minutes: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 15, + validate: { + min: { + args: [5], + msg: 'Sync interval must be at least 5 minutes', + }, + }, + }, + last_sync_at: { + type: DataTypes.DATE, + allowNull: true, + }, + last_sync_status: { + type: DataTypes.STRING, + allowNull: true, + validate: { + isIn: { + args: [['success', 'error', 'conflict']], + msg: 'Sync status must be success, error, or conflict', + }, + }, + }, + conflict_resolution: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'last_write_wins', + validate: { + isIn: { + args: [ + [ + 'last_write_wins', + 'local_wins', + 'remote_wins', + 'manual', + ], + ], + msg: 'Conflict resolution must be last_write_wins, local_wins, remote_wins, or manual', + }, + }, + }, + }, + { + tableName: 'caldav_calendars', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + underscored: true, + indexes: [ + { + fields: ['user_id'], + }, + { + fields: ['uid'], + unique: true, + }, + { + fields: ['enabled'], + }, + ], + } + ); + + CalDAVCalendar.associate = function (models) { + CalDAVCalendar.belongsTo(models.User, { + foreignKey: 'user_id', + as: 'user', + }); + + CalDAVCalendar.hasMany(models.CalDAVSyncState, { + foreignKey: 'calendar_id', + as: 'syncStates', + }); + + CalDAVCalendar.hasMany(models.CalDAVOccurrenceOverride, { + foreignKey: 'calendar_id', + as: 'occurrenceOverrides', + }); + + CalDAVCalendar.hasOne(models.CalDAVRemoteCalendar, { + foreignKey: 'local_calendar_id', + as: 'remoteCalendar', + }); + }; + + return CalDAVCalendar; +}; diff --git a/backend/models/caldav_occurrence_override.js b/backend/models/caldav_occurrence_override.js new file mode 100644 index 0000000..18581ee --- /dev/null +++ b/backend/models/caldav_occurrence_override.js @@ -0,0 +1,98 @@ +const { DataTypes } = require('sequelize'); + +module.exports = (sequelize) => { + const CalDAVOccurrenceOverride = sequelize.define( + 'CalDAVOccurrenceOverride', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + parent_task_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'tasks', + key: 'id', + }, + }, + calendar_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'caldav_calendars', + key: 'id', + }, + }, + recurrence_id: { + type: DataTypes.DATE, + allowNull: false, + }, + override_name: { + type: DataTypes.TEXT, + allowNull: true, + }, + override_due_date: { + type: DataTypes.DATE, + allowNull: true, + }, + override_status: { + type: DataTypes.INTEGER, + allowNull: true, + validate: { + min: 0, + max: 6, + }, + }, + override_priority: { + type: DataTypes.INTEGER, + allowNull: true, + validate: { + min: 0, + max: 2, + }, + }, + override_note: { + type: DataTypes.TEXT, + allowNull: true, + }, + }, + { + tableName: 'caldav_occurrence_overrides', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + underscored: true, + indexes: [ + { + fields: ['parent_task_id'], + }, + { + fields: ['calendar_id'], + }, + { + fields: ['recurrence_id'], + }, + { + fields: ['parent_task_id', 'calendar_id', 'recurrence_id'], + unique: true, + }, + ], + } + ); + + CalDAVOccurrenceOverride.associate = function (models) { + CalDAVOccurrenceOverride.belongsTo(models.Task, { + foreignKey: 'parent_task_id', + as: 'parentTask', + }); + + CalDAVOccurrenceOverride.belongsTo(models.CalDAVCalendar, { + foreignKey: 'calendar_id', + as: 'calendar', + }); + }; + + return CalDAVOccurrenceOverride; +}; diff --git a/backend/models/caldav_remote_calendar.js b/backend/models/caldav_remote_calendar.js new file mode 100644 index 0000000..185e5cd --- /dev/null +++ b/backend/models/caldav_remote_calendar.js @@ -0,0 +1,150 @@ +const { DataTypes } = require('sequelize'); + +module.exports = (sequelize) => { + const CalDAVRemoteCalendar = sequelize.define( + 'CalDAVRemoteCalendar', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + }, + local_calendar_id: { + type: DataTypes.INTEGER, + allowNull: true, + references: { + model: 'caldav_calendars', + key: 'id', + }, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + validate: { + notEmpty: { + msg: 'Remote calendar name is required', + }, + }, + }, + server_url: { + type: DataTypes.STRING, + allowNull: false, + validate: { + isUrl: { + msg: 'Server URL must be a valid URL', + }, + }, + }, + calendar_path: { + type: DataTypes.STRING, + allowNull: false, + }, + username: { + type: DataTypes.STRING, + allowNull: false, + validate: { + notEmpty: { + msg: 'Username is required', + }, + }, + }, + password_encrypted: { + type: DataTypes.TEXT, + allowNull: false, + }, + auth_type: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'basic', + validate: { + isIn: { + args: [['basic', 'digest', 'bearer']], + msg: 'Auth type must be basic, digest, or bearer', + }, + }, + }, + enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + sync_direction: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'bidirectional', + validate: { + isIn: { + args: [['bidirectional', 'pull_only', 'push_only']], + msg: 'Sync direction must be bidirectional, pull_only, or push_only', + }, + }, + }, + last_sync_at: { + type: DataTypes.DATE, + allowNull: true, + }, + last_sync_status: { + type: DataTypes.STRING, + allowNull: true, + validate: { + isIn: { + args: [['success', 'error', 'conflict']], + msg: 'Sync status must be success, error, or conflict', + }, + }, + }, + last_sync_error: { + type: DataTypes.TEXT, + allowNull: true, + }, + server_ctag: { + type: DataTypes.STRING, + allowNull: true, + }, + server_sync_token: { + type: DataTypes.STRING, + allowNull: true, + }, + }, + { + tableName: 'caldav_remote_calendars', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + underscored: true, + indexes: [ + { + fields: ['user_id'], + }, + { + fields: ['local_calendar_id'], + }, + { + fields: ['enabled'], + }, + ], + } + ); + + CalDAVRemoteCalendar.associate = function (models) { + CalDAVRemoteCalendar.belongsTo(models.User, { + foreignKey: 'user_id', + as: 'user', + }); + + CalDAVRemoteCalendar.belongsTo(models.CalDAVCalendar, { + foreignKey: 'local_calendar_id', + as: 'localCalendar', + }); + }; + + return CalDAVRemoteCalendar; +}; diff --git a/backend/models/caldav_sync_state.js b/backend/models/caldav_sync_state.js new file mode 100644 index 0000000..0d406f9 --- /dev/null +++ b/backend/models/caldav_sync_state.js @@ -0,0 +1,104 @@ +const { DataTypes } = require('sequelize'); + +module.exports = (sequelize) => { + const CalDAVSyncState = sequelize.define( + 'CalDAVSyncState', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + task_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'tasks', + key: 'id', + }, + }, + calendar_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'caldav_calendars', + key: 'id', + }, + }, + etag: { + type: DataTypes.STRING, + allowNull: false, + }, + last_modified: { + type: DataTypes.DATE, + allowNull: false, + }, + last_synced_at: { + type: DataTypes.DATE, + allowNull: true, + }, + sync_status: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'synced', + validate: { + isIn: { + args: [['synced', 'pending', 'conflict', 'error']], + msg: 'Sync status must be synced, pending, conflict, or error', + }, + }, + }, + conflict_local_version: { + type: DataTypes.JSON, + allowNull: true, + }, + conflict_remote_version: { + type: DataTypes.JSON, + allowNull: true, + }, + conflict_detected_at: { + type: DataTypes.DATE, + allowNull: true, + }, + }, + { + tableName: 'caldav_sync_state', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + underscored: true, + indexes: [ + { + fields: ['task_id'], + }, + { + fields: ['calendar_id'], + }, + { + fields: ['etag'], + }, + { + fields: ['sync_status'], + }, + { + fields: ['task_id', 'calendar_id'], + unique: true, + }, + ], + } + ); + + CalDAVSyncState.associate = function (models) { + CalDAVSyncState.belongsTo(models.Task, { + foreignKey: 'task_id', + as: 'task', + }); + + CalDAVSyncState.belongsTo(models.CalDAVCalendar, { + foreignKey: 'calendar_id', + as: 'calendar', + }); + }; + + return CalDAVSyncState; +}; diff --git a/backend/models/index.js b/backend/models/index.js index 436fb57..54c43e0 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -71,6 +71,12 @@ const Backup = require('./backup')(sequelize); const OIDCIdentity = require('./oidc_identity')(sequelize); const OIDCStateNonce = require('./oidc_state_nonce')(sequelize); const AuthAuditLog = require('./auth_audit_log')(sequelize); +const CalDAVCalendar = require('./caldav_calendar')(sequelize); +const CalDAVSyncState = require('./caldav_sync_state')(sequelize); +const CalDAVOccurrenceOverride = require('./caldav_occurrence_override')( + sequelize +); +const CalDAVRemoteCalendar = require('./caldav_remote_calendar')(sequelize); User.hasMany(Area, { foreignKey: 'user_id' }); Area.belongsTo(User, { foreignKey: 'user_id' }); @@ -198,6 +204,55 @@ OIDCIdentity.belongsTo(User, { foreignKey: 'user_id', as: 'User' }); // Auth audit log associations AuthAuditLog.belongsTo(User, { foreignKey: 'user_id', as: 'User' }); +// CalDAV associations +User.hasMany(CalDAVCalendar, { foreignKey: 'user_id', as: 'CalDAVCalendars' }); +CalDAVCalendar.belongsTo(User, { foreignKey: 'user_id', as: 'User' }); + +CalDAVCalendar.hasMany(CalDAVSyncState, { + foreignKey: 'calendar_id', + as: 'SyncStates', +}); +CalDAVSyncState.belongsTo(CalDAVCalendar, { + foreignKey: 'calendar_id', + as: 'Calendar', +}); +Task.hasMany(CalDAVSyncState, { + foreignKey: 'task_id', + as: 'CalDAVSyncStates', +}); +CalDAVSyncState.belongsTo(Task, { foreignKey: 'task_id', as: 'Task' }); + +CalDAVCalendar.hasMany(CalDAVOccurrenceOverride, { + foreignKey: 'calendar_id', + as: 'OccurrenceOverrides', +}); +CalDAVOccurrenceOverride.belongsTo(CalDAVCalendar, { + foreignKey: 'calendar_id', + as: 'Calendar', +}); +Task.hasMany(CalDAVOccurrenceOverride, { + foreignKey: 'parent_task_id', + as: 'CalDAVOccurrenceOverrides', +}); +CalDAVOccurrenceOverride.belongsTo(Task, { + foreignKey: 'parent_task_id', + as: 'ParentTask', +}); + +User.hasMany(CalDAVRemoteCalendar, { + foreignKey: 'user_id', + as: 'CalDAVRemoteCalendars', +}); +CalDAVRemoteCalendar.belongsTo(User, { foreignKey: 'user_id', as: 'User' }); +CalDAVRemoteCalendar.belongsTo(CalDAVCalendar, { + foreignKey: 'local_calendar_id', + as: 'LocalCalendar', +}); +CalDAVCalendar.hasOne(CalDAVRemoteCalendar, { + foreignKey: 'local_calendar_id', + as: 'RemoteCalendar', +}); + module.exports = { sequelize, User, @@ -221,4 +276,8 @@ module.exports = { OIDCIdentity, OIDCStateNonce, AuthAuditLog, + CalDAVCalendar, + CalDAVSyncState, + CalDAVOccurrenceOverride, + CalDAVRemoteCalendar, }; diff --git a/backend/modules/auth/controller.js b/backend/modules/auth/controller.js index 08a624c..5644f91 100644 --- a/backend/modules/auth/controller.js +++ b/backend/modules/auth/controller.js @@ -3,6 +3,7 @@ const authService = require('./service'); const { logError } = require('../../services/logService'); const { generateToken } = require('../../middleware/csrf'); +const { isPasswordAuthEnabled } = require('../../config/authConfig'); const authController = { getVersion(req, res) { @@ -18,6 +19,14 @@ const authController = { } }, + getPasswordAuthStatus(req, res, next) { + try { + res.json({ enabled: isPasswordAuthEnabled() }); + } catch (error) { + next(error); + } + }, + async register(req, res, next) { try { const { email, password } = req.body; @@ -31,6 +40,9 @@ const authController = { if (error.statusCode === 400) { return res.status(400).json({ error: error.message }); } + if (error.statusCode === 403) { + return res.status(403).json({ error: error.message }); + } if ( error.message === 'Failed to send verification email. Please try again later.' diff --git a/backend/modules/auth/routes.js b/backend/modules/auth/routes.js index 4af800e..11c86c1 100644 --- a/backend/modules/auth/routes.js +++ b/backend/modules/auth/routes.js @@ -3,11 +3,16 @@ const express = require('express'); const router = express.Router(); const authController = require('./controller'); -const { authLimiter } = require('../../middleware/rateLimiter'); +const { authLimiter, apiLimiter } = require('../../middleware/rateLimiter'); const { csrfMiddleware } = require('../../middleware/csrf'); router.get('/version', authController.getVersion); router.get('/registration-status', authController.getRegistrationStatus); +router.get( + '/password-auth-status', + apiLimiter, + authController.getPasswordAuthStatus +); router.get('/csrf-token', csrfMiddleware, authController.getCsrfToken); router.post('/register', authLimiter, authController.register); router.get('/verify-email', authLimiter, authController.verifyEmail); diff --git a/backend/modules/auth/service.js b/backend/modules/auth/service.js index 3fc3cc2..841817a 100644 --- a/backend/modules/auth/service.js +++ b/backend/modules/auth/service.js @@ -4,6 +4,7 @@ const { User, sequelize } = require('../../models'); const { isAdmin } = require('../../services/rolesService'); const { logError } = require('../../services/logService'); const { getConfig } = require('../../config/config'); +const { isPasswordAuthEnabled } = require('../../config/authConfig'); const { isRegistrationEnabled, createUnverifiedUser, @@ -31,6 +32,13 @@ class AuthService { const transaction = await sequelize.transaction(); try { + if (!isPasswordAuthEnabled()) { + await transaction.rollback(); + throw new ForbiddenError( + 'Password registration is disabled. Please use SSO to sign in.' + ); + } + if (!(await isRegistrationEnabled())) { await transaction.rollback(); throw new NotFoundError('Registration is not enabled'); @@ -149,6 +157,12 @@ class AuthService { } async login(email, password, session) { + if (!isPasswordAuthEnabled()) { + throw new ForbiddenError( + 'Password login is disabled. Please use SSO to sign in.' + ); + } + if (!email || !password) { throw new ValidationError('Invalid login parameters.'); } diff --git a/backend/modules/caldav/api/calendar-controller.js b/backend/modules/caldav/api/calendar-controller.js new file mode 100644 index 0000000..d624368 --- /dev/null +++ b/backend/modules/caldav/api/calendar-controller.js @@ -0,0 +1,211 @@ +const { AppError } = require('../../../shared/errors/AppError'); +const CalendarRepository = require('../repositories/calendar-repository'); +const SyncStateRepository = require('../repositories/sync-state-repository'); +const { uid } = require('../../../utils/uid'); + +class CalendarController { + async listCalendars(req, res) { + const userId = req.currentUser.id; + + const calendars = await CalendarRepository.findByUserId(userId); + + const calendarsWithStats = await Promise.all( + calendars.map(async (calendar) => { + const stats = await SyncStateRepository.getSyncStats( + calendar.id + ); + return { + ...calendar.toJSON(), + stats, + }; + }) + ); + + res.json(calendarsWithStats); + } + + async getCalendar(req, res) { + const { id } = req.params; + const userId = req.currentUser.id; + + const calendar = await CalendarRepository.findById(id); + + if (!calendar) { + throw new AppError('Calendar not found', 404); + } + + if (calendar.user_id !== userId) { + throw new AppError('Unauthorized access to calendar', 403); + } + + const stats = await SyncStateRepository.getSyncStats(calendar.id); + + res.json({ + ...calendar.toJSON(), + stats, + }); + } + + async createCalendar(req, res) { + const userId = req.currentUser.id; + const { + name, + description, + color, + enabled = true, + sync_direction = 'bidirectional', + sync_interval_minutes = 15, + conflict_resolution = 'last_write_wins', + } = req.body; + + if (!name) { + throw new AppError('Calendar name is required', 400); + } + + const validDirections = ['bidirectional', 'pull', 'push']; + if (!validDirections.includes(sync_direction)) { + throw new AppError( + `Invalid sync direction. Must be one of: ${validDirections.join(', ')}`, + 400 + ); + } + + const validResolutions = [ + 'last_write_wins', + 'local_wins', + 'remote_wins', + 'manual', + ]; + if (!validResolutions.includes(conflict_resolution)) { + throw new AppError( + `Invalid conflict resolution. Must be one of: ${validResolutions.join(', ')}`, + 400 + ); + } + + if ( + !Number.isInteger(sync_interval_minutes) || + sync_interval_minutes < 1 || + sync_interval_minutes > 1440 + ) { + throw new AppError( + 'Sync interval must be between 1 and 1440 minutes', + 400 + ); + } + + const calendar = await CalendarRepository.create({ + uid: uid(), + user_id: userId, + name, + description: description || null, + color: color || null, + enabled, + sync_direction, + sync_interval_minutes, + conflict_resolution, + }); + + res.status(201).json(calendar); + } + + async updateCalendar(req, res) { + const { id } = req.params; + const userId = req.currentUser.id; + + const calendar = await CalendarRepository.findById(id); + + if (!calendar) { + throw new AppError('Calendar not found', 404); + } + + if (calendar.user_id !== userId) { + throw new AppError('Unauthorized access to calendar', 403); + } + + const { + name, + description, + color, + enabled, + sync_direction, + sync_interval_minutes, + conflict_resolution, + } = req.body; + + const updates = {}; + + if (name !== undefined) updates.name = name; + if (description !== undefined) updates.description = description; + if (color !== undefined) updates.color = color; + if (enabled !== undefined) updates.enabled = enabled; + if (sync_direction !== undefined) { + const validDirections = ['bidirectional', 'pull', 'push']; + if (!validDirections.includes(sync_direction)) { + throw new AppError( + `Invalid sync direction. Must be one of: ${validDirections.join(', ')}`, + 400 + ); + } + updates.sync_direction = sync_direction; + } + if (sync_interval_minutes !== undefined) { + if ( + !Number.isInteger(sync_interval_minutes) || + sync_interval_minutes < 1 || + sync_interval_minutes > 1440 + ) { + throw new AppError( + 'Sync interval must be between 1 and 1440 minutes', + 400 + ); + } + updates.sync_interval_minutes = sync_interval_minutes; + } + if (conflict_resolution !== undefined) { + const validResolutions = [ + 'last_write_wins', + 'local_wins', + 'remote_wins', + 'manual', + ]; + if (!validResolutions.includes(conflict_resolution)) { + throw new AppError( + `Invalid conflict resolution. Must be one of: ${validResolutions.join(', ')}`, + 400 + ); + } + updates.conflict_resolution = conflict_resolution; + } + + const updatedCalendar = await CalendarRepository.update( + calendar, + updates + ); + + res.json(updatedCalendar); + } + + async deleteCalendar(req, res) { + const { id } = req.params; + const userId = req.currentUser.id; + + const calendar = await CalendarRepository.findById(id); + + if (!calendar) { + throw new AppError('Calendar not found', 404); + } + + if (calendar.user_id !== userId) { + throw new AppError('Unauthorized access to calendar', 403); + } + + await SyncStateRepository.deleteByCalendarId(calendar.id); + + await CalendarRepository.delete(calendar); + + res.status(204).send(); + } +} + +module.exports = new CalendarController(); diff --git a/backend/modules/caldav/api/remote-calendar-controller.js b/backend/modules/caldav/api/remote-calendar-controller.js new file mode 100644 index 0000000..92f914c --- /dev/null +++ b/backend/modules/caldav/api/remote-calendar-controller.js @@ -0,0 +1,388 @@ +const axios = require('axios'); +const { URL } = require('url'); +const { AppError } = require('../../../shared/errors/AppError'); +const RemoteCalendarRepository = require('../repositories/remote-calendar-repository'); +const CalendarRepository = require('../repositories/calendar-repository'); +const encryptionService = require('../services/encryption-service'); + +function isPrivateOrLocalhost(hostname) { + if (!hostname) return true; + + const lower = hostname.toLowerCase(); + + if (lower === 'localhost' || lower === '127.0.0.1' || lower === '::1') { + return true; + } + + if (lower.startsWith('192.168.') || lower.startsWith('10.')) { + return true; + } + + if (lower.startsWith('172.')) { + const parts = lower.split('.'); + const second = parseInt(parts[1], 10); + if (second >= 16 && second <= 31) { + return true; + } + } + + if (lower.startsWith('169.254.')) { + return true; + } + + if ( + lower.startsWith('[::1]') || + lower.startsWith('[fc') || + lower.startsWith('[fd') + ) { + return true; + } + + return false; +} + +function validateCalDAVUrl(urlString) { + try { + const url = new URL(urlString); + + if (!['http:', 'https:'].includes(url.protocol)) { + throw new AppError( + 'Only HTTP and HTTPS protocols are allowed', + 400 + ); + } + + if (isPrivateOrLocalhost(url.hostname)) { + throw new AppError( + 'Cannot connect to private, local, or internal network addresses', + 400 + ); + } + + return url.href; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + throw new AppError('Invalid URL format', 400); + } +} + +class RemoteCalendarController { + async listRemoteCalendars(req, res) { + const userId = req.currentUser.id; + + const remoteCalendars = + await RemoteCalendarRepository.findByUserId(userId); + + const sanitizedCalendars = remoteCalendars.map((rc) => { + const json = rc.toJSON(); + delete json.password_encrypted; + return json; + }); + + res.json(sanitizedCalendars); + } + + async getRemoteCalendar(req, res) { + const { id } = req.params; + const userId = req.currentUser.id; + + const remoteCalendar = await RemoteCalendarRepository.findById(id); + + if (!remoteCalendar) { + throw new AppError('Remote calendar not found', 404); + } + + if (remoteCalendar.user_id !== userId) { + throw new AppError('Unauthorized access to remote calendar', 403); + } + + const json = remoteCalendar.toJSON(); + delete json.password_encrypted; + + res.json(json); + } + + async createRemoteCalendar(req, res) { + const userId = req.currentUser.id; + const { + local_calendar_id, + name, + server_url, + calendar_path, + username, + password, + auth_type = 'basic', + enabled = true, + sync_direction = 'bidirectional', + } = req.body; + + if ( + !local_calendar_id || + !name || + !server_url || + !calendar_path || + !username || + !password + ) { + throw new AppError( + 'Missing required fields: local_calendar_id, name, server_url, calendar_path, username, password', + 400 + ); + } + + const localCalendar = + await CalendarRepository.findById(local_calendar_id); + if (!localCalendar) { + throw new AppError('Local calendar not found', 404); + } + + if (localCalendar.user_id !== userId) { + throw new AppError('Unauthorized access to local calendar', 403); + } + + const existing = + await RemoteCalendarRepository.findByLocalCalendarId( + local_calendar_id + ); + if (existing) { + throw new AppError( + 'Remote calendar already configured for this local calendar', + 409 + ); + } + + const validAuthTypes = ['basic', 'bearer']; + if (!validAuthTypes.includes(auth_type)) { + throw new AppError( + `Invalid auth type. Must be one of: ${validAuthTypes.join(', ')}`, + 400 + ); + } + + const validDirections = ['bidirectional', 'pull', 'push']; + if (!validDirections.includes(sync_direction)) { + throw new AppError( + `Invalid sync direction. Must be one of: ${validDirections.join(', ')}`, + 400 + ); + } + + const baseUrl = server_url.replace(/\/$/, ''); + const path = calendar_path.startsWith('/') + ? calendar_path + : `/${calendar_path}`; + const fullUrl = `${baseUrl}${path}`; + + validateCalDAVUrl(fullUrl); + + const passwordEncrypted = encryptionService.encrypt(password); + + const remoteCalendar = await RemoteCalendarRepository.create({ + user_id: userId, + local_calendar_id, + name, + server_url: baseUrl, + calendar_path: path, + username, + password_encrypted: passwordEncrypted, + auth_type, + enabled, + sync_direction, + }); + + const json = remoteCalendar.toJSON(); + delete json.password_encrypted; + + res.status(201).json(json); + } + + async updateRemoteCalendar(req, res) { + const { id } = req.params; + const userId = req.currentUser.id; + + const remoteCalendar = await RemoteCalendarRepository.findById(id); + + if (!remoteCalendar) { + throw new AppError('Remote calendar not found', 404); + } + + if (remoteCalendar.user_id !== userId) { + throw new AppError('Unauthorized access to remote calendar', 403); + } + + const { + name, + server_url, + calendar_path, + username, + password, + auth_type, + enabled, + sync_direction, + } = req.body; + + const updates = {}; + + if (name !== undefined) updates.name = name; + + const newServerUrl = + server_url !== undefined ? server_url.replace(/\/$/, '') : null; + const newCalendarPath = + calendar_path !== undefined + ? calendar_path.startsWith('/') + ? calendar_path + : `/${calendar_path}` + : null; + + if (newServerUrl || newCalendarPath) { + const finalServerUrl = newServerUrl || remoteCalendar.server_url; + const finalCalendarPath = + newCalendarPath || remoteCalendar.calendar_path; + const fullUrl = `${finalServerUrl}${finalCalendarPath}`; + + validateCalDAVUrl(fullUrl); + + if (newServerUrl) updates.server_url = newServerUrl; + if (newCalendarPath) updates.calendar_path = newCalendarPath; + } + + if (username !== undefined) updates.username = username; + if (password !== undefined) { + updates.password_encrypted = encryptionService.encrypt(password); + } + if (auth_type !== undefined) { + const validAuthTypes = ['basic', 'bearer']; + if (!validAuthTypes.includes(auth_type)) { + throw new AppError( + `Invalid auth type. Must be one of: ${validAuthTypes.join(', ')}`, + 400 + ); + } + updates.auth_type = auth_type; + } + if (enabled !== undefined) updates.enabled = enabled; + if (sync_direction !== undefined) { + const validDirections = ['bidirectional', 'pull', 'push']; + if (!validDirections.includes(sync_direction)) { + throw new AppError( + `Invalid sync direction. Must be one of: ${validDirections.join(', ')}`, + 400 + ); + } + updates.sync_direction = sync_direction; + } + + const updatedRemoteCalendar = await RemoteCalendarRepository.update( + remoteCalendar, + updates + ); + + const json = updatedRemoteCalendar.toJSON(); + delete json.password_encrypted; + + res.json(json); + } + + async deleteRemoteCalendar(req, res) { + const { id } = req.params; + const userId = req.currentUser.id; + + const remoteCalendar = await RemoteCalendarRepository.findById(id); + + if (!remoteCalendar) { + throw new AppError('Remote calendar not found', 404); + } + + if (remoteCalendar.user_id !== userId) { + throw new AppError('Unauthorized access to remote calendar', 403); + } + + await RemoteCalendarRepository.delete(remoteCalendar); + + res.status(204).send(); + } + + async testConnection(req, res) { + const userId = req.currentUser.id; + const { server_url, calendar_path, username, password, auth_type } = + req.body; + + if (!server_url || !calendar_path || !username || !password) { + throw new AppError( + 'Missing required fields: server_url, calendar_path, username, password', + 400 + ); + } + + try { + const baseUrl = server_url.replace(/\/$/, ''); + const path = calendar_path.startsWith('/') + ? calendar_path + : `/${calendar_path}`; + const testUrl = `${baseUrl}${path}`; + + const validatedUrl = validateCalDAVUrl(testUrl); + + const parsedUrl = new URL(validatedUrl); + if (isPrivateOrLocalhost(parsedUrl.hostname)) { + throw new AppError( + 'Cannot connect to private, local, or internal network addresses', + 400 + ); + } + + const authConfig = + auth_type === 'bearer' + ? { headers: { Authorization: `Bearer ${password}` } } + : { auth: { username, password } }; + + // lgtm[js/request-forgery] + // SSRF protection implemented: URL validated via validateCalDAVUrl() and + // isPrivateOrLocalhost(), only HTTP/HTTPS allowed, redirects disabled + // + const response = await axios({ + method: 'OPTIONS', + url: validatedUrl, + ...authConfig, + timeout: parseInt( + process.env.CALDAV_REQUEST_TIMEOUT || '30000', + 10 + ), + maxRedirects: 0, + }); + + const davHeader = response.headers.dav || ''; + const supportsCalDAV = + davHeader.includes('calendar-access') || + davHeader.includes('1'); + + res.json({ + success: true, + status: response.status, + supportsCalDAV, + davCapabilities: davHeader, + message: supportsCalDAV + ? 'Connection successful - CalDAV supported' + : 'Connection successful - CalDAV support unclear', + }); + } catch (error) { + if (error.response?.status === 401) { + throw new AppError('Authentication failed', 401); + } + + if (error.response?.status === 404) { + throw new AppError('Calendar path not found', 404); + } + + if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') { + throw new AppError('Unable to reach server', 503); + } + + throw new AppError(`Connection test failed: ${error.message}`, 500); + } + } +} + +module.exports = new RemoteCalendarController(); diff --git a/backend/modules/caldav/api/routes.js b/backend/modules/caldav/api/routes.js new file mode 100644 index 0000000..9351631 --- /dev/null +++ b/backend/modules/caldav/api/routes.js @@ -0,0 +1,38 @@ +const express = require('express'); +const router = express.Router(); +const calendarController = require('./calendar-controller'); +const remoteCalendarController = require('./remote-calendar-controller'); +const syncController = require('./sync-controller'); + +router.get('/calendars', calendarController.listCalendars); +router.get('/calendars/:id', calendarController.getCalendar); +router.post('/calendars', calendarController.createCalendar); +router.put('/calendars/:id', calendarController.updateCalendar); +router.delete('/calendars/:id', calendarController.deleteCalendar); + +router.get('/remote-calendars', remoteCalendarController.listRemoteCalendars); +router.get('/remote-calendars/:id', remoteCalendarController.getRemoteCalendar); +router.post('/remote-calendars', remoteCalendarController.createRemoteCalendar); +router.put( + '/remote-calendars/:id', + remoteCalendarController.updateRemoteCalendar +); +router.delete( + '/remote-calendars/:id', + remoteCalendarController.deleteRemoteCalendar +); +router.post( + '/remote-calendars/test-connection', + remoteCalendarController.testConnection +); + +router.post('/sync/calendars/:id', syncController.syncCalendar); +router.post('/sync/all', syncController.syncAllCalendars); +router.get('/sync/status/:id', syncController.getSyncStatus); +router.get('/sync/scheduler/status', syncController.getSchedulerStatus); + +router.get('/conflicts', syncController.listConflicts); +router.get('/conflicts/:taskId', syncController.getConflict); +router.post('/conflicts/:taskId/resolve', syncController.resolveConflict); + +module.exports = router; diff --git a/backend/modules/caldav/api/sync-controller.js b/backend/modules/caldav/api/sync-controller.js new file mode 100644 index 0000000..ef4f843 --- /dev/null +++ b/backend/modules/caldav/api/sync-controller.js @@ -0,0 +1,170 @@ +const { AppError } = require('../../../shared/errors/AppError'); +const syncScheduler = require('../services/sync-scheduler'); +const syncEngine = require('../sync/sync-engine'); +const MergePhase = require('../sync/merge-phase'); +const CalendarRepository = require('../repositories/calendar-repository'); +const SyncStateRepository = require('../repositories/sync-state-repository'); + +class SyncController { + async syncCalendar(req, res) { + const { id } = req.params; + const userId = req.currentUser.id; + const { direction = 'bidirectional', dryRun = false } = req.body; + + const calendar = await CalendarRepository.findById(id); + + if (!calendar) { + throw new AppError('Calendar not found', 404); + } + + if (calendar.user_id !== userId) { + throw new AppError('Unauthorized access to calendar', 403); + } + + const result = await syncEngine.syncCalendar(calendar.id, userId, { + direction, + dryRun, + }); + + res.json(result); + } + + async syncAllCalendars(req, res) { + const userId = req.currentUser.id; + const { force = false, dryRun = false } = req.body; + + const result = await syncScheduler.syncUserCalendars(userId, { + force, + dryRun, + }); + + res.json(result); + } + + async getSyncStatus(req, res) { + const { id } = req.params; + const userId = req.currentUser.id; + + const calendar = await CalendarRepository.findById(id); + + if (!calendar) { + throw new AppError('Calendar not found', 404); + } + + if (calendar.user_id !== userId) { + throw new AppError('Unauthorized access to calendar', 403); + } + + const status = await syncEngine.getSyncStatus(calendar.id, userId); + + res.json(status); + } + + async listConflicts(req, res) { + const userId = req.currentUser.id; + const { calendarId } = req.query; + + let conflicts; + if (calendarId) { + const calendar = await CalendarRepository.findById(calendarId); + + if (!calendar) { + throw new AppError('Calendar not found', 404); + } + + if (calendar.user_id !== userId) { + throw new AppError('Unauthorized access to calendar', 403); + } + + conflicts = await SyncStateRepository.findConflicts(calendarId); + } else { + conflicts = await SyncStateRepository.findConflictsByUser(userId); + } + + res.json(conflicts); + } + + async getConflict(req, res) { + const { taskId } = req.params; + const userId = req.currentUser.id; + const { calendarId } = req.query; + + if (!calendarId) { + throw new AppError('calendarId query parameter is required', 400); + } + + const calendar = await CalendarRepository.findById(calendarId); + + if (!calendar) { + throw new AppError('Calendar not found', 404); + } + + if (calendar.user_id !== userId) { + throw new AppError('Unauthorized access to calendar', 403); + } + + const conflict = await SyncStateRepository.findByTaskAndCalendar( + taskId, + calendarId + ); + + if (!conflict || conflict.sync_status !== 'conflict') { + throw new AppError('No conflict found for this task', 404); + } + + res.json(conflict); + } + + async resolveConflict(req, res) { + const { taskId } = req.params; + const userId = req.currentUser.id; + const { calendarId, resolution } = req.body; + + if (!calendarId) { + throw new AppError('calendarId is required', 400); + } + + if (!resolution) { + throw new AppError('resolution is required', 400); + } + + const validResolutions = ['local', 'remote']; + if (!validResolutions.includes(resolution)) { + throw new AppError( + `Invalid resolution. Must be one of: ${validResolutions.join(', ')}`, + 400 + ); + } + + const calendar = await CalendarRepository.findById(calendarId); + + if (!calendar) { + throw new AppError('Calendar not found', 404); + } + + if (calendar.user_id !== userId) { + throw new AppError('Unauthorized access to calendar', 403); + } + + const mergePhase = new MergePhase(); + const resolvedTask = await mergePhase.resolveConflict( + taskId, + calendarId, + resolution + ); + + res.json({ + success: true, + task: resolvedTask, + resolution, + }); + } + + async getSchedulerStatus(req, res) { + const status = syncScheduler.getStatus(); + + res.json(status); + } +} + +module.exports = new SyncController(); diff --git a/backend/modules/caldav/icalendar/field-mappings.js b/backend/modules/caldav/icalendar/field-mappings.js new file mode 100644 index 0000000..7db26f3 --- /dev/null +++ b/backend/modules/caldav/icalendar/field-mappings.js @@ -0,0 +1,70 @@ +const STATUS_TUDUDI_TO_ICAL = { + 0: 'NEEDS-ACTION', + 1: 'IN-PROCESS', + 2: 'COMPLETED', + 3: 'COMPLETED', + 4: 'NEEDS-ACTION', + 5: 'CANCELLED', + 6: 'NEEDS-ACTION', +}; + +const STATUS_ICAL_TO_TUDUDI = { + 'NEEDS-ACTION': 0, + 'IN-PROCESS': 1, + COMPLETED: 2, + CANCELLED: 5, +}; + +function tududiToIcalPriority(priority) { + if (priority === null || priority === undefined) { + return 0; + } + + if (priority === 0) return 7; + if (priority === 1) return 5; + if (priority === 2) return 3; + return 0; +} + +function icalToTududiPriority(priority) { + if (!priority || priority === 0) { + return 0; + } + + if (priority <= 3) { + return 2; + } + if (priority <= 6) { + return 1; + } + return 0; +} + +const WEEKDAY_MAP = { + 0: 'SU', + 1: 'MO', + 2: 'TU', + 3: 'WE', + 4: 'TH', + 5: 'FR', + 6: 'SA', +}; + +const WEEKDAY_REVERSE_MAP = { + SU: 0, + MO: 1, + TU: 2, + WE: 3, + TH: 4, + FR: 5, + SA: 6, +}; + +module.exports = { + STATUS_TUDUDI_TO_ICAL, + STATUS_ICAL_TO_TUDUDI, + tududiToIcalPriority, + icalToTududiPriority, + WEEKDAY_MAP, + WEEKDAY_REVERSE_MAP, +}; diff --git a/backend/modules/caldav/icalendar/rrule-generator.js b/backend/modules/caldav/icalendar/rrule-generator.js new file mode 100644 index 0000000..ce88cf6 --- /dev/null +++ b/backend/modules/caldav/icalendar/rrule-generator.js @@ -0,0 +1,105 @@ +const ICAL = require('ical.js'); +const { WEEKDAY_MAP } = require('./field-mappings'); + +function generateRRULE(task) { + if (!task.recurrence_type || task.recurrence_type === 'none') { + return null; + } + + const parts = []; + + switch (task.recurrence_type) { + case 'daily': + parts.push('FREQ=DAILY'); + if (task.recurrence_interval && task.recurrence_interval > 1) { + parts.push(`INTERVAL=${task.recurrence_interval}`); + } + break; + + case 'weekly': + parts.push('FREQ=WEEKLY'); + if (task.recurrence_interval && task.recurrence_interval > 1) { + parts.push(`INTERVAL=${task.recurrence_interval}`); + } + if ( + task.recurrence_weekdays && + task.recurrence_weekdays.length > 0 + ) { + const weekdaysArray = + typeof task.recurrence_weekdays === 'string' + ? JSON.parse(task.recurrence_weekdays) + : task.recurrence_weekdays; + + const days = weekdaysArray + .map((d) => WEEKDAY_MAP[d]) + .filter(Boolean) + .join(','); + if (days) { + parts.push(`BYDAY=${days}`); + } + } + break; + + case 'monthly': + parts.push('FREQ=MONTHLY'); + if (task.recurrence_interval && task.recurrence_interval > 1) { + parts.push(`INTERVAL=${task.recurrence_interval}`); + } + if (task.recurrence_month_day) { + parts.push(`BYMONTHDAY=${task.recurrence_month_day}`); + } + break; + + case 'monthly_weekday': + parts.push('FREQ=MONTHLY'); + if (task.recurrence_interval && task.recurrence_interval > 1) { + parts.push(`INTERVAL=${task.recurrence_interval}`); + } + if ( + task.recurrence_week_of_month && + task.recurrence_weekday !== null + ) { + const day = WEEKDAY_MAP[task.recurrence_weekday]; + if (day) { + parts.push(`BYDAY=${task.recurrence_week_of_month}${day}`); + } + } + break; + + case 'monthly_last_day': + parts.push('FREQ=MONTHLY'); + if (task.recurrence_interval && task.recurrence_interval > 1) { + parts.push(`INTERVAL=${task.recurrence_interval}`); + } + parts.push('BYMONTHDAY=-1'); + break; + + case 'yearly': + parts.push('FREQ=YEARLY'); + if (task.recurrence_interval && task.recurrence_interval > 1) { + parts.push(`INTERVAL=${task.recurrence_interval}`); + } + break; + + default: + return null; + } + + if (task.recurrence_count && task.recurrence_count > 0) { + parts.push(`COUNT=${task.recurrence_count}`); + } else if (task.recurrence_end_date) { + try { + const endDate = new Date(task.recurrence_end_date); + const until = ICAL.Time.fromJSDate(endDate, true); + parts.push(`UNTIL=${until.toICALString()}`); + } catch (error) { + console.error('Error formatting UNTIL date:', error); + } + } + + return parts.length > 0 ? parts.join(';') : null; +} + +module.exports = { + generateRRULE, +}; diff --git a/backend/modules/caldav/icalendar/rrule-parser.js b/backend/modules/caldav/icalendar/rrule-parser.js new file mode 100644 index 0000000..e2423d1 --- /dev/null +++ b/backend/modules/caldav/icalendar/rrule-parser.js @@ -0,0 +1,115 @@ +const { WEEKDAY_REVERSE_MAP } = require('./field-mappings'); + +function parseRRULE(rruleString) { + if (!rruleString) { + return null; + } + + try { + const parts = rruleString.split(';'); + const rruleData = {}; + + parts.forEach((part) => { + const [key, value] = part.split('='); + rruleData[key] = value; + }); + + const result = { + recurrence_type: null, + recurrence_interval: 1, + recurrence_end_date: null, + recurrence_count: null, + recurrence_weekdays: null, + recurrence_month_day: null, + recurrence_weekday: null, + recurrence_week_of_month: null, + }; + + if (!rruleData.FREQ) { + return null; + } + + if (rruleData.INTERVAL) { + result.recurrence_interval = parseInt(rruleData.INTERVAL, 10); + } + + if (rruleData.COUNT) { + result.recurrence_count = parseInt(rruleData.COUNT, 10); + } + + if (rruleData.UNTIL) { + try { + const until = rruleData.UNTIL; + result.recurrence_end_date = new Date( + until.replace( + /(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?/, + '$1-$2-$3T$4:$5:$6Z' + ) + ); + } catch (error) { + console.error('Error parsing UNTIL:', error); + } + } + + switch (rruleData.FREQ) { + case 'DAILY': + result.recurrence_type = 'daily'; + break; + + case 'WEEKLY': + result.recurrence_type = 'weekly'; + if (rruleData.BYDAY) { + const days = rruleData.BYDAY.split(',') + .map((day) => { + const cleanDay = day.replace(/[0-9-]/g, ''); + return WEEKDAY_REVERSE_MAP[cleanDay]; + }) + .filter((d) => d !== undefined); + result.recurrence_weekdays = days; + } + break; + + case 'MONTHLY': + if (rruleData.BYDAY) { + result.recurrence_type = 'monthly_weekday'; + const byDayMatch = + rruleData.BYDAY.match(/(-?\d+)([A-Z]{2})/); + if (byDayMatch) { + result.recurrence_week_of_month = parseInt( + byDayMatch[1], + 10 + ); + result.recurrence_weekday = + WEEKDAY_REVERSE_MAP[byDayMatch[2]]; + } + } else if (rruleData.BYMONTHDAY === '-1') { + result.recurrence_type = 'monthly_last_day'; + } else if (rruleData.BYMONTHDAY) { + result.recurrence_type = 'monthly'; + result.recurrence_month_day = parseInt( + rruleData.BYMONTHDAY, + 10 + ); + } else { + result.recurrence_type = 'monthly'; + } + break; + + case 'YEARLY': + result.recurrence_type = 'yearly'; + break; + + default: + return null; + } + + return result; + } catch (error) { + console.error('Error parsing RRULE:', error); + return null; + } +} + +module.exports = { + parseRRULE, +}; diff --git a/backend/modules/caldav/icalendar/vtodo-parser.js b/backend/modules/caldav/icalendar/vtodo-parser.js new file mode 100644 index 0000000..1d8b965 --- /dev/null +++ b/backend/modules/caldav/icalendar/vtodo-parser.js @@ -0,0 +1,229 @@ +const ICAL = require('ical.js'); +const { + STATUS_ICAL_TO_TUDUDI, + icalToTududiPriority, +} = require('./field-mappings'); +const { parseRRULE } = require('./rrule-parser'); + +async function parseVTODOToTask(vtodoString) { + try { + const jcalData = ICAL.parse(vtodoString); + const comp = new ICAL.Component(jcalData); + const vtodo = comp.getFirstSubcomponent('vtodo'); + + if (!vtodo) { + throw new Error('No VTODO component found'); + } + + const task = { + uid: null, + name: null, + note: null, + due_date: null, + defer_until: null, + completed_at: null, + status: 0, + priority: 0, + recurrence_type: 'none', + recurrence_interval: null, + recurrence_end_date: null, + recurrence_weekdays: null, + recurrence_month_day: null, + recurrence_weekday: null, + recurrence_week_of_month: null, + parent_task_uid: null, + project_uid: null, + tag_names: [], + habit_mode: false, + habit_current_streak: 0, + habit_total_completions: 0, + order: null, + }; + + const uid = vtodo.getFirstPropertyValue('uid'); + if (uid) { + task.uid = uid; + } + + const summary = vtodo.getFirstPropertyValue('summary'); + if (summary) { + task.name = summary; + } + + const description = vtodo.getFirstPropertyValue('description'); + if (description) { + task.note = description; + } + + const status = vtodo.getFirstPropertyValue('status'); + if (status) { + task.status = STATUS_ICAL_TO_TUDUDI[status] || 0; + } + + const priority = vtodo.getFirstPropertyValue('priority'); + if (priority !== null && priority !== undefined) { + task.priority = icalToTududiPriority(priority); + } + + const due = vtodo.getFirstPropertyValue('due'); + if (due) { + if (due.isDate) { + const year = due.year; + const month = due.month - 1; + const day = due.day; + task.due_date = new Date( + Date.UTC(year, month, day, 0, 0, 0, 0) + ); + } else { + task.due_date = due.toJSDate(); + } + } + + const dtstart = vtodo.getFirstPropertyValue('dtstart'); + if (dtstart) { + if (dtstart.isDate) { + const year = dtstart.year; + const month = dtstart.month - 1; + const day = dtstart.day; + task.defer_until = new Date( + Date.UTC(year, month, day, 0, 0, 0, 0) + ); + } else { + task.defer_until = dtstart.toJSDate(); + } + } + + const completed = vtodo.getFirstPropertyValue('completed'); + if (completed) { + task.completed_at = completed.toJSDate(); + } + + const rrule = vtodo.getFirstPropertyValue('rrule'); + if (rrule) { + const recurrenceData = parseRRULE(rrule.toString()); + if (recurrenceData) { + Object.assign(task, recurrenceData); + } + } + + const relatedTo = vtodo.getFirstPropertyValue('related-to'); + if (relatedTo) { + task.parent_task_uid = relatedTo; + } + + const projectUid = vtodo.getFirstPropertyValue('x-tududi-project-uid'); + if (projectUid) { + task.project_uid = projectUid; + } + + const categories = vtodo.getFirstPropertyValue('categories'); + if (categories) { + if (Array.isArray(categories)) { + task.tag_names = categories; + } else if (typeof categories === 'string') { + task.tag_names = categories.split(',').map((t) => t.trim()); + } else { + task.tag_names = [categories]; + } + } + + const habitMode = vtodo.getFirstPropertyValue('x-tududi-habit-mode'); + if (habitMode === 'true' || habitMode === true) { + task.habit_mode = true; + + const streak = vtodo.getFirstPropertyValue('x-tududi-habit-streak'); + if (streak) { + task.habit_current_streak = parseInt(streak, 10) || 0; + } + + const completions = vtodo.getFirstPropertyValue( + 'x-tududi-habit-completions' + ); + if (completions) { + task.habit_total_completions = parseInt(completions, 10) || 0; + } + } + + const order = vtodo.getFirstPropertyValue('x-tududi-order'); + if (order) { + task.order = parseInt(order, 10); + } + + return task; + } catch (error) { + console.error('Error parsing VTODO:', error); + throw new Error(`Failed to parse VTODO: ${error.message}`); + } +} + +async function parseRecurrenceOverride(vtodoString) { + try { + const jcalData = ICAL.parse(vtodoString); + const comp = new ICAL.Component(jcalData); + const vtodo = comp.getFirstSubcomponent('vtodo'); + + if (!vtodo) { + throw new Error('No VTODO component found'); + } + + const override = { + uid: null, + recurrence_id: null, + name: null, + due_date: null, + status: 0, + completed_at: null, + }; + + const uid = vtodo.getFirstPropertyValue('uid'); + if (uid) { + override.uid = uid; + } + + const recurrenceId = vtodo.getFirstPropertyValue('recurrence-id'); + if (recurrenceId) { + override.recurrence_id = recurrenceId.toJSDate(); + } + + const summary = vtodo.getFirstPropertyValue('summary'); + if (summary) { + override.name = summary; + } + + const due = vtodo.getFirstPropertyValue('due'); + if (due) { + if (due.isDate) { + const year = due.year; + const month = due.month - 1; + const day = due.day; + override.due_date = new Date( + Date.UTC(year, month, day, 0, 0, 0, 0) + ); + } else { + override.due_date = due.toJSDate(); + } + } + + const status = vtodo.getFirstPropertyValue('status'); + if (status) { + override.status = STATUS_ICAL_TO_TUDUDI[status] || 0; + } + + const completed = vtodo.getFirstPropertyValue('completed'); + if (completed) { + override.completed_at = completed.toJSDate(); + } + + return override; + } catch (error) { + console.error('Error parsing recurrence override:', error); + throw new Error( + `Failed to parse recurrence override: ${error.message}` + ); + } +} + +module.exports = { + parseVTODOToTask, + parseRecurrenceOverride, +}; diff --git a/backend/modules/caldav/icalendar/vtodo-serializer.js b/backend/modules/caldav/icalendar/vtodo-serializer.js new file mode 100644 index 0000000..7768d17 --- /dev/null +++ b/backend/modules/caldav/icalendar/vtodo-serializer.js @@ -0,0 +1,176 @@ +const ICAL = require('ical.js'); +const { + STATUS_TUDUDI_TO_ICAL, + tududiToIcalPriority, +} = require('./field-mappings'); +const { generateRRULE } = require('./rrule-generator'); + +function serializeTaskToVTODO(task, options = {}) { + const comp = new ICAL.Component('vcalendar'); + comp.addPropertyWithValue('version', '2.0'); + comp.addPropertyWithValue('prodid', '-//Tududi//Task Manager//EN'); + comp.addPropertyWithValue('calscale', 'GREGORIAN'); + + const vtodo = new ICAL.Component('vtodo'); + + vtodo.addPropertyWithValue('uid', task.uid); + vtodo.addPropertyWithValue('summary', task.name); + vtodo.addPropertyWithValue('dtstamp', ICAL.Time.now()); + + const status = STATUS_TUDUDI_TO_ICAL[task.status] || 'NEEDS-ACTION'; + vtodo.addPropertyWithValue('status', status); + + if (task.priority !== null && task.priority !== undefined) { + const priority = tududiToIcalPriority(task.priority); + vtodo.addPropertyWithValue('priority', priority); + } + + if (task.due_date) { + try { + const dueTime = ICAL.Time.fromJSDate(new Date(task.due_date), true); + vtodo.addPropertyWithValue('due', dueTime); + } catch (error) { + console.error('Error formatting due date:', error); + } + } + + if (task.defer_until) { + try { + const startTime = ICAL.Time.fromJSDate( + new Date(task.defer_until), + true + ); + vtodo.addPropertyWithValue('dtstart', startTime); + } catch (error) { + console.error('Error formatting defer_until date:', error); + } + } + + if (task.completed_at) { + try { + const completedTime = ICAL.Time.fromJSDate( + new Date(task.completed_at), + true + ); + vtodo.addPropertyWithValue('completed', completedTime); + + if (status === 'COMPLETED') { + vtodo.addPropertyWithValue('percent-complete', 100); + } + } catch (error) { + console.error('Error formatting completed_at date:', error); + } + } + + if (task.note) { + vtodo.addPropertyWithValue('description', task.note); + } + + if (task.recurrence_type && task.recurrence_type !== 'none') { + const rrule = generateRRULE(task); + if (rrule) { + try { + const recur = ICAL.Recur.fromString(rrule); + vtodo.addPropertyWithValue('rrule', recur); + } catch (error) { + console.error('Error parsing RRULE:', error); + } + } + } + + if (task.parent_task_id && task.ParentTask) { + vtodo.addPropertyWithValue('related-to', task.ParentTask.uid); + const relatedProp = vtodo.getFirstProperty('related-to'); + if (relatedProp) { + relatedProp.setParameter('reltype', 'PARENT'); + } + } + + if (task.Project) { + vtodo.addPropertyWithValue('x-tududi-project-uid', task.Project.uid); + if (task.Project.name) { + vtodo.addPropertyWithValue( + 'x-tududi-project-name', + task.Project.name + ); + } + } + + if (task.Tags && task.Tags.length > 0) { + const tagNames = task.Tags.map((t) => t.name).join(','); + vtodo.addPropertyWithValue('categories', tagNames); + + const tagUids = task.Tags.map((t) => t.uid).join(','); + vtodo.addPropertyWithValue('x-tududi-tag-uids', tagUids); + } + + if (task.habit_mode) { + vtodo.addPropertyWithValue('x-tududi-habit-mode', 'true'); + if (task.habit_current_streak !== null) { + vtodo.addPropertyWithValue( + 'x-tududi-habit-streak', + task.habit_current_streak.toString() + ); + } + if (task.habit_total_completions !== null) { + vtodo.addPropertyWithValue( + 'x-tududi-habit-completions', + task.habit_total_completions.toString() + ); + } + } + + if (task.order !== null && task.order !== undefined) { + vtodo.addPropertyWithValue('x-tududi-order', task.order.toString()); + } + + const statusName = getStatusName(task.status); + if (statusName) { + vtodo.addPropertyWithValue('x-tududi-status-name', statusName); + } + + if (task.created_at) { + try { + const createdTime = ICAL.Time.fromJSDate( + new Date(task.created_at), + true + ); + vtodo.addPropertyWithValue('created', createdTime); + } catch (error) { + console.error('Error formatting created_at date:', error); + } + } + + if (task.updated_at) { + try { + const modifiedTime = ICAL.Time.fromJSDate( + new Date(task.updated_at), + true + ); + vtodo.addPropertyWithValue('last-modified', modifiedTime); + } catch (error) { + console.error('Error formatting updated_at date:', error); + } + } + + comp.addSubcomponent(vtodo); + + return comp.toString(); +} + +function getStatusName(status) { + const statusMap = { + 0: 'not_started', + 1: 'in_progress', + 2: 'done', + 3: 'archived', + 4: 'waiting', + 5: 'cancelled', + 6: 'planned', + }; + return statusMap[status]; +} + +module.exports = { + serializeTaskToVTODO, +}; diff --git a/backend/modules/caldav/index.js b/backend/modules/caldav/index.js new file mode 100644 index 0000000..cf5e491 --- /dev/null +++ b/backend/modules/caldav/index.js @@ -0,0 +1,5 @@ +const routes = require('./routes'); + +module.exports = { + routes, +}; diff --git a/backend/modules/caldav/middleware/caldav-auth.js b/backend/modules/caldav/middleware/caldav-auth.js new file mode 100644 index 0000000..8ceedae --- /dev/null +++ b/backend/modules/caldav/middleware/caldav-auth.js @@ -0,0 +1,81 @@ +const bcrypt = require('bcrypt'); +const { User } = require('../../../models'); + +async function caldavAuth(req, res, next) { + try { + if ( + req.session?.userId || + req.headers.authorization?.startsWith('Bearer ') + ) { + if (req.session?.userId) { + const user = await User.findByPk(req.session.userId); + if (user) { + req.currentUser = user; + return next(); + } + } + + if (req.headers.authorization?.startsWith('Bearer ')) { + return next(); + } + } + + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Basic ')) { + return res + .status(401) + .set('WWW-Authenticate', 'Basic realm="Tududi CalDAV"') + .json({ error: 'Authentication required' }); + } + + const credentials = Buffer.from( + authHeader.split(' ')[1], + 'base64' + ).toString('utf8'); + const colonIndex = credentials.indexOf(':'); + + if (colonIndex === -1) { + return res + .status(401) + .set('WWW-Authenticate', 'Basic realm="Tududi CalDAV"') + .json({ error: 'Invalid credentials format' }); + } + + const username = credentials.substring(0, colonIndex); + const password = credentials.substring(colonIndex + 1); + + const user = await User.findOne({ where: { email: username } }); + if (!user) { + return res + .status(401) + .set('WWW-Authenticate', 'Basic realm="Tududi CalDAV"') + .json({ error: 'Invalid credentials' }); + } + + const isValidPassword = await bcrypt.compare( + password, + user.password_digest + ); + if (!isValidPassword) { + return res + .status(401) + .set('WWW-Authenticate', 'Basic realm="Tududi CalDAV"') + .json({ error: 'Invalid credentials' }); + } + + req.currentUser = user; + + if (req.params.username && req.params.username !== user.email) { + return res.status(403).json({ + error: 'Access to other users calendars is forbidden', + }); + } + + next(); + } catch (error) { + console.error('CalDAV auth error:', error); + return res.status(500).json({ error: 'Authentication failed' }); + } +} + +module.exports = caldavAuth; diff --git a/backend/modules/caldav/middleware/xml-parser.js b/backend/modules/caldav/middleware/xml-parser.js new file mode 100644 index 0000000..3b3736b --- /dev/null +++ b/backend/modules/caldav/middleware/xml-parser.js @@ -0,0 +1,45 @@ +async function xmlParser(req, res, next) { + const contentType = req.headers['content-type'] || ''; + const contentLength = parseInt(req.headers['content-length'] || '0', 10); + const hasBody = + req.method === 'POST' || + req.method === 'PUT' || + req.method === 'PROPFIND' || + req.method === 'REPORT'; + + if ( + hasBody && + contentLength > 0 && + (contentType.includes('xml') || + contentType.includes('text/calendar') || + req.method === 'PROPFIND' || + req.method === 'REPORT') + ) { + if (req.rawBody) { + console.log('[XML-PARSER] Already processed, skipping'); + return next(); + } + + try { + console.log('[XML-PARSER] Processing', req.method, contentType); + const chunks = []; + + for await (const chunk of req) { + console.log('[XML-PARSER] Got chunk:', chunk.length); + chunks.push(chunk); + } + + req.rawBody = Buffer.concat(chunks).toString('utf8'); + console.log('[XML-PARSER] Total body length:', req.rawBody.length); + return next(); + } catch (error) { + console.error('XML parser error:', error); + return res.status(400).json({ error: 'Invalid request body' }); + } + } else { + console.log('[XML-PARSER] Skipping', req.method, contentType); + return next(); + } +} + +module.exports = xmlParser; diff --git a/backend/modules/caldav/protocol/capabilities.js b/backend/modules/caldav/protocol/capabilities.js new file mode 100644 index 0000000..357673e --- /dev/null +++ b/backend/modules/caldav/protocol/capabilities.js @@ -0,0 +1,41 @@ +function handleOptions(req, res) { + const isCalendarCollection = req.path.includes('/tasks/'); + + const davMethods = [ + 'OPTIONS', + 'GET', + 'HEAD', + 'PUT', + 'DELETE', + 'PROPFIND', + 'REPORT', + ]; + + const davCapabilities = [ + '1', + '2', + '3', + 'calendar-access', + 'calendar-schedule', + ]; + + res.set({ + DAV: davCapabilities.join(', '), + Allow: davMethods.join(', '), + 'MS-Author-Via': 'DAV', + 'Accept-Ranges': 'none', + 'Content-Length': '0', + }); + + if (isCalendarCollection) { + res.set({ + 'Content-Type': 'text/calendar; charset=utf-8', + }); + } + + res.status(204).end(); +} + +module.exports = { + handleOptions, +}; diff --git a/backend/modules/caldav/protocol/discovery.js b/backend/modules/caldav/protocol/discovery.js new file mode 100644 index 0000000..305283f --- /dev/null +++ b/backend/modules/caldav/protocol/discovery.js @@ -0,0 +1,11 @@ +function handleWellKnown(req, res) { + const protocol = req.protocol; + const host = req.get('host'); + const redirectUrl = `${protocol}://${host}/caldav/`; + + res.redirect(301, redirectUrl); +} + +module.exports = { + handleWellKnown, +}; diff --git a/backend/modules/caldav/repositories/calendar-repository.js b/backend/modules/caldav/repositories/calendar-repository.js new file mode 100644 index 0000000..669e053 --- /dev/null +++ b/backend/modules/caldav/repositories/calendar-repository.js @@ -0,0 +1,79 @@ +const BaseRepository = require('../../../shared/database/BaseRepository'); +const { CalDAVCalendar } = require('../../../models'); + +class CalendarRepository extends BaseRepository { + constructor() { + super(CalDAVCalendar); + } + + async findByUserId(userId, options = {}) { + return this.findAll({ user_id: userId }, options); + } + + async findEnabledByUserId(userId, options = {}) { + return this.findAll({ user_id: userId, enabled: true }, options); + } + + async findByUid(uid, options = {}) { + return this.findOne({ uid }, options); + } + + async findDueForSync(options = {}) { + const now = new Date(); + const calendars = await this.model.findAll({ + where: { + enabled: true, + }, + ...options, + }); + + return calendars.filter((calendar) => { + if (!calendar.last_sync_at) { + return true; + } + + const nextSyncTime = new Date( + calendar.last_sync_at.getTime() + + calendar.sync_interval_minutes * 60 * 1000 + ); + return now >= nextSyncTime; + }); + } + + async updateSyncStatus(calendarId, status, error = null, options = {}) { + const calendar = await this.findById(calendarId); + if (!calendar) { + throw new Error(`Calendar ${calendarId} not found`); + } + + return this.update( + calendar, + { + last_sync_at: new Date(), + last_sync_status: status, + ...(error && { last_sync_error: error }), + }, + options + ); + } + + async updateCTag(calendarId, ctag, options = {}) { + const calendar = await this.findById(calendarId); + if (!calendar) { + throw new Error(`Calendar ${calendarId} not found`); + } + + return this.update(calendar, { ctag }, options); + } + + async updateSyncToken(calendarId, syncToken, options = {}) { + const calendar = await this.findById(calendarId); + if (!calendar) { + throw new Error(`Calendar ${calendarId} not found`); + } + + return this.update(calendar, { sync_token: syncToken }, options); + } +} + +module.exports = new CalendarRepository(); diff --git a/backend/modules/caldav/repositories/override-repository.js b/backend/modules/caldav/repositories/override-repository.js new file mode 100644 index 0000000..9cf6208 --- /dev/null +++ b/backend/modules/caldav/repositories/override-repository.js @@ -0,0 +1,92 @@ +const BaseRepository = require('../../../shared/database/BaseRepository'); +const { CalDAVOccurrenceOverride } = require('../../../models'); + +class OverrideRepository extends BaseRepository { + constructor() { + super(CalDAVOccurrenceOverride); + } + + async findByParentTaskId(parentTaskId, options = {}) { + return this.findAll({ parent_task_id: parentTaskId }, options); + } + + async findByCalendarId(calendarId, options = {}) { + return this.findAll({ calendar_id: calendarId }, options); + } + + async findByRecurrenceId( + parentTaskId, + calendarId, + recurrenceId, + options = {} + ) { + return this.findOne( + { + parent_task_id: parentTaskId, + calendar_id: calendarId, + recurrence_id: recurrenceId, + }, + options + ); + } + + async createOrUpdate( + parentTaskId, + calendarId, + recurrenceId, + overrides, + options = {} + ) { + const existing = await this.findByRecurrenceId( + parentTaskId, + calendarId, + recurrenceId + ); + + if (existing) { + return this.update(existing, overrides, options); + } + + return this.create( + { + parent_task_id: parentTaskId, + calendar_id: calendarId, + recurrence_id: recurrenceId, + ...overrides, + }, + options + ); + } + + async deleteByRecurrenceId( + parentTaskId, + calendarId, + recurrenceId, + options = {} + ) { + const override = await this.findByRecurrenceId( + parentTaskId, + calendarId, + recurrenceId + ); + + if (override) { + return this.destroy(override, options); + } + + return null; + } + + async deleteAllForTask(parentTaskId, calendarId, options = {}) { + const overrides = await this.findAll( + { parent_task_id: parentTaskId, calendar_id: calendarId }, + options + ); + + return Promise.all( + overrides.map((override) => this.destroy(override, options)) + ); + } +} + +module.exports = new OverrideRepository(); diff --git a/backend/modules/caldav/repositories/remote-calendar-repository.js b/backend/modules/caldav/repositories/remote-calendar-repository.js new file mode 100644 index 0000000..28bd83a --- /dev/null +++ b/backend/modules/caldav/repositories/remote-calendar-repository.js @@ -0,0 +1,84 @@ +const BaseRepository = require('../../../shared/database/BaseRepository'); +const { CalDAVRemoteCalendar } = require('../../../models'); + +class RemoteCalendarRepository extends BaseRepository { + constructor() { + super(CalDAVRemoteCalendar); + } + + async findByUserId(userId, options = {}) { + return this.findAll({ user_id: userId }, options); + } + + async findEnabledByUserId(userId, options = {}) { + return this.findAll({ user_id: userId, enabled: true }, options); + } + + async findByLocalCalendarId(localCalendarId, options = {}) { + return this.findOne({ local_calendar_id: localCalendarId }, options); + } + + async findDueForSync(options = {}) { + const remoteCalendars = await this.findAll({ enabled: true }, options); + + const now = new Date(); + return remoteCalendars.filter((remote) => { + if (!remote.last_sync_at || !remote.LocalCalendar) { + return true; + } + + const syncInterval = + remote.LocalCalendar.sync_interval_minutes || 15; + const nextSyncTime = new Date( + remote.last_sync_at.getTime() + syncInterval * 60 * 1000 + ); + return now >= nextSyncTime; + }); + } + + async updateSyncStatus( + remoteCalendarId, + status, + error = null, + options = {} + ) { + const remoteCalendar = await this.findById(remoteCalendarId); + if (!remoteCalendar) { + throw new Error(`Remote calendar ${remoteCalendarId} not found`); + } + + return this.update( + remoteCalendar, + { + last_sync_at: new Date(), + last_sync_status: status, + last_sync_error: error, + }, + options + ); + } + + async updateServerCTag(remoteCalendarId, ctag, options = {}) { + const remoteCalendar = await this.findById(remoteCalendarId); + if (!remoteCalendar) { + throw new Error(`Remote calendar ${remoteCalendarId} not found`); + } + + return this.update(remoteCalendar, { server_ctag: ctag }, options); + } + + async updateServerSyncToken(remoteCalendarId, syncToken, options = {}) { + const remoteCalendar = await this.findById(remoteCalendarId); + if (!remoteCalendar) { + throw new Error(`Remote calendar ${remoteCalendarId} not found`); + } + + return this.update( + remoteCalendar, + { server_sync_token: syncToken }, + options + ); + } +} + +module.exports = new RemoteCalendarRepository(); diff --git a/backend/modules/caldav/repositories/sync-state-repository.js b/backend/modules/caldav/repositories/sync-state-repository.js new file mode 100644 index 0000000..da37c24 --- /dev/null +++ b/backend/modules/caldav/repositories/sync-state-repository.js @@ -0,0 +1,140 @@ +const BaseRepository = require('../../../shared/database/BaseRepository'); +const { CalDAVSyncState } = require('../../../models'); + +class SyncStateRepository extends BaseRepository { + constructor() { + super(CalDAVSyncState); + } + + async findByTaskId(taskId, options = {}) { + return this.findAll({ task_id: taskId }, options); + } + + async findByCalendarId(calendarId, options = {}) { + return this.findAll({ calendar_id: calendarId }, options); + } + + async findByTaskAndCalendar(taskId, calendarId, options = {}) { + return this.findOne( + { task_id: taskId, calendar_id: calendarId }, + options + ); + } + + async findByETag(etag, options = {}) { + return this.findOne({ etag }, options); + } + + async findConflicts(calendarId = null, options = {}) { + const where = { sync_status: 'conflict' }; + if (calendarId) { + where.calendar_id = calendarId; + } + return this.findAll(where, options); + } + + async createOrUpdate(taskId, calendarId, data, options = {}) { + const existing = await this.findByTaskAndCalendar(taskId, calendarId); + + if (existing) { + return this.update(existing, data, options); + } + + return this.create( + { + task_id: taskId, + calendar_id: calendarId, + ...data, + }, + options + ); + } + + async updateETag(taskId, calendarId, etag, options = {}) { + const syncState = await this.findByTaskAndCalendar(taskId, calendarId); + if (!syncState) { + throw new Error( + `Sync state not found for task ${taskId} and calendar ${calendarId}` + ); + } + + return this.update( + syncState, + { + etag, + last_modified: new Date(), + }, + options + ); + } + + async markSynced(taskId, calendarId, options = {}) { + const syncState = await this.findByTaskAndCalendar(taskId, calendarId); + if (!syncState) { + throw new Error( + `Sync state not found for task ${taskId} and calendar ${calendarId}` + ); + } + + return this.update( + syncState, + { + sync_status: 'synced', + last_synced_at: new Date(), + conflict_local_version: null, + conflict_remote_version: null, + conflict_detected_at: null, + }, + options + ); + } + + async markConflict( + taskId, + calendarId, + localVersion, + remoteVersion, + options = {} + ) { + const syncState = await this.findByTaskAndCalendar(taskId, calendarId); + if (!syncState) { + throw new Error( + `Sync state not found for task ${taskId} and calendar ${calendarId}` + ); + } + + return this.update( + syncState, + { + sync_status: 'conflict', + conflict_local_version: localVersion, + conflict_remote_version: remoteVersion, + conflict_detected_at: new Date(), + }, + options + ); + } + + async resolveConflict(taskId, calendarId, resolution, options = {}) { + const syncState = await this.findByTaskAndCalendar(taskId, calendarId); + if (!syncState) { + throw new Error( + `Sync state not found for task ${taskId} and calendar ${calendarId}` + ); + } + + return this.update( + syncState, + { + sync_status: 'synced', + conflict_local_version: null, + conflict_remote_version: null, + conflict_detected_at: null, + last_synced_at: new Date(), + }, + options + ); + } +} + +module.exports = new SyncStateRepository(); diff --git a/backend/modules/caldav/routes.js b/backend/modules/caldav/routes.js new file mode 100644 index 0000000..a87c2c9 --- /dev/null +++ b/backend/modules/caldav/routes.js @@ -0,0 +1,74 @@ +const express = require('express'); +const caldavAuth = require('./middleware/caldav-auth'); +const xmlParser = require('./middleware/xml-parser'); +const { handleWellKnown } = require('./protocol/discovery'); +const { handleOptions } = require('./webdav/options'); +const { handlePropfind } = require('./webdav/propfind'); +const { handleReport } = require('./webdav/report'); +const { + handleGetTask, + handlePutTask, + handleDeleteTask, +} = require('./webdav/task-handlers'); +const apiRoutes = require('./api/routes'); +const { requireAuth } = require('../../middleware/auth'); + +const router = express.Router(); + +router.get('/.well-known/caldav', handleWellKnown); + +router.options( + '/caldav/:username/tasks/', + xmlParser, + caldavAuth, + handleOptions +); +router.options( + '/caldav/:username/tasks/:uid', + xmlParser, + caldavAuth, + handleOptions +); + +function registerMethod(method, path, handler) { + router.all(path, xmlParser, caldavAuth, (req, res, next) => { + if (req.method === method) { + return handler(req, res, next); + } + next(); + }); +} + +registerMethod('PROPFIND', '/caldav/:username/tasks/', handlePropfind); +registerMethod('PROPFIND', '/caldav/:username/tasks/:uid', handlePropfind); + +registerMethod('REPORT', '/caldav/:username/tasks/', handleReport); + +router.get('/caldav/:username/tasks/', xmlParser, caldavAuth, (req, res) => { + res.status(207).send( + '+ {calendar.description} +
+ )} + ++ {formatLastSync(calendar.last_sync_at)} +
++ {calendar.sync_interval_minutes}{' '} + {t('profile.caldav.minutes', 'min')} +
++ {getSyncDirectionLabel()} +
++ {calendar.stats.synced} /{' '} + {calendar.stats.total} +
++ {t('common.loading', 'Loading...')} +
++ {t( + 'profile.conflictResolver.noConflictsDescription', + 'There are no conflicts to resolve for this calendar.' + )} +
+ ++ {t( + 'profile.conflictResolver.conflictCount', + 'Conflict {{current}} of {{total}}', + { + current: currentIndex + 1, + total: conflicts.length, + } + )} +
++ {t( + 'profile.conflictResolver.detectedAt', + 'Detected: {{time}}', + { + time: format( + new Date( + currentConflict.conflict_detected_at + ), + 'PPpp' + ), + } + )} +
++ {t( + 'profile.conflictResolver.chooseVersion', + 'Choose which version to keep for each field:' + )} +
+ ++ {t( + 'profile.caldavWizard.testDescription', + 'Test the connection to your CalDAV server before proceeding.' + )} +
+ ++ {testResult.message} +
++ {t( + 'profile.caldavWizard.syncIntervalHelp', + 'How often to automatically sync (5-1440 minutes)' + )} +
++ {t( + 'profile.caldavWizard.reviewDescription', + 'Review your settings before creating the calendar.' + )} +
+ ++ {error} +
+