Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion helpers/carddav-xml.js
Original file line number Diff line number Diff line change
Expand Up @@ -655,12 +655,53 @@ function getSyncCollectionXML(addressBook, changes, props) {
return xml.end({ pretty: true });
}

/**
* Validate vCard content before processing
* @param {string} vCardString - vCard string to validate
* @throws {Error} If vCard is invalid
*/
function validateVCard(vCardString) {
// Validate input type
if (typeof vCardString !== 'string') {
throw new TypeError('vCard input must be a string');
}

// Check size limit (1MB as advertised in max-resource-size)
if (vCardString.length > 1024 * 1024) {
throw new Error('vCard exceeds maximum size of 1MB');
}

// Validate basic vCard structure
if (!vCardString.includes('BEGIN:VCARD')) {
throw new Error('vCard must contain BEGIN:VCARD');
}

if (!vCardString.includes('END:VCARD')) {
throw new Error('vCard must contain END:VCARD');
}

// Validate VERSION property exists (required by RFC 6350)
if (!/^VERSION:[34]\.\d/m.test(vCardString)) {
throw new Error('vCard must contain valid VERSION property (3.0 or 4.0)');
}

// Validate FN property exists (required by RFC 6350)
if (!/^FN:/m.test(vCardString)) {
throw new Error('vCard must contain FN (Formatted Name) property');
}

return true;
}

