diff --git a/Changelog.md b/Changelog.md index c94f4df..94cb0a7 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,26 @@ # Changelog for nextcloud api +## Version 14.3.0 (unreleased) +- Add calendar (CalDAV) and contacts (CardDAV) support (issue #59): + - Calendars: list calendars, read all entries of a calendar, query the events + overlapping a time range, read a single entry, create/replace and delete + entries, and create and delete calendars + - The time-range query optionally lets the server expand recurring events + into one occurrence per repetition (`getCalendarEntriesInRange(..., true)`) + instead of returning the stored event with its recurrence rule + - Address books: list address books, read all contacts, read a single + contact, create/replace and delete contacts, and create and delete address + books + - Entries are exchanged as raw iCalendar/vCard documents, so the library + gains no iCalendar or vCard dependency and callers stay free to parse them + with the library of their choice (e.g. ical4j or ez-vcard) + - Updates accept the etag of the entry they are based on, so a concurrent + change is reported instead of silently overwritten + - DAV paths use the internal user id rather than the login name, so they are + also correct when the two differ (external user backends) +- `NextcloudApiException` gained a `(String message, Throwable cause)` + constructor so failures can be reported with both context and cause + ## Version 14.2.1 - 2026-08-11 - Updated dependencies: diff --git a/README.md b/README.md index dcfebba..77f4a51 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Java api library to access nextcloud features from java applications - Management of groups - Folder management (Without access control) - List shares and create new file shares (No way to delete/update shares atm.) +- Calendars (CalDAV) and contacts (CardDAV), see below - Tested against nextCloud 31.0.0 server version, but should also work with older nextCloud and ownCloud systems ## Usage @@ -23,6 +24,29 @@ Java api library to access nextcloud features from java applications ``` +### Trying a pre-release snapshot + +Unreleased work is published as a `-SNAPSHOT` to the Central Portal snapshot +repository so it can be tried before a release. Snapshots are overwritten by +later builds and Sonatype removes them after about 90 days, so don't depend on +one from a production build. + +```xml + + + central-snapshots + https://central.sonatype.com/repository/maven-snapshots/ + true + + + + + org.aarboard.nextcloud + nextcloud-api + 14.3.0-SNAPSHOT + +``` + - The 14.x versions require Java 11+,as the jakarta.xml binding requires Java 11+ - The 13.x versions are now using the jakarta.xml binding stuff, to prevent problems with Java 11+ No API changes have been made in v13, but at some places the XML stuff is exposed @@ -30,6 +54,47 @@ Java api library to access nextcloud features from java applications - Create a NextcloudConnector instance and provide your server settings and authentification - Now you can use the methods exposed to access your nextcloud instance +## Calendars and contacts + +Calendar entries and contacts are exchanged as raw iCalendar/vCard documents. +The library does not parse them, so it needs no iCalendar or vCard dependency +and you stay free to use the parser of your choice (for example +[ical4j](https://github.com/ical4j/ical4j) or +[ez-vcard](https://github.com/mangstadt/ez-vcard)). + +```java +try (NextcloudConnector nc = new NextcloudConnector("cloud.example.org", true, 443, "user", "password")) { + for (Calendar calendar : nc.listCalendars()) { + System.out.println(calendar.getName() + " -> " + calendar.getDisplayName()); + } + + // All entries of a calendar, or only the events in a time range + List all = nc.getCalendarEntries("personal"); + List thisWeek = nc.getCalendarEntriesInRange("personal", + Instant.now(), Instant.now().plus(7, ChronoUnit.DAYS)); + + String ics = thisWeek.get(0).getData(); // the iCalendar document + + // Pass true to have the server expand recurring events into one VEVENT per + // occurrence in the range, instead of one event carrying its RRULE. The + // expanded result is a computed view of that range, so don't write it back. + List occurrences = nc.getCalendarEntriesInRange("personal", + Instant.now(), Instant.now().plus(7, ChronoUnit.DAYS), true); + + // Store an entry, and update it only while it still carries this etag + String etag = nc.putCalendarEntry("personal", "my-event.ics", ics); + nc.putCalendarEntry("personal", "my-event.ics", changedIcs, etag); + + nc.deleteCalendarEntry("personal", "my-event.ics"); +} +``` + +Contacts work the same way via `listAddressBooks()`, `getContacts(book)`, +`getContact(book, name)`, `putContact(...)` and `deleteContact(...)`. +Calendars and address books can also be created and deleted with +`createCalendar(name, displayName, colour)` / `deleteCalendar(name)` and +`createAddressBook(name, displayName, description)` / `deleteAddressBook(name)`. + ## When you wish to contribute to the project [Infos for contributors](./README.developers.md) diff --git a/pom.xml b/pom.xml index 805d8fb..b1d2916 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.aarboard.nextcloud nextcloud-api - 14.2.2-SNAPSHOT + 14.3.0-SNAPSHOT jar diff --git a/src/main/java/org/aarboard/nextcloud/api/NextcloudConnector.java b/src/main/java/org/aarboard/nextcloud/api/NextcloudConnector.java index 25a9df9..0974cdc 100644 --- a/src/main/java/org/aarboard/nextcloud/api/NextcloudConnector.java +++ b/src/main/java/org/aarboard/nextcloud/api/NextcloudConnector.java @@ -35,6 +35,12 @@ import org.aarboard.nextcloud.api.filesharing.RemoteShare; import org.aarboard.nextcloud.api.filesharing.SingleShareXMLAnswer; import org.aarboard.nextcloud.api.groupfolders.GroupFolderInfo; +import org.aarboard.nextcloud.api.calendar.Calendar; +import org.aarboard.nextcloud.api.calendar.CalendarEntry; +import org.aarboard.nextcloud.api.calendar.Calendars; +import org.aarboard.nextcloud.api.contacts.AddressBook; +import org.aarboard.nextcloud.api.contacts.AddressBooks; +import org.aarboard.nextcloud.api.contacts.Contact; import org.aarboard.nextcloud.api.groupfolders.GroupFolders; import org.aarboard.nextcloud.api.provisioning.*; import org.aarboard.nextcloud.api.systemtags.SystemTags; @@ -66,6 +72,8 @@ public class NextcloudConnector implements AutoCloseable { private final Files fl; private final GroupFolders gf; private final SystemTags st; + private final Calendars cal; + private final AddressBooks ab; /** * @@ -130,6 +138,8 @@ public NextcloudConnector(String originalServiceUrl, AuthenticationConfig authen fl = new Files(this.serverConfig); gf = new GroupFolders(this.serverConfig); st = new SystemTags(this.serverConfig); + cal = new Calendars(this.serverConfig); + ab = new AddressBooks(this.serverConfig); OPEN_INSTANCES.incrementAndGet(); } catch (MalformedURLException e) { @@ -154,6 +164,8 @@ public NextcloudConnector(String serverName, boolean useHTTPS, int port, fl = new Files(this.serverConfig); gf = new GroupFolders(this.serverConfig); st = new SystemTags(this.serverConfig); + cal = new Calendars(this.serverConfig); + ab = new AddressBooks(this.serverConfig); OPEN_INSTANCES.incrementAndGet(); } @@ -280,6 +292,230 @@ public boolean declinePendingRemoteShare(int remoteShareId) { return fc.declinePendingRemoteShare(remoteShareId); } + /** + * Lists the calendars of the authenticated user. + * + * @return the user's calendars + * @since 14.3 + */ + public java.util.List listCalendars() { + return cal.listCalendars(); + } + + /** + * Fetches every entry of a calendar as a raw iCalendar document. + * + * @param calendarName name of the calendar + * @return all entries of the calendar + * @since 14.3 + */ + public java.util.List getCalendarEntries(String calendarName) { + return cal.getCalendarEntries(calendarName); + } + + /** + * Fetches the events of a calendar overlapping a time range. Recurring + * events are returned once, as stored, carrying their recurrence rule. + * + * @param calendarName name of the calendar + * @param from start of the range, inclusive + * @param to end of the range, exclusive + * @return the matching entries + * @since 14.3 + */ + public java.util.List getCalendarEntriesInRange(String calendarName, + java.time.Instant from, java.time.Instant to) { + return cal.getCalendarEntriesInRange(calendarName, from, to); + } + + /** + * Fetches the events of a calendar overlapping a time range, optionally + * having the server expand recurring events into one occurrence each. + *

