diff --git a/server/routes/calendar.js b/server/routes/calendar.js index 9d212b8..6aa245f 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -172,7 +172,7 @@ router.get('/events/:year/:month', async (req, res) => { res.json(events); } } catch (error) { - console.error('Error retrieving calendar events:', error); + console.error('Error retrieving calendar events:', error.message); res.status(500).json({ error: 'Failed to retrieve calendar events' }); } }); @@ -208,7 +208,7 @@ router.post('/events', async (req, res) => { const event = await CalendarService.createEvent(userConfig, date, text); res.status(201).json(event); } catch (error) { - console.error('Error creating calendar event:', error); + console.error('Error creating calendar event:', error.message); res.status(500).json({ error: 'Failed to create calendar event' }); } }); diff --git a/server/services/CalendarService.js b/server/services/CalendarService.js index d3954dc..b96a0e8 100644 --- a/server/services/CalendarService.js +++ b/server/services/CalendarService.js @@ -6,11 +6,6 @@ const http = require('http'); const { URL } = require('url'); class CalendarService { - constructor() { - this.events = new Map(); // In-memory storage for demo purposes - // TODO: Replace with actual CalDAV operations when library is available - } - /** * Create a calendar event * @param {Object} userConfig - Nextcloud user configuration @@ -20,33 +15,40 @@ class CalendarService { */ async createEvent(userConfig, date, text) { try { - // TODO: Implement actual CalDAV event creation - // For now, simulate the operation with local storage - + // Validate input parameters + if (!userConfig) { + throw new Error('User configuration is required'); + } + if (!userConfig.name) { + throw new Error('User name is required'); + } + if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + throw new Error('Valid date in YYYY-MM-DD format is required'); + } + if (!text || text.trim().length === 0) { + throw new Error('Event text is required'); + } + + // Generate event ID and structure const eventId = crypto.randomUUID(); const event = { id: eventId, date: date, - text: text, + text: text.trim(), user: userConfig.name, - summary: text, + summary: text.trim(), dtstart: this._formatDateForCalDAV(date), dtend: this._formatDateForCalDAV(date), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; - this.events.set(eventId, event); + // Create event via CalDAV + const caldavEvent = await this._createCalDAVEvent(userConfig, event); - console.log(`Calendar event created for ${userConfig.name}: ${text} on ${date}`); - - // TODO: When CalDAV library is available, replace with: - // const caldavEvent = await this._createCalDAVEvent(userConfig, event); - - return event; + return caldavEvent; } catch (error) { - console.error('Error creating calendar event:', error); - throw new Error('Failed to create calendar event'); + throw new Error(`Failed to create calendar event: ${error.message}`); } } @@ -59,26 +61,23 @@ class CalendarService { */ async getEventsForMonth(userConfig, year, month) { try { - // TODO: Implement actual CalDAV event retrieval - // For now, filter local events - - const events = Array.from(this.events.values()); - const filteredEvents = events.filter(event => { - if (event.user !== userConfig.name) return false; - const eventDate = new Date(event.date); - return eventDate.getFullYear() === parseInt(year) && - (eventDate.getMonth() + 1) === parseInt(month); - }); + // Validate input parameters + if (!userConfig || !userConfig.name) { + throw new Error('Valid user configuration is required'); + } + if (!year || !/^\d{4}$/.test(year)) { + throw new Error('Valid year in YYYY format is required'); + } + if (!month || !/^(0[1-9]|1[0-2])$/.test(month)) { + throw new Error('Valid month in MM format is required'); + } - console.log(`Retrieved ${filteredEvents.length} events for ${userConfig.name} in ${year}-${month}`); + // Fetch events from CalDAV + const events = await this._fetchCalDAVEvents(userConfig, year, month); - // TODO: When CalDAV library is available, replace with: - // const caldavEvents = await this._fetchCalDAVEvents(userConfig, year, month); - - return filteredEvents; + return events; } catch (error) { - console.error('Error retrieving calendar events:', error); - throw new Error('Failed to retrieve calendar events'); + throw new Error(`Failed to retrieve calendar events: ${error.message}`); } } @@ -119,32 +118,23 @@ class CalendarService { */ async updateEvent(userConfig, eventId, date, text) { try { - const event = this.events.get(eventId); - if (!event) { - throw new Error('Event not found'); - } + // Build updated event object + const event = { + id: eventId, + date: date, + text: text, + user: userConfig.name, + summary: text, + dtstart: this._formatDateForCalDAV(date), + dtend: this._formatDateForCalDAV(date), + updatedAt: new Date().toISOString() + }; - if (event.user !== userConfig.name) { - throw new Error('Permission denied: You can only edit your own events'); - } - - event.date = date; - event.text = text; - event.summary = text; - event.dtstart = this._formatDateForCalDAV(date); - event.dtend = this._formatDateForCalDAV(date); - event.updatedAt = new Date().toISOString(); - - this.events.set(eventId, event); + // Update event via CalDAV + const updatedEvent = await this._updateCalDAVEvent(userConfig, event); - console.log(`Calendar event updated for ${userConfig.name}: ${eventId}`); - - // TODO: When CalDAV library is available, replace with: - // await this._updateCalDAVEvent(userConfig, event); - - return event; + return updatedEvent; } catch (error) { - console.error('Error updating calendar event:', error); throw error; } } @@ -157,25 +147,11 @@ class CalendarService { */ async deleteEvent(userConfig, eventId) { try { - const event = this.events.get(eventId); - if (!event) { - throw new Error('Event not found'); - } - - if (event.user !== userConfig.name) { - throw new Error('Permission denied: You can only delete your own events'); - } - - this.events.delete(eventId); - - console.log(`Calendar event deleted for ${userConfig.name}: ${eventId}`); - - // TODO: When CalDAV library is available, replace with: - // await this._deleteCalDAVEvent(userConfig, eventId); + // Delete event via CalDAV + await this._deleteCalDAVEvent(userConfig, eventId); return true; } catch (error) { - console.error('Error deleting calendar event:', error); throw error; } } @@ -187,8 +163,14 @@ class CalendarService { */ async testConnection(userConfig) { try { + // Validate user configuration + if (!userConfig.nextcloudUrl || !userConfig.username || !userConfig.password) { + return false; + } + // Basic HTTP test to verify Nextcloud instance is reachable const url = new URL(userConfig.nextcloudUrl); + const options = { hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), @@ -202,38 +184,44 @@ class CalendarService { const protocol = url.protocol === 'https:' ? https : http; - return new Promise((resolve) => { + const result = await new Promise((resolve) => { const req = protocol.request(options, (res) => { let data = ''; - res.on('data', chunk => data += chunk); + res.on('data', chunk => { + data += chunk; + }); + res.on('end', () => { try { const status = JSON.parse(data); const isNextcloud = status.productname && status.productname.toLowerCase().includes('nextcloud'); - console.log(`Connection test for ${userConfig.name}: ${isNextcloud ? 'SUCCESS' : 'NOT_NEXTCLOUD'}`); resolve(isNextcloud); } catch (e) { - console.log(`Connection test for ${userConfig.name}: INVALID_RESPONSE`); resolve(false); } }); }); - req.on('error', (error) => { - console.log(`Connection test for ${userConfig.name}: ERROR - ${error.message}`); + req.on('error', () => { resolve(false); }); req.on('timeout', () => { - console.log(`Connection test for ${userConfig.name}: TIMEOUT`); req.destroy(); resolve(false); }); req.end(); }); + + // If basic connection works, test CalDAV endpoint + if (result) { + const caldavResult = await this._testCalDAVConnection(userConfig); + return caldavResult; + } + + return result; } catch (error) { - console.error('Error testing connection:', error); return false; } } @@ -247,44 +235,520 @@ class CalendarService { return date.toISOString().slice(0, 10).replace(/-/g, ''); } - // TODO: Implement these methods when @nextcloud/cdav-library is available - /* + /** + * Test CalDAV connection by trying to access the calendar endpoint + * @private + */ + async _testCalDAVConnection(userConfig) { + try { + // Try to access the CalDAV principal endpoint + const caldavUrl = this._buildCalDAVUrl(userConfig); + + const url = new URL(caldavUrl); + const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64'); + + const options = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname, + method: 'PROPFIND', + timeout: 10000, + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/xml; charset=utf-8', + 'Depth': '0', + 'User-Agent': 'OpenWall/1.0 CalDAV-Client' + } + }; + + const protocol = url.protocol === 'https:' ? https : http; + + const body = ` + + + + + + +`; + + return new Promise((resolve) => { + const req = protocol.request(options, (res) => { + let data = ''; + res.on('data', chunk => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(true); + } else { + resolve(false); + } + }); + }); + + req.on('error', () => { + resolve(false); + }); + + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + + req.write(body); + req.end(); + }); + } catch (error) { + return false; + } + } + + /** + * Build CalDAV URL for a user + * @private + */ + _buildCalDAVUrl(userConfig) { + // Remove trailing slash from nextcloudUrl if present + const baseUrl = userConfig.nextcloudUrl.replace(/\/$/, ''); + + // Build CalDAV principal URL - this is the standard Nextcloud CalDAV path + const calendarName = userConfig.calendarName || 'personal'; + return `${baseUrl}/remote.php/dav/calendars/${userConfig.username}/${calendarName}/`; + } + + /** + * Build CalDAV principals URL for a user + * @private + */ + _buildCalDAVPrincipalsUrl(userConfig) { + const baseUrl = userConfig.nextcloudUrl.replace(/\/$/, ''); + return `${baseUrl}/remote.php/dav/principals/users/${userConfig.username}/`; + } + + /** + * Create a CalDAV event + * @private + */ async _createCalDAVEvent(userConfig, event) { - const { createClient } = require('@nextcloud/cdav-library'); - const client = createClient({ - url: userConfig.nextcloudUrl, - username: userConfig.username, - password: userConfig.password - }); - - // Create ICS event content - const icsContent = this._generateICSEvent(event); - - // Create event on Nextcloud - const result = await client.createEvent(icsContent); - return result; + try { + const caldavUrl = this._buildCalDAVUrl(userConfig); + const eventUrl = `${caldavUrl}${event.id}.ics`; + + const url = new URL(eventUrl); + const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64'); + + const icsContent = this._generateICSEvent(event); + + const options = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname, + method: 'PUT', + timeout: 10000, + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'text/calendar; charset=utf-8', + 'Content-Length': Buffer.byteLength(icsContent, 'utf8'), + 'User-Agent': 'OpenWall/1.0 CalDAV-Client', + 'If-None-Match': '*' // Prevent overwriting existing events + } + }; + + const protocol = url.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const req = protocol.request(options, (res) => { + let data = ''; + res.on('data', chunk => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(event); + } else if (res.statusCode === 401) { + reject(new Error('CalDAV authentication failed')); + } else if (res.statusCode === 412) { + reject(new Error('CalDAV event already exists')); + } else { + reject(new Error(`CalDAV event creation failed with status ${res.statusCode}: ${data}`)); + } + }); + }); + + req.on('error', (error) => { + reject(new Error(`CalDAV request failed: ${error.message}`)); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('CalDAV request timeout')); + }); + + req.write(icsContent); + req.end(); + }); + } catch (error) { + throw error; + } } + /** + * Fetch CalDAV events + * @private + */ async _fetchCalDAVEvents(userConfig, year, month) { - const { createClient } = require('@nextcloud/cdav-library'); - const client = createClient({ - url: userConfig.nextcloudUrl, - username: userConfig.username, - password: userConfig.password - }); - - const startDate = new Date(year, month - 1, 1); - const endDate = new Date(year, month, 0); - - const events = await client.fetchCalendarEvents({ - start: startDate, - end: endDate - }); - - return events; + try { + const caldavUrl = this._buildCalDAVUrl(userConfig); + + const url = new URL(caldavUrl); + const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64'); + + // Build date range for the month + const startDate = new Date(parseInt(year), parseInt(month) - 1, 1); + const endDate = new Date(parseInt(year), parseInt(month), 0); + + const startDateStr = startDate.toISOString().slice(0, 10).replace(/-/g, '') + 'T000000Z'; + const endDateStr = endDate.toISOString().slice(0, 10).replace(/-/g, '') + 'T235959Z'; + + const reportBody = ` + + + + + + + + + + + + +`; + + const options = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname, + method: 'REPORT', + timeout: 10000, + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/xml; charset=utf-8', + 'Content-Length': Buffer.byteLength(reportBody, 'utf8'), + 'Depth': '1', + 'User-Agent': 'OpenWall/1.0 CalDAV-Client' + } + }; + + const protocol = url.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const req = protocol.request(options, (res) => { + let data = ''; + res.on('data', chunk => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + const events = this._parseCalDAVResponse(data, userConfig); + resolve(events); + } catch (parseError) { + reject(parseError); + } + } else if (res.statusCode === 401) { + reject(new Error('CalDAV authentication failed')); + } else { + reject(new Error(`CalDAV event fetch failed with status ${res.statusCode}: ${data}`)); + } + }); + }); + + req.on('error', (error) => { + reject(new Error(`CalDAV request failed: ${error.message}`)); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('CalDAV request timeout')); + }); + + req.write(reportBody); + req.end(); + }); + } catch (error) { + throw error; + } } + /** + * Parse CalDAV response and extract events + * @private + */ + _parseCalDAVResponse(xmlData, userConfig) { + try { + // This is a basic parser - in a real implementation, you'd use a proper XML parser + const events = []; + + // Look for calendar-data with different possible namespace prefixes + const eventMatches = xmlData.match(/<(?:C:|cal:)?calendar-data[^>]*>([\s\S]*?)<\/(?:C:|cal:)?calendar-data>/gi); + + if (eventMatches) { + eventMatches.forEach((match, index) => { + try { + // Extract the VCALENDAR content - handle CDATA and escaped content + let icsContent = match + .replace(//g, '$1') // Remove CDATA wrapper + .replace(/<[^>]*>/g, '') // Remove XML tags + .replace(/</g, '<') // Decode HTML entities + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .trim(); + + const event = this._parseICSEvent(icsContent, userConfig); + + if (event) { + events.push(event); + } + } catch (eventError) { + // Skip individual events that fail to parse + } + }); + } + + return events; + } catch (error) { + throw new Error(`Failed to parse CalDAV response: ${error.message}`); + } + } + + /** + * Parse ICS event content + * @private + */ + _parseICSEvent(icsContent, userConfig) { + try { + const lines = icsContent.split('\n').map(line => line.trim()).filter(line => line); + + const event = { + user: userConfig.name, + source: 'caldav' + }; + + for (const line of lines) { + if (line.startsWith('UID:')) { + event.id = line.substring(4); + } else if (line.startsWith('SUMMARY:')) { + event.text = line.substring(8); + event.summary = event.text; + } else if (line.startsWith('DTSTART')) { + const dateValue = line.split(':')[1]; + event.dtstart = dateValue; + // Parse date more robustly + if (dateValue && dateValue.length >= 8) { + // Handle different DTSTART formats: YYYYMMDD, YYYYMMDDTHHMMSSZ, etc. + const dateOnly = dateValue.substring(0, 8); + if (/^\d{8}$/.test(dateOnly)) { + const year = parseInt(dateOnly.substring(0, 4)); + const month = parseInt(dateOnly.substring(4, 6)); + const day = parseInt(dateOnly.substring(6, 8)); + + // Validate the parsed date and ensure it's reasonable (not from 1981!) + if (year >= 2020 && year <= 2030 && month >= 1 && month <= 12 && day >= 1 && day <= 31) { + const parsedDate = new Date(year, month - 1, day); + if (!isNaN(parsedDate.getTime()) && + parsedDate.getFullYear() === year && + parsedDate.getMonth() === (month - 1) && + parsedDate.getDate() === day) { + event.date = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`; + } + } + } + } + } else if (line.startsWith('DTEND')) { + const dateValue = line.split(':')[1]; + event.dtend = dateValue; + // If we don't have a start date yet, try to get it from end date + if (!event.date && dateValue && dateValue.length >= 8) { + const dateOnly = dateValue.substring(0, 8); + if (/^\d{8}$/.test(dateOnly)) { + const year = parseInt(dateOnly.substring(0, 4)); + const month = parseInt(dateOnly.substring(4, 6)); + const day = parseInt(dateOnly.substring(6, 8)); + + // Validate the parsed date and ensure it's reasonable (not from 1981!) + if (year >= 2020 && year <= 2030 && month >= 1 && month <= 12 && day >= 1 && day <= 31) { + const parsedDate = new Date(year, month - 1, day); + if (!isNaN(parsedDate.getTime()) && + parsedDate.getFullYear() === year && + parsedDate.getMonth() === (month - 1) && + parsedDate.getDate() === day) { + event.date = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`; + } + } + } + } + } else if (line.startsWith('CREATED:')) { + event.createdAt = line.substring(8); + } else if (line.startsWith('LAST-MODIFIED:')) { + event.updatedAt = line.substring(14); + } + } + + // Only return events with required fields and valid dates + if (event.id && event.text && event.date) { + return event; + } else { + return null; + } + } catch (error) { + return null; + } + } + + /** + * Update a CalDAV event + * @private + */ + async _updateCalDAVEvent(userConfig, event) { + try { + const caldavUrl = this._buildCalDAVUrl(userConfig); + const eventUrl = `${caldavUrl}${event.id}.ics`; + + const url = new URL(eventUrl); + const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64'); + + const icsContent = this._generateICSEvent(event); + + const options = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname, + method: 'PUT', + timeout: 10000, + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'text/calendar; charset=utf-8', + 'Content-Length': Buffer.byteLength(icsContent, 'utf8'), + 'User-Agent': 'OpenWall/1.0 CalDAV-Client' + } + }; + + const protocol = url.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const req = protocol.request(options, (res) => { + let data = ''; + res.on('data', chunk => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(event); + } else if (res.statusCode === 401) { + reject(new Error('CalDAV authentication failed')); + } else if (res.statusCode === 404) { + reject(new Error('Event not found')); + } else { + reject(new Error(`CalDAV event update failed with status ${res.statusCode}: ${data}`)); + } + }); + }); + + req.on('error', (error) => { + reject(new Error(`CalDAV request failed: ${error.message}`)); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('CalDAV request timeout')); + }); + + req.write(icsContent); + req.end(); + }); + } catch (error) { + throw error; + } + } + + /** + * Delete a CalDAV event + * @private + */ + async _deleteCalDAVEvent(userConfig, eventId) { + try { + const caldavUrl = this._buildCalDAVUrl(userConfig); + const eventUrl = `${caldavUrl}${eventId}.ics`; + + const url = new URL(eventUrl); + const auth = Buffer.from(`${userConfig.username}:${userConfig.password}`).toString('base64'); + + const options = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname, + method: 'DELETE', + timeout: 10000, + headers: { + 'Authorization': `Basic ${auth}`, + 'User-Agent': 'OpenWall/1.0 CalDAV-Client' + } + }; + + const protocol = url.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const req = protocol.request(options, (res) => { + let data = ''; + res.on('data', chunk => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(true); + } else if (res.statusCode === 401) { + reject(new Error('CalDAV authentication failed')); + } else if (res.statusCode === 404) { + reject(new Error('Event not found')); + } else { + reject(new Error(`CalDAV event deletion failed with status ${res.statusCode}: ${data}`)); + } + }); + }); + + req.on('error', (error) => { + reject(new Error(`CalDAV request failed: ${error.message}`)); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('CalDAV request timeout')); + }); + + req.end(); + }); + } catch (error) { + throw error; + } + } + + /** + * Generate ICS event content + * @private + */ _generateICSEvent(event) { + const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + const created = event.createdAt ? new Date(event.createdAt).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' : now; + const modified = event.updatedAt ? new Date(event.updatedAt).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' : now; + return `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//OpenWall//Calendar//EN @@ -294,12 +758,12 @@ DTSTART;VALUE=DATE:${event.dtstart} DTEND;VALUE=DATE:${event.dtend} SUMMARY:${event.summary} DESCRIPTION:${event.text} -CREATED:${new Date(event.createdAt).toISOString().replace(/[-:]/g, '').split('.')[0]}Z -LAST-MODIFIED:${new Date(event.updatedAt).toISOString().replace(/[-:]/g, '').split('.')[0]}Z +CREATED:${created} +LAST-MODIFIED:${modified} +DTSTAMP:${now} END:VEVENT END:VCALENDAR`; } - */ } module.exports = new CalendarService();