/**
* Parse vCard string into a JavaScript object (EXISTING FUNCTION - PRESERVED)
* @param {string} vCardString - vCard string to parse
* @returns {Object} - Parsed vCard as JavaScript object
*/
function parseVCard(vCardString) {
// Validate before parsing
validateVCard(vCardString);

const result = {};
const lines = vCardString.split(/\r\n|\r|\n/);
let currentKey = null;
Expand Down Expand Up @@ -740,5 +781,8 @@ module.exports = {
getAddressbookPropfindXML,

// Entity encoding function
encodeXMLEntities
encodeXMLEntities,

// vCard validation
validateVCard
};
2 changes: 1 addition & 1 deletion helpers/ensure-default-address-book.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ async function ensureDefaultAddressBook(ctx) {
// TODO: isn't this automatic (?)
// TODO: if we need to change /default here (?)
// TODO: fix port if 443 or 80 then don't render it (?)
url: `${ctx.instance.config.protocol}://${ctx.instance.config.host}:${ctx.instance.config.port}/dav/${ctx.state.session.user.email}/addressbooks/default/`,
url: `${ctx.instance.config.protocol}://${ctx.instance.config.host}:${ctx.instance.config.port}/dav/${ctx.state.session.user.username}/addressbooks/default/`,
// TODO: isn't this automatic (?)
prodId: `//forwardemail.net//carddav//EN`
});
Expand Down
189 changes: 170 additions & 19 deletions routes/carddav/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ davRouter.all('/:user/addressbooks/:addressbook/:contact(.+)', async (ctx) => {
if (!['PROPFIND', 'GET', 'PUT', 'DELETE'].includes(ctx.method))
throw Boom.methodNotAllowed();

const { addressbook, contact } = ctx.params;
const { addressbook, contact: rawContact } = ctx.params;

// Normalize contact_id by removing .vcf extension if present
// macOS and other clients may or may not include the extension
const contact = rawContact.replace(/\.vcf$/i, '');

// Find address book
const addressBook = await AddressBooks.findOne(
Expand Down Expand Up @@ -126,10 +130,12 @@ davRouter.all('/:user/addressbooks/:addressbook/:contact(.+)', async (ctx) => {
: null;
const props = xmlHelpers.extractRequestedProps(xmlBody);

// Create response
// Create response - include .vcf extension for client compatibility
const xml = xmlHelpers.getPropfindContactXML(
{
href: `/dav/${ctx.params.user}/addressbooks/${addressbook}/${contact}`,
href: `/dav/${
ctx.params.user
}/addressbooks/${addressbook}/${contact}${vcf(contact)}`,
etag: contactObj.etag,
content: contactObj.content
},
Expand Down Expand Up @@ -171,7 +177,7 @@ davRouter.all('/:user/addressbooks/:addressbook/:contact(.+)', async (ctx) => {
// Get vCard content
const vCardContent = ctx.request.body.toString();

// Parse vCard to extract properties
// Parse vCard to extract properties (this also validates the vCard)
const vCard = xmlHelpers.parseVCard(vCardContent);

// Check if contact already exists
Expand All @@ -184,6 +190,14 @@ davRouter.all('/:user/addressbooks/:addressbook/:contact(.+)', async (ctx) => {
}
);

// Check If-None-Match header (RFC 2616 Section 14.26)
const ifNoneMatch = ctx.request.headers['if-none-match'];
if (ifNoneMatch === '*' && existingContact) {
throw Boom.preconditionFailed(
ctx.translateError('RESOURCE_ALREADY_EXISTS')
);
}

// Generate ETag
const newEtag = xmlHelpers.generateETag(vCardContent);

Expand Down Expand Up @@ -320,7 +334,9 @@ davRouter.all('/:user/addressbooks/:addressbook/:contact(.+)', async (ctx) => {
});

davRouter.all('/:user/addressbooks/:addressbook', async (ctx) => {
if (!['PROPFIND', 'MKCOL', 'DELETE', 'REPORT'].includes(ctx.method))
if (
!['PROPFIND', 'MKCOL', 'DELETE', 'REPORT', 'PROPPATCH'].includes(ctx.method)
)
throw Boom.methodNotAllowed();

const { addressbook } = ctx.params;
Expand Down Expand Up @@ -387,7 +403,9 @@ davRouter.all('/:user/addressbooks/:addressbook', async (ctx) => {

for (const contact of contacts) {
responses.push({
href: `/dav/${ctx.params.user}/addressbooks/${addressbook}/${contact.contact_id}`,
href: `/dav/${ctx.params.user}/addressbooks/${addressbook}/${
contact.contact_id
}${vcf(contact.contact_id)}`,
propstat: [
{
props: [
Expand Down Expand Up @@ -520,6 +538,117 @@ davRouter.all('/:user/addressbooks/:addressbook', async (ctx) => {
break;
}

case 'PROPPATCH': {
// Parse XML request body
const xmlBody = ctx.request.body
? await xmlHelpers.parseXML(ctx.request.body.toString())
: null;

if (!xmlBody || !xmlBody.propertyupdate)
throw Boom.badRequest(ctx.translateError('INVALID_XML_REQUEST_BODY'));

// db virtual helper
addressBook.instance = ctx.instance;
addressBook.session = ctx.state.session;
addressBook.isNew = false;

// Track which properties were successfully updated
const updatedProps = [];
const failedProps = [];

// Handle set operations
if (xmlBody.propertyupdate.set) {
const setOperations = Array.isArray(xmlBody.propertyupdate.set)
? xmlBody.propertyupdate.set
: [xmlBody.propertyupdate.set];

for (const setOp of setOperations) {
if (!setOp.prop) continue;

const props = setOp.prop;

// Update displayname
if (props.displayname) {
addressBook.name = props.displayname;
updatedProps.push('displayname');
}

// Update addressbook-description
if (props['addressbook-description']) {
addressBook.description = props['addressbook-description'];
updatedProps.push('addressbook-description');
}

// Update color (if provided)
if (props['calendar-color'] || props.color) {
addressBook.color = props['calendar-color'] || props.color;
updatedProps.push('calendar-color');
}
}
}

// Handle remove operations
if (xmlBody.propertyupdate.remove) {
const removeOperations = Array.isArray(xmlBody.propertyupdate.remove)
? xmlBody.propertyupdate.remove
: [xmlBody.propertyupdate.remove];

for (const removeOp of removeOperations) {
if (!removeOp.prop) continue;

const props = removeOp.prop;

// Remove description
if (props['addressbook-description']) {
addressBook.description = '';
updatedProps.push('addressbook-description');
}
}
}

// Save changes
if (updatedProps.length > 0) {
await addressBook.save();

// Update sync token
addressBook.synctoken = `${
config.urls.web
}/ns/sync-token/${Date.now()}`;
await addressBook.save();
}

// Build response
const responses = [
{
href: `/dav/${ctx.params.user}/addressbooks/${addressbook}/`,
propstat: []
}
];

// Add success propstat if there are updated props
if (updatedProps.length > 0) {
responses[0].propstat.push({
props: updatedProps.map((name) => ({ name, value: '' })),
status: '200 OK'
});
}

// Add failed propstat if there are failed props
if (failedProps.length > 0) {
responses[0].propstat.push({
props: failedProps.map((name) => ({ name, value: '' })),
status: '403 Forbidden'
});
}

const xml = xmlHelpers.getMultistatusXML(responses);

ctx.type = 'application/xml';
ctx.status = 207;
ctx.body = xml;
break;
}

default: {
throw Boom.methodNotAllowed();
}
Expand Down Expand Up @@ -755,10 +884,12 @@ async function handleAddressbookMultiget(ctx, xmlBody, addressBook) {
return;
}

// Get contact IDs from hrefs - preserve .vcf extension
// Get contact IDs from hrefs - normalize by removing .vcf extension
const contactIds = hrefs.map((href) => {
const parts = href.split('/');
return parts[parts.length - 1];
const filename = parts[parts.length - 1];
// Remove .vcf extension if present for consistent lookups
return filename.replace(/\.vcf$/i, '');
});

// Build query for specific contacts using actual model structure
Expand Down Expand Up @@ -802,8 +933,15 @@ async function handleSyncCollection(ctx, xmlBody, addressBook) {

// Extract sync token
let syncToken = null;
let syncTimestamp = null;
if (xmlBody['sync-collection']['sync-token']) {
syncToken = xmlBody['sync-collection']['sync-token'];

// Parse timestamp from sync token (format: http://domain.com/ns/sync-token/1234567890)
const match = syncToken.match(/\/sync-token\/(\d+)$/);
if (match && match[1]) {
syncTimestamp = new Date(Number.parseInt(match[1], 10));
}
}

// Extract props
Expand All @@ -817,34 +955,47 @@ async function handleSyncCollection(ctx, xmlBody, addressBook) {
// Get changes since sync token
let changes = [];

if (syncToken) {
// TODO: Implement proper sync token handling
// For now, just return all contacts
const contacts = await Contacts.find(ctx.instance, ctx.state.session, {
address_book: addressBook._id
});
if (syncTimestamp && !Number.isNaN(syncTimestamp.getTime())) {
// Return only contacts modified after the sync token timestamp
const modifiedContacts = await Contacts.find(
ctx.instance,
ctx.state.session,
{
address_book: addressBook._id,
updated_at: { $gt: syncTimestamp }
}
);

changes = contacts.map((contact) => ({
href: `/dav/${ctx.params.user}/addressbooks/${addressbook}/${contact.contact_id}`,
changes = modifiedContacts.map((contact) => ({
href: `/dav/${ctx.params.user}/addressbooks/${addressbook}/${
contact.contact_id
}${vcf(contact.contact_id)}`,
etag: contact.etag,
vcard: contact.content,
deleted: false
}));

// TODO: Track deleted contacts
// For now, we don't track deletions separately
// In a full implementation, you'd need a "deleted_contacts" table
// or a soft-delete flag with deleted_at timestamp
} else {
// If no sync token, return all contacts
// If no sync token or invalid token, return all contacts (initial sync)
const contacts = await Contacts.find(ctx.instance, ctx.state.session, {
address_book: addressBook._id
});

changes = contacts.map((contact) => ({
href: `/dav/${ctx.params.user}/addressbooks/${addressbook}/${contact.contact_id}`,
href: `/dav/${ctx.params.user}/addressbooks/${addressbook}/${
contact.contact_id
}${vcf(contact.contact_id)}`,
etag: contact.etag,
vcard: contact.content,
deleted: false
}));
}

// Generate XML response
// Generate XML response with updated sync token
const xml = xmlHelpers.getSyncCollectionXML(addressBook, changes, props);

ctx.type = 'application/xml';
Expand Down
Loading
Loading