+ * An expanded result is a computed view of that range, not the stored + * resource, so it must not be written back. + * + * @param calendarName name of the calendar + * @param from start of the range, inclusive + * @param to end of the range, exclusive + * @param expandRecurrences whether recurring events should be expanded + * @return the matching entries + * @since 14.3 + */ + public java.util.List getCalendarEntriesInRange(String calendarName, + java.time.Instant from, java.time.Instant to, boolean expandRecurrences) { + return cal.getCalendarEntriesInRange(calendarName, from, to, expandRecurrences); + } + + /** + * Fetches a single calendar entry. + * + * @param calendarName name of the calendar + * @param entryName name of the entry, or its href + * @return the entry, or {@code null} if it does not exist + * @since 14.3 + */ + public CalendarEntry getCalendarEntry(String calendarName, String entryName) { + return cal.getCalendarEntry(calendarName, entryName); + } + + /** + * Creates a calendar entry, or replaces it if one of that name exists. + * + * @param calendarName name of the calendar + * @param entryName name of the entry, conventionally the event UID + * followed by {@code .ics} + * @param iCalendar the iCalendar document to store + * @return the etag of the stored entry + * @since 14.3 + */ + public String putCalendarEntry(String calendarName, String entryName, String iCalendar) { + return cal.putCalendarEntry(calendarName, entryName, iCalendar); + } + + /** + * Replaces a calendar entry only while it still carries the given etag. + * + * @param calendarName name of the calendar + * @param entryName name of the entry + * @param iCalendar the iCalendar document to store + * @param etag the etag the stored entry must still have + * @return the etag of the stored entry + * @since 14.3 + */ + public String putCalendarEntry(String calendarName, String entryName, String iCalendar, String etag) { + return cal.putCalendarEntry(calendarName, entryName, iCalendar, etag); + } + + /** + * Deletes a calendar entry. + * + * @param calendarName name of the calendar + * @param entryName name of the entry, or its href + * @since 14.3 + */ + public void deleteCalendarEntry(String calendarName, String entryName) { + cal.deleteCalendarEntry(calendarName, entryName); + } + + /** + * Creates a calendar. + * + * @param calendarName name of the calendar as it appears in the URL + * @param displayName name shown in the UI, may be {@code null} + * @param color HTML colour code, e.g. {@code #FF0000}, may be + * {@code null} + * @since 14.3 + */ + public void createCalendar(String calendarName, String displayName, String color) { + cal.createCalendar(calendarName, displayName, color); + } + + /** + * Deletes a calendar and all of its entries. + * + * @param calendarName name of the calendar + * @since 14.3 + */ + public void deleteCalendar(String calendarName) { + cal.deleteCalendar(calendarName); + } + + /** + * Lists the address books of the authenticated user. + * + * @return the user's address books + * @since 14.3 + */ + public java.util.List listAddressBooks() { + return ab.listAddressBooks(); + } + + /** + * Fetches every contact of an address book as a raw vCard document. + * + * @param addressBookName name of the address book + * @return all contacts of the address book + * @since 14.3 + */ + public java.util.List getContacts(String addressBookName) { + return ab.getContacts(addressBookName); + } + + /** + * Fetches a single contact. + * + * @param addressBookName name of the address book + * @param contactName name of the contact resource, or its href + * @return the contact, or {@code null} if it does not exist + * @since 14.3 + */ + public Contact getContact(String addressBookName, String contactName) { + return ab.getContact(addressBookName, contactName); + } + + /** + * Creates a contact, or replaces it if one of that name exists. + * + * @param addressBookName name of the address book + * @param contactName name of the contact resource, conventionally the + * vCard UID followed by {@code .vcf} + * @param vCard the vCard document to store + * @return the etag of the stored contact + * @since 14.3 + */ + public String putContact(String addressBookName, String contactName, String vCard) { + return ab.putContact(addressBookName, contactName, vCard); + } + + /** + * Replaces a contact only while it still carries the given etag. + * + * @param addressBookName name of the address book + * @param contactName name of the contact resource + * @param vCard the vCard document to store + * @param etag the etag the stored contact must still have + * @return the etag of the stored contact + * @since 14.3 + */ + public String putContact(String addressBookName, String contactName, String vCard, String etag) { + return ab.putContact(addressBookName, contactName, vCard, etag); + } + + /** + * Deletes a contact. + * + * @param addressBookName name of the address book + * @param contactName name of the contact resource, or its href + * @since 14.3 + */ + public void deleteContact(String addressBookName, String contactName) { + ab.deleteContact(addressBookName, contactName); + } + + /** + * Creates an address book. + * + * @param addressBookName name of the address book as it appears in the URL + * @param displayName name shown in the UI, may be {@code null} + * @param description description of the address book, may be + * {@code null} + * @since 14.3 + */ + public void createAddressBook(String addressBookName, String displayName, String description) { + ab.createAddressBook(addressBookName, displayName, description); + } + + /** + * Deletes an address book and all of its contacts. + * + * @param addressBookName name of the address book + * @since 14.3 + */ + public void deleteAddressBook(String addressBookName) { + ab.deleteAddressBook(addressBookName); + } + /** * @return all system tags on the server */ diff --git a/src/main/java/org/aarboard/nextcloud/api/calendar/Calendar.java b/src/main/java/org/aarboard/nextcloud/api/calendar/Calendar.java new file mode 100644 index 0000000..5f71e2a --- /dev/null +++ b/src/main/java/org/aarboard/nextcloud/api/calendar/Calendar.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2026 a.schild + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.aarboard.nextcloud.api.calendar; + +import org.aarboard.nextcloud.api.dav.DavCollection; + +/** + * A calendar collection of a Nextcloud user. + * + * @author a.schild + * @since 14.3 + */ +public class Calendar extends DavCollection { + + private String color; + private String order; + + /** + * @return the calendar colour as an HTML colour code, e.g. {@code #FF0000}, + * or {@code null} if the calendar has none + */ + public String getColor() { + return color; + } + + public void setColor(String color) { + this.color = color; + } + + /** + * @return the sort order the clients display the calendar in, or + * {@code null} if unset + */ + public String getOrder() { + return order; + } + + public void setOrder(String order) { + this.order = order; + } +} diff --git a/src/main/java/org/aarboard/nextcloud/api/calendar/CalendarEntry.java b/src/main/java/org/aarboard/nextcloud/api/calendar/CalendarEntry.java new file mode 100644 index 0000000..36b0dfa --- /dev/null +++ b/src/main/java/org/aarboard/nextcloud/api/calendar/CalendarEntry.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 a.schild + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.aarboard.nextcloud.api.calendar; + +import org.aarboard.nextcloud.api.dav.DavEntry; + +/** + * A single resource of a calendar, holding one iCalendar document. Such a + * document contains the event, todo or journal itself plus, where applicable, + * its recurrence exceptions and the time zone definitions it refers to. + *

+ * {@link #getData()} returns the iCalendar text verbatim; parsing it is left to + * the caller, for example with ical4j. + * + * @author a.schild + * @since 14.3 + */ +public class CalendarEntry extends DavEntry { +} diff --git a/src/main/java/org/aarboard/nextcloud/api/calendar/Calendars.java b/src/main/java/org/aarboard/nextcloud/api/calendar/Calendars.java new file mode 100644 index 0000000..132188a --- /dev/null +++ b/src/main/java/org/aarboard/nextcloud/api/calendar/Calendars.java @@ -0,0 +1,273 @@ +/* + * Copyright (C) 2026 a.schild + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.aarboard.nextcloud.api.calendar; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import javax.xml.namespace.QName; +import org.aarboard.nextcloud.api.ServerConfig; +import org.aarboard.nextcloud.api.dav.ADavCollectionHandler; +import org.aarboard.nextcloud.api.utils.ConnectorCommon; +import org.apache.http.entity.ContentType; + +/** + * Access to the calendars of the authenticated user over + * CalDAV, below + * {@code remote.php/dav/calendars/}: listing calendars, reading and writing + * their entries, and creating or deleting calendars. + *

+ * Entries are exchanged as raw iCalendar documents, see {@link CalendarEntry}. + * + * @author a.schild + * @since 14.3 + */ +public class Calendars extends ADavCollectionHandler { + + private static final String CALENDAR_ROOT = "remote.php/dav/calendars/"; + + private static final QName RESOURCE_TYPE_CALENDAR = new QName(NS_CALDAV, "calendar"); + private static final QName PROP_CALENDAR_DESCRIPTION = new QName(NS_CALDAV, "calendar-description", "c"); + private static final QName PROP_CALENDAR_COLOR = new QName(NS_APPLE_ICAL, "calendar-color", "ic"); + private static final QName PROP_CALENDAR_ORDER = new QName(NS_APPLE_ICAL, "calendar-order", "ic"); + + /** UTC in the basic format iCalendar and the CalDAV time-range filter use. */ + private static final DateTimeFormatter UTC_TIMESTAMP = + DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC); + + public Calendars(ServerConfig serverConfig) { + super(serverConfig); + } + + @Override + protected String getHomeUrl() { + return buildDavUrl(CALENDAR_ROOT + encodeSegment(getCurrentUserId())) + "/"; + } + + @Override + protected QName getCollectionResourceType() { + return RESOURCE_TYPE_CALENDAR; + } + + @Override + protected String getDataElementName() { + return "calendar-data"; + } + + @Override + protected String getReportNamespace() { + return NS_CALDAV; + } + + @Override + protected String getMultigetElementName() { + return "calendar-multiget"; + } + + @Override + protected ContentType getEntryContentType() { + return ContentType.create("text/calendar", java.nio.charset.StandardCharsets.UTF_8); + } + + /** + * Lists the calendars of the authenticated user. The scheduling inbox and + * outbox, the trash bin and subscribed calendars are not included. + * + * @return the user's calendars + */ + public List listCalendars() { + Set props = new HashSet<>(); + props.add(PROP_CALENDAR_DESCRIPTION); + props.add(PROP_CALENDAR_COLOR); + props.add(PROP_CALENDAR_ORDER); + + return listCollections(props, Calendar::new, (calendar, resource) -> { + calendar.setDescription(resource.getCustomPropsNS().get(PROP_CALENDAR_DESCRIPTION)); + calendar.setColor(resource.getCustomPropsNS().get(PROP_CALENDAR_COLOR)); + calendar.setOrder(resource.getCustomPropsNS().get(PROP_CALENDAR_ORDER)); + }); + } + + /** + * Fetches every entry of a calendar, iCalendar payload included. + * + * @param calendarName name of the calendar, see {@link Calendar#getName()} + * @return all entries of the calendar + */ + public List getCalendarEntries(String calendarName) { + return getEntries(calendarName, CalendarEntry::new); + } + + /** + * Fetches the events of a calendar overlapping a time range. Recurring + * events are matched on their expanded occurrences, but are returned as the + * stored iCalendar document, i.e. as a single entry carrying its recurrence + * rule. Use {@link #getCalendarEntriesInRange(String, Instant, Instant, + * boolean)} to have the server expand them instead. + * + * @param calendarName name of the calendar + * @param from start of the range, inclusive + * @param to end of the range, exclusive + * @return the matching entries + */ + public List getCalendarEntriesInRange(String calendarName, Instant from, Instant to) { + return getCalendarEntriesInRange(calendarName, from, to, false); + } + + /** + * Fetches the events of a calendar overlapping a time range, optionally + * expanding recurring events. + *

+ * With {@code expandRecurrences} set, the server resolves each recurring + * event into one {@code VEVENT} per occurrence falling in the range: the + * recurrence rule is gone, every occurrence carries its own + * {@code RECURRENCE-ID} and start/end, overridden occurrences are already + * applied, and all times are returned in UTC. That makes the result + * directly usable for displaying a period, but it is a computed view for + * this range only - it is not the stored resource, so it must not be + * written back with {@link #putCalendarEntry(String, String, String)}. + *

+ * Without it, each matching event is returned once, exactly as stored. + * + * @param calendarName name of the calendar + * @param from start of the range, inclusive + * @param to end of the range, exclusive + * @param expandRecurrences whether the server should expand recurring + * events into their individual occurrences + * @return the matching entries + * @since 14.3 + */ + public List getCalendarEntriesInRange(String calendarName, Instant from, Instant to, + boolean expandRecurrences) { + if (from == null || to == null) { + throw new IllegalArgumentException("Both range bounds must be set"); + } + if (to.isBefore(from)) { + throw new IllegalArgumentException("The end of the range must not be before its start"); + } + String start = UTC_TIMESTAMP.format(from); + String end = UTC_TIMESTAMP.format(to); + + // The expand element is a child of calendar-data, the time-range in the + // filter only selects which resources match. + String calendarData = expandRecurrences + ? "" + : ""; + + String body = "" + + "" + + "" + calendarData + "" + + "" + + "" + + "" + + ""; + + return report(collectionUrl(calendarName), body, CalendarEntry::new); + } + + /** + * Fetches a single calendar entry. + * + * @param calendarName name of the calendar + * @param entryName name of the entry, or its href + * @return the entry, or {@code null} if it does not exist + */ + public CalendarEntry getCalendarEntry(String calendarName, String entryName) { + return getEntry(calendarName, entryName, CalendarEntry::new); + } + + /** + * Creates a calendar entry, or replaces it if one of that name exists. + * + * @param calendarName name of the calendar + * @param entryName name of the entry, conventionally the UID of the + * event followed by {@code .ics} + * @param iCalendar the iCalendar document to store + * @return the etag of the stored entry, or {@code null} if the server did + * not return one + */ + public String putCalendarEntry(String calendarName, String entryName, String iCalendar) { + return putEntry(calendarName, entryName, iCalendar, null); + } + + /** + * Replaces a calendar entry only while it still carries the given etag, so + * that a change made in the meantime is not silently overwritten. + * + * @param calendarName name of the calendar + * @param entryName name of the entry + * @param iCalendar the iCalendar document to store + * @param etag the etag the stored entry must still have + * @return the etag of the stored entry + * @throws org.aarboard.nextcloud.api.exception.NextcloudApiException if the + * entry was modified in the meantime + */ + public String putCalendarEntry(String calendarName, String entryName, String iCalendar, String etag) { + return putEntry(calendarName, entryName, iCalendar, etag); + } + + /** + * Deletes a calendar entry. + * + * @param calendarName name of the calendar + * @param entryName name of the entry, or its href + */ + public void deleteCalendarEntry(String calendarName, String entryName) { + deleteEntry(calendarName, entryName); + } + + /** + * Creates a calendar. + * + * @param calendarName name of the calendar as it appears in the URL + * @param displayName name shown in the UI, may be {@code null} + * @param color HTML colour code, e.g. {@code #FF0000}, may be + * {@code null} + */ + public void createCalendar(String calendarName, String displayName, String color) { + ConnectorCommon.requireValidPathSegment(calendarName); + + StringBuilder props = new StringBuilder(); + if (displayName != null) { + props.append("").append(xmlEscape(displayName)).append(""); + } + if (color != null) { + props.append("").append(xmlEscape(color)).append(""); + } + + String body = "" + + "" + + (props.length() > 0 ? "" + props + "" : "") + + ""; + + executeWithBody("MKCALENDAR", collectionUrl(calendarName), body, + "Creating calendar " + calendarName, 201); + } + + /** + * Deletes a calendar and all of its entries. + * + * @param calendarName name of the calendar + */ + public void deleteCalendar(String calendarName) { + deleteCollection(calendarName); + } +} diff --git a/src/main/java/org/aarboard/nextcloud/api/contacts/AddressBook.java b/src/main/java/org/aarboard/nextcloud/api/contacts/AddressBook.java new file mode 100644 index 0000000..e2055b7 --- /dev/null +++ b/src/main/java/org/aarboard/nextcloud/api/contacts/AddressBook.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 a.schild + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.aarboard.nextcloud.api.contacts; + +import org.aarboard.nextcloud.api.dav.DavCollection; + +/** + * An address book of a Nextcloud user. + * + * @author a.schild + * @since 14.3 + */ +public class AddressBook extends DavCollection { +} diff --git a/src/main/java/org/aarboard/nextcloud/api/contacts/AddressBooks.java b/src/main/java/org/aarboard/nextcloud/api/contacts/AddressBooks.java new file mode 100644 index 0000000..34ed9b0 --- /dev/null +++ b/src/main/java/org/aarboard/nextcloud/api/contacts/AddressBooks.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2026 a.schild + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.aarboard.nextcloud.api.contacts; + +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import javax.xml.namespace.QName; +import org.aarboard.nextcloud.api.ServerConfig; +import org.aarboard.nextcloud.api.dav.ADavCollectionHandler; +import org.aarboard.nextcloud.api.utils.ConnectorCommon; +import org.apache.http.entity.ContentType; + +/** + * Access to the address books of the authenticated user over + * CardDAV, below + * {@code remote.php/dav/addressbooks/users/}: listing address books, reading and + * writing their contacts, and creating or deleting address books. + *

+ * Contacts are exchanged as raw vCard documents, see {@link Contact}. + * + * @author a.schild + * @since 14.3 + */ +public class AddressBooks extends ADavCollectionHandler { + + private static final String ADDRESSBOOK_ROOT = "remote.php/dav/addressbooks/users/"; + + private static final QName RESOURCE_TYPE_ADDRESSBOOK = new QName(NS_CARDDAV, "addressbook"); + private static final QName PROP_ADDRESSBOOK_DESCRIPTION = + new QName(NS_CARDDAV, "addressbook-description", "card"); + + public AddressBooks(ServerConfig serverConfig) { + super(serverConfig); + } + + @Override + protected String getHomeUrl() { + return buildDavUrl(ADDRESSBOOK_ROOT + encodeSegment(getCurrentUserId())) + "/"; + } + + @Override + protected QName getCollectionResourceType() { + return RESOURCE_TYPE_ADDRESSBOOK; + } + + @Override + protected String getDataElementName() { + return "address-data"; + } + + @Override + protected String getReportNamespace() { + return NS_CARDDAV; + } + + @Override + protected String getMultigetElementName() { + return "addressbook-multiget"; + } + + @Override + protected ContentType getEntryContentType() { + return ContentType.create("text/vcard", StandardCharsets.UTF_8); + } + + /** + * Lists the address books of the authenticated user. + * + * @return the user's address books + */ + public List listAddressBooks() { + Set props = new HashSet<>(); + props.add(PROP_ADDRESSBOOK_DESCRIPTION); + + return listCollections(props, AddressBook::new, (book, resource) -> + book.setDescription(resource.getCustomPropsNS().get(PROP_ADDRESSBOOK_DESCRIPTION))); + } + + /** + * Fetches every contact of an address book, vCard payload included. + * + * @param addressBookName name of the address book, see + * {@link AddressBook#getName()} + * @return all contacts of the address book + */ + public List getContacts(String addressBookName) { + return getEntries(addressBookName, Contact::new); + } + + /** + * Fetches a single contact. + * + * @param addressBookName name of the address book + * @param contactName name of the contact resource, or its href + * @return the contact, or {@code null} if it does not exist + */ + public Contact getContact(String addressBookName, String contactName) { + return getEntry(addressBookName, contactName, Contact::new); + } + + /** + * Creates a contact, or replaces it if one of that name exists. + * + * @param addressBookName name of the address book + * @param contactName name of the contact resource, conventionally the + * UID of the vCard followed by {@code .vcf} + * @param vCard the vCard document to store + * @return the etag of the stored contact, or {@code null} if the server did + * not return one + */ + public String putContact(String addressBookName, String contactName, String vCard) { + return putEntry(addressBookName, contactName, vCard, null); + } + + /** + * Replaces a contact only while it still carries the given etag, so that a + * change made in the meantime is not silently overwritten. + * + * @param addressBookName name of the address book + * @param contactName name of the contact resource + * @param vCard the vCard document to store + * @param etag the etag the stored contact must still have + * @return the etag of the stored contact + * @throws org.aarboard.nextcloud.api.exception.NextcloudApiException if the + * contact was modified in the meantime + */ + public String putContact(String addressBookName, String contactName, String vCard, String etag) { + return putEntry(addressBookName, contactName, vCard, etag); + } + + /** + * Deletes a contact. + * + * @param addressBookName name of the address book + * @param contactName name of the contact resource, or its href + */ + public void deleteContact(String addressBookName, String contactName) { + deleteEntry(addressBookName, contactName); + } + + /** + * Creates an address book. + * + * @param addressBookName name of the address book as it appears in the URL + * @param displayName name shown in the UI, may be {@code null} + * @param description description of the address book, may be + * {@code null} + */ + public void createAddressBook(String addressBookName, String displayName, String description) { + ConnectorCommon.requireValidPathSegment(addressBookName); + + StringBuilder props = new StringBuilder(); + props.append(""); + if (displayName != null) { + props.append("").append(xmlEscape(displayName)).append(""); + } + if (description != null) { + props.append("").append(xmlEscape(description)) + .append(""); + } + + // Extended MKCOL (RFC 5689): a plain MKCOL would create an ordinary + // collection, the resourcetype in the body is what makes it an address book. + String body = "" + + "" + + "" + props + "" + + ""; + + executeWithBody("MKCOL", collectionUrl(addressBookName), body, + "Creating address book " + addressBookName, 201); + } + + /** + * Deletes an address book and all of its contacts. + * + * @param addressBookName name of the address book + */ + public void deleteAddressBook(String addressBookName) { + deleteCollection(addressBookName); + } +} diff --git a/src/main/java/org/aarboard/nextcloud/api/contacts/Contact.java b/src/main/java/org/aarboard/nextcloud/api/contacts/Contact.java new file mode 100644 index 0000000..863f964 --- /dev/null +++ b/src/main/java/org/aarboard/nextcloud/api/contacts/Contact.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2026 a.schild + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.aarboard.nextcloud.api.contacts; + +import org.aarboard.nextcloud.api.dav.DavEntry; + +/** + * A single resource of an address book, holding one vCard document. + *

+ * {@link #getData()} returns the vCard text verbatim; parsing it is left to the + * caller, for example with ez-vcard. + * + * @author a.schild + * @since 14.3 + */ +public class Contact extends DavEntry { +} diff --git a/src/main/java/org/aarboard/nextcloud/api/dav/ADavCollectionHandler.java b/src/main/java/org/aarboard/nextcloud/api/dav/ADavCollectionHandler.java new file mode 100644 index 0000000..8b70877 --- /dev/null +++ b/src/main/java/org/aarboard/nextcloud/api/dav/ADavCollectionHandler.java @@ -0,0 +1,518 @@ +/* + * Copyright (C) 2026 a.schild + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.aarboard.nextcloud.api.dav; + +import com.github.sardine.DavResource; +import com.github.sardine.Sardine; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; +import javax.xml.namespace.QName; +import org.aarboard.nextcloud.api.ServerConfig; +import org.aarboard.nextcloud.api.exception.NextcloudApiException; +import org.aarboard.nextcloud.api.utils.ConnectorCommon; +import org.aarboard.nextcloud.api.webdav.AWebdavHandler; +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; + +/** + * Shared plumbing for the CalDAV and CardDAV connectors. The two protocols are + * structurally identical - a per-user home collection holding collections which + * in turn hold text resources - so collection listing, entry retrieval and the + * write operations live here and the subclasses only supply the namespaces, + * element and content type names that differ. + * + * @author a.schild + * @since 14.3 + */ +public abstract class ADavCollectionHandler extends AWebdavHandler { + + protected static final String NS_DAV = "DAV:"; + protected static final String NS_CALDAV = "urn:ietf:params:xml:ns:caldav"; + protected static final String NS_CARDDAV = "urn:ietf:params:xml:ns:carddav"; + protected static final String NS_CALENDARSERVER = "http://calendarserver.org/ns/"; + protected static final String NS_APPLE_ICAL = "http://apple.com/ns/ical/"; + + protected static final QName PROP_DISPLAYNAME = new QName(NS_DAV, "displayname", "d"); + protected static final QName PROP_RESOURCETYPE = new QName(NS_DAV, "resourcetype", "d"); + protected static final QName PROP_CTAG = new QName(NS_CALENDARSERVER, "getctag", "cs"); + + private static final ContentType XML_CONTENT_TYPE = + ContentType.create("application/xml", StandardCharsets.UTF_8); + + protected ADavCollectionHandler(ServerConfig serverConfig) { + super(serverConfig); + } + + /** + * @return the URL of the per-user home collection, with a trailing slash, + * e.g. {@code https://host/remote.php/dav/calendars/user/} + */ + protected abstract String getHomeUrl(); + + /** + * @return the resource type marking a collection this handler manages, e.g. + * {@code {urn:ietf:params:xml:ns:caldav}calendar} + */ + protected abstract QName getCollectionResourceType(); + + /** + * @return local name of the element carrying the payload in a REPORT + * response, i.e. {@code calendar-data} or {@code address-data} + */ + protected abstract String getDataElementName(); + + /** + * @return namespace of the payload element and of the multiget report + */ + protected abstract String getReportNamespace(); + + /** + * @return name of the multiget report element, e.g. + * {@code calendar-multiget} + */ + protected abstract String getMultigetElementName(); + + /** + * @return content type to send when storing an entry + */ + protected abstract ContentType getEntryContentType(); + + /** + * Builds the URL of a collection below the home collection. + * + * @param collectionName name of the collection + * @return the collection URL, with a trailing slash + */ + protected String collectionUrl(String collectionName) { + ConnectorCommon.requireValidPathSegment(collectionName); + return getHomeUrl() + encodeSegment(collectionName) + "/"; + } + + /** + * Builds the URL of a single entry inside a collection. + * + * @param collectionName name of the collection + * @param entryName name of the entry, or a full href of one - in which + * case the last path segment is used + * @return the entry URL + */ + protected String entryUrl(String collectionName, String entryName) { + return collectionUrl(collectionName) + encodeSegment(lastSegment(entryName)); + } + + /** + * Lists the collections in the user's home collection, keeping only those + * carrying the resource type this handler manages. This filters out the + * scheduling inbox/outbox, the trash bin and subscriptions, which live in + * the same home collection. + * + * @param the concrete collection type + * @param extraProps additional properties to request + * @param factory creates a new, empty collection instance + * @param decorator fills the type specific properties of a collection + * @return the matching collections + */ + protected List listCollections(Set extraProps, + Supplier factory, CollectionDecorator decorator) { + Set props = new java.util.HashSet<>(extraProps); + props.add(PROP_DISPLAYNAME); + props.add(PROP_CTAG); + // A PROPFIND naming specific properties returns only those, so the + // resource type has to be asked for explicitly - without it every + // collection would look like a non-calendar and be filtered out below. + props.add(PROP_RESOURCETYPE); + + String homeUrl = getHomeUrl(); + Sardine sardine = buildAuthSardine(); + try { + List collections = new ArrayList<>(); + for (DavResource resource : sardine.propfind(homeUrl, 1, props)) { + if (!resource.getResourceTypes().contains(getCollectionResourceType())) { + continue; + } + T collection = factory.get(); + collection.setHref(resource.getPath()); + collection.setName(lastSegment(resource.getPath())); + collection.setDisplayName(resource.getDisplayName() != null + ? resource.getDisplayName() + : resource.getCustomPropsNS().get(PROP_DISPLAYNAME)); + collection.setCtag(resource.getCustomPropsNS().get(PROP_CTAG)); + decorator.decorate(collection, resource); + collections.add(collection); + } + return collections; + } catch (IOException e) { + throw new NextcloudApiException(e); + } finally { + shutdownSardine(sardine); + } + } + + /** + * Fetches every entry of a collection, payload included. + *

+ * This is done with a PROPFIND to enumerate the entries followed by a single + * multiget REPORT for their payloads, rather than one GET per entry. + * + * @param the concrete entry type + * @param collectionName name of the collection + * @param entryFactory creates a new, empty entry instance + * @return all entries of the collection + */ + protected List getEntries(String collectionName, Supplier entryFactory) { + String collectionUrl = collectionUrl(collectionName); + List hrefs = listEntryHrefs(collectionUrl); + if (hrefs.isEmpty()) { + return new ArrayList<>(); + } + return multiget(collectionUrl, hrefs, entryFactory); + } + + /** + * Fetches a single entry. + * + * @param the concrete entry type + * @param collectionName name of the collection + * @param entryName name of the entry + * @param entryFactory creates a new, empty entry instance + * @return the entry, or {@code null} if it does not exist + */ + protected T getEntry(String collectionName, String entryName, + Supplier entryFactory) { + String url = entryUrl(collectionName, entryName); + HttpGet get = new HttpGet(url); + get.setHeader("Authorization", authorizationHeader()); + + try (CloseableHttpClient client = buildSyncClient(); + CloseableHttpResponse response = client.execute(get)) { + int status = response.getStatusLine().getStatusCode(); + if (status == 404) { + return null; + } + if (status != 200) { + throw new NextcloudApiException("Fetching " + url + " failed with status " + status); + } + T entry = entryFactory.get(); + entry.setHref(URI.create(url).getPath()); + entry.setData(readBody(response.getEntity())); + Header etag = response.getFirstHeader("ETag"); + if (etag != null) { + entry.setEtag(unquote(etag.getValue())); + } + return entry; + } catch (IOException e) { + throw new NextcloudApiException(e); + } + } + + /** + * Creates or replaces an entry. + * + * @param collectionName name of the collection + * @param entryName name of the entry, including the file extension + * @param data the raw payload to store + * @param ifMatchEtag when set, the write only succeeds while the stored + * entry still carries this etag; when {@code null} the + * entry is written unconditionally + * @return the etag of the stored entry, or {@code null} if the server did + * not return one + */ + protected String putEntry(String collectionName, String entryName, String data, String ifMatchEtag) { + if (data == null || data.isEmpty()) { + throw new IllegalArgumentException("Entry data must not be empty"); + } + String url = entryUrl(collectionName, entryName); + HttpPut put = new HttpPut(url); + put.setHeader("Authorization", authorizationHeader()); + if (ifMatchEtag != null) { + put.setHeader("If-Match", quote(ifMatchEtag)); + } + put.setEntity(new StringEntity(data, getEntryContentType())); + + try (CloseableHttpClient client = buildSyncClient(); + CloseableHttpResponse response = client.execute(put)) { + int status = response.getStatusLine().getStatusCode(); + if (status == 412) { + throw new NextcloudApiException("Entry " + entryName + + " was modified on the server, the If-Match precondition failed"); + } + if (status != 201 && status != 204 && status != 200) { + throw new NextcloudApiException("Storing " + url + " failed with status " + status); + } + Header etag = response.getFirstHeader("ETag"); + return etag != null ? unquote(etag.getValue()) : null; + } catch (IOException e) { + throw new NextcloudApiException(e); + } + } + + /** + * Deletes an entry. + * + * @param collectionName name of the collection + * @param entryName name of the entry + */ + protected void deleteEntry(String collectionName, String entryName) { + HttpDelete delete = new HttpDelete(entryUrl(collectionName, entryName)); + delete.setHeader("Authorization", authorizationHeader()); + execute(delete, "Deleting entry " + entryName, 204, 200); + } + + /** + * Deletes a whole collection and everything in it. + * + * @param collectionName name of the collection + */ + protected void deleteCollection(String collectionName) { + HttpDelete delete = new HttpDelete(collectionUrl(collectionName)); + delete.setHeader("Authorization", authorizationHeader()); + execute(delete, "Deleting collection " + collectionName, 204, 200); + } + + /** + * Issues a request with a body and an XML content type, e.g. MKCALENDAR or + * an extended MKCOL. + * + * @param method the HTTP method name + * @param url the target URL + * @param body the request body + * @param description used in the error message + * @param expectedStatus the accepted status codes + */ + protected void executeWithBody(String method, String url, String body, String description, + int... expectedStatus) { + DavMethod request = new DavMethod(method, url); + request.setHeader("Authorization", authorizationHeader()); + request.setEntity(new StringEntity(body, XML_CONTENT_TYPE)); + execute(request, description, expectedStatus); + } + + private List listEntryHrefs(String collectionUrl) { + Sardine sardine = buildAuthSardine(); + try { + List hrefs = new ArrayList<>(); + String collectionPath = URI.create(collectionUrl).getPath(); + for (DavResource resource : sardine.list(collectionUrl, 1)) { + String path = resource.getPath(); + // The collection itself is part of a depth 1 listing. + if (path == null || samePath(path, collectionPath)) { + continue; + } + hrefs.add(path); + } + return hrefs; + } catch (IOException e) { + throw new NextcloudApiException(e); + } finally { + shutdownSardine(sardine); + } + } + + private List multiget(String collectionUrl, List hrefs, + Supplier entryFactory) { + StringBuilder body = new StringBuilder(); + body.append("") + .append("") + .append(""); + for (String href : hrefs) { + body.append("").append(xmlEscape(href)).append(""); + } + body.append("'); + + return report(collectionUrl, body.toString(), entryFactory); + } + + /** + * Sends a REPORT request and parses the multistatus response. + * + * @param the concrete entry type + * @param url the target collection URL + * @param body the REPORT body + * @param entryFactory creates a new, empty entry instance + * @return the entries carried by the response + */ + protected List report(String url, String body, Supplier entryFactory) { + DavMethod request = new DavMethod("REPORT", url); + request.setHeader("Authorization", authorizationHeader()); + request.setHeader("Depth", "1"); + request.setEntity(new StringEntity(body, XML_CONTENT_TYPE)); + + try (CloseableHttpClient client = buildSyncClient(); + CloseableHttpResponse response = client.execute(request)) { + int status = response.getStatusLine().getStatusCode(); + if (status != 207) { + throw new NextcloudApiException("REPORT on " + url + " failed with status " + status); + } + HttpEntity entity = response.getEntity(); + if (entity == null) { + return Collections.emptyList(); + } + try (InputStream in = entity.getContent()) { + return MultistatusParser.parse(in, getDataElementName(), entryFactory); + } + } catch (IOException e) { + throw new NextcloudApiException(e); + } + } + + private void execute(HttpUriRequest request, String description, int... expectedStatus) { + try (CloseableHttpClient client = buildSyncClient(); + CloseableHttpResponse response = client.execute(request)) { + int status = response.getStatusLine().getStatusCode(); + for (int expected : expectedStatus) { + if (status == expected) { + return; + } + } + throw new NextcloudApiException(description + " failed with status " + status); + } catch (IOException e) { + throw new NextcloudApiException(e); + } finally { + if (request instanceof HttpRequestBase) { + ((HttpRequestBase) request).releaseConnection(); + } + } + } + + private static String readBody(HttpEntity entity) throws IOException { + if (entity == null) { + return null; + } + try (InputStream in = entity.getContent()) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[FILE_BUFFER_SIZE]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + } + + private static boolean samePath(String left, String right) { + return stripTrailingSlash(left).equals(stripTrailingSlash(right)); + } + + private static String stripTrailingSlash(String value) { + return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; + } + + /** + * Returns the last path segment of a value, so that callers may pass either + * a bare entry name or a full href. + * + * @param value an entry name or href + * @return the last path segment + */ + protected static String lastSegment(String value) { + if (value == null) { + throw new IllegalArgumentException("Name must not be null"); + } + String trimmed = stripTrailingSlash(value); + int lastSlash = trimmed.lastIndexOf('/'); + String segment = lastSlash >= 0 ? trimmed.substring(lastSlash + 1) : trimmed; + if (segment.isEmpty()) { + throw new IllegalArgumentException("Name must not be empty"); + } + return segment; + } + + /** + * Percent-encodes a single path segment. The segment has already been + * checked not to contain a path separator, so only the remaining reserved + * characters need escaping. + * + * @param segment the path segment + * @return the encoded segment + */ + protected static String encodeSegment(String segment) { + StringBuilder encoded = new StringBuilder(); + for (byte b : segment.getBytes(StandardCharsets.UTF_8)) { + int value = b & 0xFF; + if ((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') + || (value >= '0' && value <= '9') + || value == '-' || value == '_' || value == '.' || value == '~') { + encoded.append((char) value); + } else { + encoded.append('%').append(String.format("%02X", value)); + } + } + return encoded.toString(); + } + + /** + * Escapes the characters that may not appear in XML character data. + * + * @param value the raw value + * @return the escaped value + */ + protected static String xmlEscape(String value) { + if (value == null) { + return ""; + } + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + private static String quote(String etag) { + String value = etag.trim(); + return value.startsWith("\"") || value.startsWith("W/") ? value : "\"" + value + "\""; + } + + private static String unquote(String etag) { + String value = etag.trim(); + if (value.startsWith("W/")) { + value = value.substring(2); + } + if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + return value; + } + + /** + * Fills in the properties specific to a collection type. + * + * @param the concrete collection type + */ + @FunctionalInterface + protected interface CollectionDecorator { + void decorate(T collection, DavResource resource); + } +} diff --git a/src/main/java/org/aarboard/nextcloud/api/dav/DavCollection.java b/src/main/java/org/aarboard/nextcloud/api/dav/DavCollection.java new file mode 100644 index 0000000..8ec316b --- /dev/null +++ b/src/main/java/org/aarboard/nextcloud/api/dav/DavCollection.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2026 a.schild + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.aarboard.nextcloud.api.dav; + +/** + * Common metadata of a CalDAV/CardDAV collection (a calendar or an address + * book). + * + * @author a.schild + * @since 14.3 + */ +public abstract class DavCollection { + + private String name; + private String href; + private String displayName; + private String description; + private String ctag; + + /** + * The collection name as it appears in the URL, for example + * {@code personal}. This is the identifier to pass to the entry methods. + * + * @return the collection name + */ + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + /** + * @return the absolute path of the collection on the server + */ + public String getHref() { + return href; + } + + public void setHref(String href) { + this.href = href; + } + + /** + * @return the human readable name shown in the Nextcloud UI, which may + * differ from {@link #getName()} + */ + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + /** + * The collection tag: it changes whenever anything inside the collection + * changes, so it can be used to skip a full sync when nothing has changed. + * + * @return the ctag of the collection + */ + public String getCtag() { + return ctag; + } + + public void setCtag(String ctag) { + this.ctag = ctag; + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{name=" + name + ", displayName=" + displayName + '}'; + } +} diff --git a/src/main/java/org/aarboard/nextcloud/api/dav/DavEntry.java b/src/main/java/org/aarboard/nextcloud/api/dav/DavEntry.java new file mode 100644 index 0000000..3dbda90 --- /dev/null +++ b/src/main/java/org/aarboard/nextcloud/api/dav/DavEntry.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2026 a.schild + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.aarboard.nextcloud.api.dav; + +/** + * A single resource inside a CalDAV or CardDAV collection, carrying the raw + * payload as it is stored on the server. + *

+ * The library deliberately does not parse the iCalendar/vCard payload: it has no + * dependency on an iCalendar or vCard library, so consumers stay free to use + * whichever one they prefer (for example ical4j or ez-vcard) or none at all. + * + * @author a.schild + * @since 14.3 + */ +public abstract class DavEntry { + + private String href; + private String etag; + private String data; + + /** + * @return the absolute path of the resource on the server, for example + * {@code /remote.php/dav/calendars/user/personal/abc123.ics} + */ + public String getHref() { + return href; + } + + public void setHref(String href) { + this.href = href; + } + + /** + * The name of the resource within its collection, i.e. the last segment of + * the href. This is the value to pass back to the update and delete methods. + * + * @return the resource name, or {@code null} if no href is set + */ + public String getName() { + if (href == null) { + return null; + } + String trimmed = href.endsWith("/") ? href.substring(0, href.length() - 1) : href; + int lastSlash = trimmed.lastIndexOf('/'); + return lastSlash >= 0 ? trimmed.substring(lastSlash + 1) : trimmed; + } + + /** + * @return the entity tag of this resource, usable as a precondition when + * updating it to avoid overwriting a concurrent change + */ + public String getEtag() { + return etag; + } + + public void setEtag(String etag) { + this.etag = etag; + } + + /** + * @return the raw payload (an iCalendar or vCard document), or {@code null} + * if only the metadata was requested + */ + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{href=" + href + ", etag=" + etag + '}'; + } +} diff --git a/src/main/java/org/aarboard/nextcloud/api/dav/DavMethod.java b/src/main/java/org/aarboard/nextcloud/api/dav/DavMethod.java new file mode 100644 index 0000000..328db36 --- /dev/null +++ b/src/main/java/org/aarboard/nextcloud/api/dav/DavMethod.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 a.schild + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.aarboard.nextcloud.api.dav; + +import java.net.URI; +import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; + +/** + * A request with an arbitrary method name and a body, for the DAV verbs Apache + * HttpClient does not ship (REPORT, MKCALENDAR, extended MKCOL). + * + * @author a.schild + * @since 14.3 + */ +class DavMethod extends HttpEntityEnclosingRequestBase { + + private final String method; + + DavMethod(String method, String uri) { + this.method = method; + setURI(URI.create(uri)); + } + + @Override + public String getMethod() { + return method; + } +} diff --git a/src/main/java/org/aarboard/nextcloud/api/dav/MultistatusParser.java b/src/main/java/org/aarboard/nextcloud/api/dav/MultistatusParser.java new file mode 100644 index 0000000..71bf896 --- /dev/null +++ b/src/main/java/org/aarboard/nextcloud/api/dav/MultistatusParser.java @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2026 a.schild + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.aarboard.nextcloud.api.dav; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; +import org.aarboard.nextcloud.api.exception.NextcloudApiException; + +/** + * Minimal reader for the {@code DAV:multistatus} documents returned by the + * CalDAV/CardDAV {@code REPORT} requests. + *

+ * Sardine models plain WebDAV properties only and drops the + * {@code calendar-data} / {@code address-data} payload elements, so the REPORT + * responses are read here instead. Only the three elements the connectors need + * are extracted (href, getetag and the payload); everything else is skipped. + * Like {@code XMLAnswerParser}, the reader is hardened against XXE by + * disabling DTDs and external entities. + * + * @author a.schild + * @since 14.3 + */ +public final class MultistatusParser { + + private static final XMLInputFactory XML_INPUT_FACTORY = createHardenedInputFactory(); + + private static final String EL_RESPONSE = "response"; + private static final String EL_HREF = "href"; + private static final String EL_ETAG = "getetag"; + + private MultistatusParser() { + } + + private static XMLInputFactory createHardenedInputFactory() { + XMLInputFactory factory = XMLInputFactory.newFactory(); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); + return factory; + } + + /** + * Parses a multistatus document into entries. + * + * @param the concrete entry type + * @param in the response body + * @param dataElement local name of the element holding the payload, i.e. + * {@code calendar-data} or {@code address-data} + * @param entryFactory creates a new, empty entry instance + * @return one entry per {@code response} element that carried a payload + */ + public static List parse(InputStream in, String dataElement, + Supplier entryFactory) { + List entries = new ArrayList<>(); + try { + XMLStreamReader reader = XML_INPUT_FACTORY.createXMLStreamReader(in); + try { + T current = null; + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + String local = reader.getLocalName(); + if (EL_RESPONSE.equals(local)) { + current = entryFactory.get(); + } else if (current == null) { + continue; + } else if (EL_HREF.equals(local)) { + // A response holds exactly one href, but the propstat + // sections can contain further ones (e.g. in principal + // properties); keep the first, which is the resource. + if (current.getHref() == null) { + current.setHref(reader.getElementText()); + } + } else if (EL_ETAG.equals(local)) { + current.setEtag(unquote(reader.getElementText())); + } else if (dataElement.equals(local)) { + current.setData(reader.getElementText()); + } + } else if (event == XMLStreamConstants.END_ELEMENT + && EL_RESPONSE.equals(reader.getLocalName())) { + // Responses without a payload are error entries (404/403 + // propstat) and are not useful to the caller. Such a + // propstat still echoes the requested element, empty, so + // an empty payload counts as absent here. + if (current != null && current.getData() != null + && !current.getData().isEmpty()) { + entries.add(current); + } + current = null; + } + } + } finally { + reader.close(); + } + } catch (XMLStreamException e) { + throw new NextcloudApiException("Could not parse the DAV multistatus response", e); + } + return entries; + } + + /** + * Reads only the hrefs of a multistatus document, ignoring any payload. + * + * @param in the response body + * @return the href of every {@code response} element, in document order + */ + public static List parseHrefs(InputStream in) { + List hrefs = new ArrayList<>(); + try { + XMLStreamReader reader = XML_INPUT_FACTORY.createXMLStreamReader(in); + try { + boolean inResponse = false; + boolean hrefSeen = false; + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + String local = reader.getLocalName(); + if (EL_RESPONSE.equals(local)) { + inResponse = true; + hrefSeen = false; + } else if (inResponse && !hrefSeen && EL_HREF.equals(local)) { + hrefs.add(reader.getElementText()); + hrefSeen = true; + } + } else if (event == XMLStreamConstants.END_ELEMENT + && EL_RESPONSE.equals(reader.getLocalName())) { + inResponse = false; + } + } + } finally { + reader.close(); + } + } catch (XMLStreamException e) { + throw new NextcloudApiException("Could not parse the DAV multistatus response", e); + } + return hrefs; + } + + private static String unquote(String etag) { + if (etag == null) { + return null; + } + String value = etag.trim(); + if (value.startsWith("W/")) { + value = value.substring(2); + } + if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + return value; + } +} diff --git a/src/main/java/org/aarboard/nextcloud/api/exception/NextcloudApiException.java b/src/main/java/org/aarboard/nextcloud/api/exception/NextcloudApiException.java index 4c6a3d5..fa5222a 100644 --- a/src/main/java/org/aarboard/nextcloud/api/exception/NextcloudApiException.java +++ b/src/main/java/org/aarboard/nextcloud/api/exception/NextcloudApiException.java @@ -10,4 +10,13 @@ public NextcloudApiException(Throwable cause) { public NextcloudApiException(String message) { super(message); } + + /** + * @param message describes what failed + * @param cause the underlying failure + * @since 14.3 + */ + public NextcloudApiException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/src/main/java/org/aarboard/nextcloud/api/systemtags/SystemTags.java b/src/main/java/org/aarboard/nextcloud/api/systemtags/SystemTags.java index 9d5ff51..b7bc684 100644 --- a/src/main/java/org/aarboard/nextcloud/api/systemtags/SystemTags.java +++ b/src/main/java/org/aarboard/nextcloud/api/systemtags/SystemTags.java @@ -20,29 +20,22 @@ import com.github.sardine.DavResource; import com.github.sardine.Sardine; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Base64; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; -import javax.net.ssl.SSLContext; import javax.xml.namespace.QName; import org.aarboard.nextcloud.api.ServerConfig; import org.aarboard.nextcloud.api.exception.NextcloudApiException; -import org.aarboard.nextcloud.api.utils.SslUtils; import org.aarboard.nextcloud.api.webdav.AWebdavHandler; import org.apache.http.Header; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; -import org.apache.http.conn.ssl.NoopHostnameVerifier; import org.apache.http.entity.ContentType; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.impl.client.HttpClients; /** * Access to Nextcloud system @@ -212,32 +205,4 @@ private static boolean toBoolean(String value) { return "true".equalsIgnoreCase(value) || "1".equals(value); } - private String authorizationHeader() { - if (getServerConfig().getAuthenticationConfig().usesBasicAuthentication()) { - String credentials = getServerConfig().getUserName() + ":" - + getServerConfig().getAuthenticationConfig().getPassword(); - return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); - } - return "Bearer " + getServerConfig().getAuthenticationConfig().getBearerToken(); - } - - private CloseableHttpClient buildSyncClient() { - HttpClientBuilder builder = HttpClients.custom(); - SSLContext sslContext = SslUtils.buildSslContext(getServerConfig()); - if (sslContext != null) { - builder.setSSLContext(sslContext); - if (SslUtils.isHostnameVerificationDisabled(getServerConfig())) { - builder.setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE); - } - } - return builder.build(); - } - - private void shutdownSardine(Sardine sardine) { - try { - sardine.shutdown(); - } catch (IOException e) { - // best effort - } - } } diff --git a/src/main/java/org/aarboard/nextcloud/api/webdav/AWebdavHandler.java b/src/main/java/org/aarboard/nextcloud/api/webdav/AWebdavHandler.java index e527d5e..3c5aafa 100644 --- a/src/main/java/org/aarboard/nextcloud/api/webdav/AWebdavHandler.java +++ b/src/main/java/org/aarboard/nextcloud/api/webdav/AWebdavHandler.java @@ -25,6 +25,7 @@ import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.Base64; import java.util.stream.Collectors; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SSLContext; @@ -40,6 +41,9 @@ import org.apache.http.conn.socket.ConnectionSocketFactory; import org.apache.http.conn.ssl.NoopHostnameVerifier; import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.client.HttpClients; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -62,6 +66,8 @@ public abstract class AWebdavHandler private String nextcloudServerVersion; + private String currentUserId; + protected AWebdavHandler(ServerConfig serverConfig) { this.serverConfig = serverConfig; @@ -191,6 +197,83 @@ protected ServerConfig getServerConfig() return this.serverConfig; } + /** + * Resolves the internal user id of the authenticated user, which is the one + * appearing in DAV paths. This is not necessarily the login name: with + * external user backends the two can differ, and the DAV endpoints only + * accept the internal id. The value is resolved once and then cached. + * + * @return the internal user id of the authenticated user + * @since 14.3 + */ + protected String getCurrentUserId() + { + if (null == this.currentUserId) + { + ProvisionConnector pc = new ProvisionConnector(this.serverConfig); + this.currentUserId = pc.getCurrentUser().getId(); + } + return this.currentUserId; + } + + /** + * Builds the value for the {@code Authorization} header matching the + * configured authentication method. + * + * @return the authorization header value + * @since 14.3 + */ + protected String authorizationHeader() + { + if (this.serverConfig.getAuthenticationConfig().usesBasicAuthentication()) + { + String credentials = this.serverConfig.getUserName() + ":" + + this.serverConfig.getAuthenticationConfig().getPassword(); + return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + } + return "Bearer " + this.serverConfig.getAuthenticationConfig().getBearerToken(); + } + + /** + * Builds a synchronous HTTP client honouring the configured certificate + * trust settings. Used for the DAV verbs Sardine does not expose. + * + * @return a new client the caller is responsible for closing + * @since 14.3 + */ + protected CloseableHttpClient buildSyncClient() + { + HttpClientBuilder builder = HttpClients.custom(); + SSLContext sslContext = SslUtils.buildSslContext(this.serverConfig); + if (sslContext != null) + { + builder.setSSLContext(sslContext); + if (SslUtils.isHostnameVerificationDisabled(this.serverConfig)) + { + builder.setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE); + } + } + return builder.build(); + } + + /** + * Shuts a Sardine connector down, logging but not propagating failures. + * + * @param sardine the connector to shut down + * @since 14.3 + */ + protected void shutdownSardine(Sardine sardine) + { + try + { + sardine.shutdown(); + } + catch (IOException ex) + { + LOG.warn(ERROR_CLOSING, ex); + } + } + protected String getWebdavPathPrefix() { if (resolver != null) diff --git a/src/test/java/org/aarboard/nextcloud/api/TestAddressBooks.java b/src/test/java/org/aarboard/nextcloud/api/TestAddressBooks.java new file mode 100644 index 0000000..3699841 --- /dev/null +++ b/src/test/java/org/aarboard/nextcloud/api/TestAddressBooks.java @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2026 a.schild + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.aarboard.nextcloud.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.List; +import org.aarboard.nextcloud.api.contacts.AddressBook; +import org.aarboard.nextcloud.api.contacts.Contact; +import org.aarboard.nextcloud.api.exception.NextcloudApiException; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.FixMethodOrder; +import org.junit.Test; +import org.junit.runners.MethodSorters; + +/** + * Integration tests for the CardDAV support (issue #59). + * + * @author a.schild + */ +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class TestAddressBooks { + + private static final String ADDRESSBOOK = "api-test-addressbook"; + private static final String DISPLAY_NAME = "API test address book"; + private static final String DESCRIPTION = "Created by the integration tests"; + private static final String CONTACT = "api-test-contact-1.vcf"; + + private static String serverName = null; + private static NextcloudConnector _nc = null; + + private static String vCard(String fullName) { + return "BEGIN:VCARD\r\n" + + "VERSION:3.0\r\n" + + "UID:api-test-contact-1\r\n" + + "FN:" + fullName + "\r\n" + + "N:Doe;Jane;;;\r\n" + + "EMAIL:jane.doe@example.org\r\n" + + "END:VCARD\r\n"; + } + + @BeforeClass + public static void setUp() { + TestHelper th = new TestHelper(); + serverName = th.getServerName(); + if (serverName != null) { + _nc = new NextcloudConnector(serverName, th.getServerPort() == 443, th.getServerPort(), + th.getUserName(), th.getPassword()); + _nc.createAddressBook(ADDRESSBOOK, DISPLAY_NAME, DESCRIPTION); + } + } + + @AfterClass + public static void tearDown() throws Exception { + if (_nc != null) { + try { + _nc.deleteAddressBook(ADDRESSBOOK); + } catch (NextcloudApiException e) { + // the test that deletes it may already have run + } + _nc.close(); + } + } + + @Test + public void t01_listAddressBooks() { + if (serverName == null) { + return; + } + List books = _nc.listAddressBooks(); + + AddressBook created = books.stream() + .filter(b -> ADDRESSBOOK.equals(b.getName())) + .findFirst().orElse(null); + assertNotNull("Created address book not found in the listing", created); + assertEquals(DISPLAY_NAME, created.getDisplayName()); + assertEquals(DESCRIPTION, created.getDescription()); + } + + @Test + public void t02_putAndGetContact() { + if (serverName == null) { + return; + } + _nc.putContact(ADDRESSBOOK, CONTACT, vCard("Jane Doe")); + + Contact contact = _nc.getContact(ADDRESSBOOK, CONTACT); + assertNotNull(contact); + assertEquals(CONTACT, contact.getName()); + assertNotNull("A stored contact must have an etag", contact.getEtag()); + assertTrue(contact.getData().contains("FN:Jane Doe")); + } + + @Test + public void t03_getContacts() { + if (serverName == null) { + return; + } + List contacts = _nc.getContacts(ADDRESSBOOK); + assertEquals(1, contacts.size()); + assertEquals(CONTACT, contacts.get(0).getName()); + assertTrue(contacts.get(0).getData().contains("EMAIL:jane.doe@example.org")); + } + + @Test + public void t04_updateContact() { + if (serverName == null) { + return; + } + _nc.putContact(ADDRESSBOOK, CONTACT, vCard("Jane Renamed")); + + assertTrue(_nc.getContact(ADDRESSBOOK, CONTACT).getData().contains("FN:Jane Renamed")); + assertEquals("Updating must not create a second contact", 1, _nc.getContacts(ADDRESSBOOK).size()); + } + + @Test + public void t05_deleteContact() { + if (serverName == null) { + return; + } + _nc.deleteContact(ADDRESSBOOK, CONTACT); + + assertNull(_nc.getContact(ADDRESSBOOK, CONTACT)); + assertTrue(_nc.getContacts(ADDRESSBOOK).isEmpty()); + } + + @Test + public void t06_deleteAddressBook() { + if (serverName == null) { + return; + } + _nc.deleteAddressBook(ADDRESSBOOK); + + assertTrue("The deleted address book must be gone from the listing", + _nc.listAddressBooks().stream().noneMatch(b -> ADDRESSBOOK.equals(b.getName()))); + } + + @Test + public void t07_rejectsNameWithPathSeparator() { + if (serverName == null) { + return; + } + try { + _nc.getContacts("../other"); + fail("Expected a path separator in an address book name to be rejected"); + } catch (IllegalArgumentException expected) { + // expected + } + } +} diff --git a/src/test/java/org/aarboard/nextcloud/api/TestCalendars.java b/src/test/java/org/aarboard/nextcloud/api/TestCalendars.java new file mode 100644 index 0000000..d9ba50b --- /dev/null +++ b/src/test/java/org/aarboard/nextcloud/api/TestCalendars.java @@ -0,0 +1,274 @@ +/* + * Copyright (C) 2026 a.schild + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.aarboard.nextcloud.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import org.aarboard.nextcloud.api.calendar.Calendar; +import org.aarboard.nextcloud.api.calendar.CalendarEntry; +import org.aarboard.nextcloud.api.exception.NextcloudApiException; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.FixMethodOrder; +import org.junit.Test; +import org.junit.runners.MethodSorters; + +/** + * Integration tests for the CalDAV support (issue #59). + * + * @author a.schild + */ +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class TestCalendars { + + private static final String CALENDAR = "api-test-calendar"; + private static final String DISPLAY_NAME = "API test calendar"; + private static final String ENTRY = "api-test-event-1.ics"; + private static final String RECURRING_ENTRY = "api-test-event-recurring.ics"; + + /** The event starts here, so a range around it must match and one after it must not. */ + private static final Instant EVENT_START = Instant.parse("2026-08-12T09:00:00Z"); + + private static String serverName = null; + private static NextcloudConnector _nc = null; + + private static String iCalendar(String summary) { + return "BEGIN:VCALENDAR\r\n" + + "VERSION:2.0\r\n" + + "PRODID:-//nextcloud-java-api//integration test//EN\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:api-test-event-1\r\n" + + "DTSTAMP:20260811T080000Z\r\n" + + "DTSTART:20260812T090000Z\r\n" + + "DTEND:20260812T100000Z\r\n" + + "SUMMARY:" + summary + "\r\n" + + "END:VEVENT\r\n" + + "END:VCALENDAR\r\n"; + } + + /** A daily event with exactly three occurrences, starting at EVENT_START. */ + private static String recurringICalendar() { + return "BEGIN:VCALENDAR\r\n" + + "VERSION:2.0\r\n" + + "PRODID:-//nextcloud-java-api//integration test//EN\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:api-test-event-recurring\r\n" + + "DTSTAMP:20260811T080000Z\r\n" + + "DTSTART:20260812T090000Z\r\n" + + "DTEND:20260812T100000Z\r\n" + + "RRULE:FREQ=DAILY;COUNT=3\r\n" + + "SUMMARY:API test recurring event\r\n" + + "END:VEVENT\r\n" + + "END:VCALENDAR\r\n"; + } + + @BeforeClass + public static void setUp() { + TestHelper th = new TestHelper(); + serverName = th.getServerName(); + if (serverName != null) { + _nc = new NextcloudConnector(serverName, th.getServerPort() == 443, th.getServerPort(), + th.getUserName(), th.getPassword()); + _nc.createCalendar(CALENDAR, DISPLAY_NAME, "#FF0000"); + } + } + + @AfterClass + public static void tearDown() throws Exception { + if (_nc != null) { + try { + _nc.deleteCalendar(CALENDAR); + } catch (NextcloudApiException e) { + // the test that deletes it may already have run + } + _nc.close(); + } + } + + @Test + public void t01_listCalendars() { + if (serverName == null) { + return; + } + List calendars = _nc.listCalendars(); + assertFalse("The user should have at least the calendar we created", calendars.isEmpty()); + + Calendar created = calendars.stream() + .filter(c -> CALENDAR.equals(c.getName())) + .findFirst().orElse(null); + assertNotNull("Created calendar not found in the listing", created); + assertEquals(DISPLAY_NAME, created.getDisplayName()); + assertNotNull("Calendar should expose a ctag", created.getCtag()); + } + + @Test + public void t02_putAndGetEntry() { + if (serverName == null) { + return; + } + _nc.putCalendarEntry(CALENDAR, ENTRY, iCalendar("API test event")); + + CalendarEntry entry = _nc.getCalendarEntry(CALENDAR, ENTRY); + assertNotNull(entry); + assertEquals(ENTRY, entry.getName()); + assertNotNull("A stored entry must have an etag", entry.getEtag()); + assertTrue(entry.getData().contains("SUMMARY:API test event")); + assertTrue(entry.getData().contains("UID:api-test-event-1")); + } + + @Test + public void t03_getEntries() { + if (serverName == null) { + return; + } + List entries = _nc.getCalendarEntries(CALENDAR); + assertEquals(1, entries.size()); + assertEquals(ENTRY, entries.get(0).getName()); + assertTrue(entries.get(0).getData().contains("UID:api-test-event-1")); + } + + @Test + public void t04_getEntriesInRange() { + if (serverName == null) { + return; + } + List hit = _nc.getCalendarEntriesInRange(CALENDAR, + EVENT_START.minus(1, ChronoUnit.DAYS), EVENT_START.plus(1, ChronoUnit.DAYS)); + assertEquals("The event should be in a range around it", 1, hit.size()); + assertTrue(hit.get(0).getData().contains("UID:api-test-event-1")); + + List miss = _nc.getCalendarEntriesInRange(CALENDAR, + EVENT_START.plus(30, ChronoUnit.DAYS), EVENT_START.plus(60, ChronoUnit.DAYS)); + assertTrue("No event should be in a range after it", miss.isEmpty()); + } + + /** + * A daily event repeating three times must come back as one stored resource + * when recurrences are not expanded, and as three occurrences when they are. + */ + @Test + public void t05_expandRecurringEvent() { + if (serverName == null) { + return; + } + _nc.putCalendarEntry(CALENDAR, RECURRING_ENTRY, recurringICalendar()); + try { + Instant from = EVENT_START.minus(1, ChronoUnit.DAYS); + Instant to = EVENT_START.plus(10, ChronoUnit.DAYS); + + List stored = _nc.getCalendarEntriesInRange(CALENDAR, from, to, false); + CalendarEntry storedRecurring = stored.stream() + .filter(e -> RECURRING_ENTRY.equals(e.getName())) + .findFirst().orElse(null); + assertNotNull("The recurring event should match the range", storedRecurring); + assertTrue("Unexpanded, the recurrence rule must still be there", + storedRecurring.getData().contains("RRULE:")); + assertEquals("Unexpanded, the event is a single resource", + 1, countOccurrences(storedRecurring.getData(), "BEGIN:VEVENT")); + + List expanded = _nc.getCalendarEntriesInRange(CALENDAR, from, to, true); + CalendarEntry expandedRecurring = expanded.stream() + .filter(e -> RECURRING_ENTRY.equals(e.getName())) + .findFirst().orElse(null); + assertNotNull("The recurring event should also match when expanding", expandedRecurring); + assertEquals("Expanded, each occurrence is its own VEVENT", + 3, countOccurrences(expandedRecurring.getData(), "BEGIN:VEVENT")); + assertTrue("Expanded occurrences carry a RECURRENCE-ID", + expandedRecurring.getData().contains("RECURRENCE-ID")); + assertFalse("Expanded, the recurrence rule is resolved away", + expandedRecurring.getData().contains("RRULE:")); + } finally { + _nc.deleteCalendarEntry(CALENDAR, RECURRING_ENTRY); + } + } + + private static int countOccurrences(String haystack, String needle) { + int count = 0; + int index = haystack.indexOf(needle); + while (index >= 0) { + count++; + index = haystack.indexOf(needle, index + needle.length()); + } + return count; + } + + @Test + public void t06_updateWithEtagPrecondition() { + if (serverName == null) { + return; + } + CalendarEntry entry = _nc.getCalendarEntry(CALENDAR, ENTRY); + String etag = entry.getEtag(); + + // Updating with the current etag succeeds + _nc.putCalendarEntry(CALENDAR, ENTRY, iCalendar("Updated summary"), etag); + assertTrue(_nc.getCalendarEntry(CALENDAR, ENTRY).getData().contains("SUMMARY:Updated summary")); + + // Re-using the now stale etag must be refused instead of overwriting + try { + _nc.putCalendarEntry(CALENDAR, ENTRY, iCalendar("Should not be stored"), etag); + fail("Expected the stale If-Match precondition to fail"); + } catch (NextcloudApiException expected) { + // expected + } + assertTrue("The refused update must not have been stored", + _nc.getCalendarEntry(CALENDAR, ENTRY).getData().contains("SUMMARY:Updated summary")); + } + + @Test + public void t07_deleteEntry() { + if (serverName == null) { + return; + } + _nc.deleteCalendarEntry(CALENDAR, ENTRY); + + assertNull(_nc.getCalendarEntry(CALENDAR, ENTRY)); + assertTrue(_nc.getCalendarEntries(CALENDAR).isEmpty()); + } + + @Test + public void t08_deleteCalendar() { + if (serverName == null) { + return; + } + _nc.deleteCalendar(CALENDAR); + + assertTrue("The deleted calendar must be gone from the listing", + _nc.listCalendars().stream().noneMatch(c -> CALENDAR.equals(c.getName()))); + } + + @Test + public void t09_rejectsNameWithPathSeparator() { + if (serverName == null) { + return; + } + try { + _nc.getCalendarEntries("../../etc"); + fail("Expected a path separator in a calendar name to be rejected"); + } catch (IllegalArgumentException expected) { + // expected + } + } +} diff --git a/src/test/java/org/aarboard/nextcloud/api/dav/MultistatusParserTest.java b/src/test/java/org/aarboard/nextcloud/api/dav/MultistatusParserTest.java new file mode 100644 index 0000000..f7cafd1 --- /dev/null +++ b/src/test/java/org/aarboard/nextcloud/api/dav/MultistatusParserTest.java @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2026 a.schild + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.aarboard.nextcloud.api.dav; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.aarboard.nextcloud.api.calendar.CalendarEntry; +import org.aarboard.nextcloud.api.contacts.Contact; +import org.aarboard.nextcloud.api.exception.NextcloudApiException; +import org.junit.Test; + +/** + * Unit tests for the CalDAV/CardDAV multistatus reader. These need no server. + * + * @author a.schild + */ +public class MultistatusParserTest { + + private static InputStream stream(String xml) { + return new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)); + } + + @Test + public void testParsesCalendarMultistatus() { + String xml = "" + + "" + + "" + + "/remote.php/dav/calendars/user/personal/event1.ics" + + "" + + ""abc123"" + + "BEGIN:VCALENDAR\nUID:event1\nEND:VCALENDAR" + + "HTTP/1.1 200 OK" + + "" + + "" + + "/remote.php/dav/calendars/user/personal/event2.ics" + + "" + + "W/"def456"" + + "BEGIN:VCALENDAR\nUID:event2\nEND:VCALENDAR" + + "HTTP/1.1 200 OK" + + "" + + ""; + + List entries = MultistatusParser.parse(stream(xml), "calendar-data", + CalendarEntry::new); + + assertEquals(2, entries.size()); + assertEquals("/remote.php/dav/calendars/user/personal/event1.ics", entries.get(0).getHref()); + assertEquals("event1.ics", entries.get(0).getName()); + assertEquals("abc123", entries.get(0).getEtag()); + assertTrue(entries.get(0).getData().contains("UID:event1")); + // A weak etag keeps its value but loses the W/ marker and the quotes + assertEquals("def456", entries.get(1).getEtag()); + } + + @Test + public void testParsesAddressbookMultistatus() { + String xml = "" + + "" + + "" + + "/remote.php/dav/addressbooks/users/user/contacts/c1.vcf" + + "" + + ""e1"" + + "BEGIN:VCARD\nFN:Jane Doe\nEND:VCARD" + + "HTTP/1.1 200 OK" + + "" + + ""; + + List contacts = MultistatusParser.parse(stream(xml), "address-data", Contact::new); + + assertEquals(1, contacts.size()); + assertEquals("c1.vcf", contacts.get(0).getName()); + assertTrue(contacts.get(0).getData().contains("FN:Jane Doe")); + } + + /** + * A multiget for a href that no longer exists yields a 404 propstat without + * a payload. Such a response carries no data for the caller, so it must not + * turn into an entry with a null payload. + */ + @Test + public void testSkipsResponsesWithoutPayload() { + String xml = "" + + "" + + "" + + "/remote.php/dav/calendars/user/personal/gone.ics" + + "" + + "HTTP/1.1 404 Not Found" + + "" + + "" + + "/remote.php/dav/calendars/user/personal/here.ics" + + "" + + ""e"" + + "BEGIN:VCALENDAR\nEND:VCALENDAR" + + "HTTP/1.1 200 OK" + + "" + + ""; + + List entries = MultistatusParser.parse(stream(xml), "calendar-data", + CalendarEntry::new); + + assertEquals(1, entries.size()); + assertEquals("here.ics", entries.get(0).getName()); + } + + @Test + public void testEmptyMultistatusYieldsNoEntries() { + String xml = ""; + + assertTrue(MultistatusParser.parse(stream(xml), "calendar-data", CalendarEntry::new).isEmpty()); + } + + @Test + public void testParsesHrefs() { + String xml = "" + + "" + + "/dav/calendars/user/personal/" + + "/dav/calendars/user/personal/a.ics" + + ""; + + List hrefs = MultistatusParser.parseHrefs(stream(xml)); + + assertEquals(2, hrefs.size()); + assertEquals("/dav/calendars/user/personal/", hrefs.get(0)); + assertEquals("/dav/calendars/user/personal/a.ics", hrefs.get(1)); + } + + /** + * The reader must not resolve external entities, so that a malicious or + * compromised server cannot use a DAV response to read local files. + */ + @Test + public void testRejectsExternalEntities() { + String xml = "" + + "]>" + + "" + + "/a.ics" + + "&xxe;" + + ""; + + try { + List entries = MultistatusParser.parse(stream(xml), "calendar-data", + CalendarEntry::new); + // Some StAX implementations report the disallowed DTD by failing, others + // by simply not expanding the entity. Never leak the file content. + for (CalendarEntry entry : entries) { + assertTrue("External entity was expanded", + entry.getData() == null || !entry.getData().contains("root:")); + } + } catch (NextcloudApiException expected) { + // DTD support is disabled, which is the outcome we want + } + } + + @Test + public void testEntryNameOfNullHrefIsNull() { + CalendarEntry entry = new CalendarEntry(); + assertNull(entry.getName()); + } + + @Test + public void testMalformedXmlIsWrapped() { + try { + MultistatusParser.parse(stream(""), "calendar-data", CalendarEntry::new); + fail("Expected a NextcloudApiException for malformed XML"); + } catch (NextcloudApiException expected) { + // expected + } + } +}