Add secure ICS calendar imports and subscriptions
This commit is contained in:
@@ -248,6 +248,7 @@ inputs and outputs is absent from the ZIP.
|
||||
| WEB-06 | Identities, external senders, signatures, SMTP selection | | | |
|
||||
| WEB-07 | Filters/tasks/quarantine/external accounts/app passwords/2FA | | | |
|
||||
| WEB-08 | English, German, and every installed gettext catalogue load | | | |
|
||||
| WEB-09 | ICS file import and bounded public-HTTPS holiday subscription into personal/named calendars | | | |
|
||||
| DAV-01 | CalDAV discovery, CRUD, sync, ETag, and iCalendar | | | |
|
||||
| DAV-02 | CardDAV discovery, CRUD, sync, ETag, and vCard | | | |
|
||||
| DAV-03 | DAV master/app-password scope and TLS/proxy policy | | | |
|
||||
|
||||
+3
-2
@@ -104,8 +104,9 @@ fit Bongo; it is not a reason to rewrite working server code.
|
||||
extensions once their Standards Track specifications are stable; draft
|
||||
versions may be exercised behind experimental capabilities but are not part
|
||||
of the supported 0.9 interoperability contract;
|
||||
- scheduled external CalDAV calendar subscriptions and provider calendar sync,
|
||||
reusing the external-account identity and credential model;
|
||||
- authenticated external CalDAV/provider calendar sync, building on the
|
||||
HTTPS iCalendar URL subscriptions already available in 0.7 and reusing the
|
||||
external-account identity and credential model;
|
||||
- stable migration and compatibility APIs needed by the Web redesign and
|
||||
future clients.
|
||||
|
||||
|
||||
+17
@@ -46,6 +46,23 @@ then dismissed, retried, ignored, or completed where the task type permits.
|
||||
English, German, and every installed gettext catalogue use the same API
|
||||
instead of hard-coded interface text.
|
||||
|
||||
Calendar users can import a local `.ics` file into the personal calendar or
|
||||
create a separate named calendar, for example `holidays-at`. A public HTTPS
|
||||
URL can be imported once or kept as a subscription. The Web service resolves
|
||||
and pins only public destination addresses, revalidates every HTTPS redirect,
|
||||
and bounds connection time, redirect count, and response size. The Collector
|
||||
applies the same public-address, HTTPS-only, timeout, redirect, and size rules
|
||||
when it refreshes subscriptions. Feed-owned events are matched by iCalendar
|
||||
UID so a refresh updates existing events and unlinks events removed by the
|
||||
publisher.
|
||||
|
||||
The classic interface includes an optional preset for the public Austrian
|
||||
holiday feed from `feiertage-oesterreich.at`. Its calendar data is fetched
|
||||
from the publisher and is not copied into the Bongo source or packages. Other
|
||||
providers can be entered as normal HTTPS URLs. A separate calendar is
|
||||
recommended for subscriptions so users can hide or remove the complete feed
|
||||
without modifying their personal events.
|
||||
|
||||
The administrator view exposes health and a restricted set of validated
|
||||
actions. Full server configuration remains the responsibility of
|
||||
`bongo-admin` and the configuration TUI; Webmail must not become an arbitrary
|
||||
|
||||
@@ -414,3 +414,27 @@ msgid "Passwords do not match."
|
||||
msgstr "Die Passwörter stimmen nicht überein."
|
||||
msgid "Apply the validated configuration files to the running server?"
|
||||
msgstr "Die geprüften Konfigurationsdateien auf den laufenden Server anwenden?"
|
||||
msgid "Calendar source"
|
||||
msgstr "Kalenderquelle"
|
||||
msgid "Import calendar"
|
||||
msgstr "Kalender importieren"
|
||||
msgid "Personal calendar"
|
||||
msgstr "Persönlicher Kalender"
|
||||
msgid "New calendar name"
|
||||
msgstr "Neuer Kalendername"
|
||||
msgid "ICS file"
|
||||
msgstr "ICS-Datei"
|
||||
msgid "Calendar URL"
|
||||
msgstr "Kalender-URL"
|
||||
msgid "Import once"
|
||||
msgstr "Einmal importieren"
|
||||
msgid "Keep synchronized"
|
||||
msgstr "Synchron halten"
|
||||
msgid "Choose an ICS file or enter a public HTTPS calendar URL."
|
||||
msgstr "Wählen Sie eine ICS-Datei oder geben Sie eine öffentliche HTTPS-Kalender-URL ein."
|
||||
msgid "Calendar imported."
|
||||
msgstr "Kalender importiert."
|
||||
msgid "Calendar import failed."
|
||||
msgstr "Kalenderimport fehlgeschlagen."
|
||||
msgid "Austria public holidays"
|
||||
msgstr "Österreichische Feiertage"
|
||||
|
||||
@@ -1770,3 +1770,27 @@ msgid "Passwords do not match."
|
||||
msgstr "Les mots de passe ne correspondent pas."
|
||||
msgid "Apply the validated configuration files to the running server?"
|
||||
msgstr "Appliquer les fichiers de configuration validés au serveur en cours d’exécution ?"
|
||||
msgid "Calendar source"
|
||||
msgstr "Source du calendrier"
|
||||
msgid "Import calendar"
|
||||
msgstr "Importer un calendrier"
|
||||
msgid "Personal calendar"
|
||||
msgstr "Calendrier personnel"
|
||||
msgid "New calendar name"
|
||||
msgstr "Nom du nouveau calendrier"
|
||||
msgid "ICS file"
|
||||
msgstr "Fichier ICS"
|
||||
msgid "Calendar URL"
|
||||
msgstr "URL du calendrier"
|
||||
msgid "Import once"
|
||||
msgstr "Importer une fois"
|
||||
msgid "Keep synchronized"
|
||||
msgstr "Maintenir synchronisé"
|
||||
msgid "Choose an ICS file or enter a public HTTPS calendar URL."
|
||||
msgstr "Choisissez un fichier ICS ou saisissez une URL HTTPS publique de calendrier."
|
||||
msgid "Calendar imported."
|
||||
msgstr "Calendrier importé."
|
||||
msgid "Calendar import failed."
|
||||
msgstr "Échec de l’importation du calendrier."
|
||||
msgid "Austria public holidays"
|
||||
msgstr "Jours fériés autrichiens"
|
||||
|
||||
@@ -1255,3 +1255,27 @@ msgid "Passwords do not match."
|
||||
msgstr "As senhas não coincidem."
|
||||
msgid "Apply the validated configuration files to the running server?"
|
||||
msgstr "Aplicar os arquivos de configuração validados ao servidor em execução?"
|
||||
msgid "Calendar source"
|
||||
msgstr "Fonte do calendário"
|
||||
msgid "Import calendar"
|
||||
msgstr "Importar calendário"
|
||||
msgid "Personal calendar"
|
||||
msgstr "Calendário pessoal"
|
||||
msgid "New calendar name"
|
||||
msgstr "Nome do novo calendário"
|
||||
msgid "ICS file"
|
||||
msgstr "Arquivo ICS"
|
||||
msgid "Calendar URL"
|
||||
msgstr "URL do calendário"
|
||||
msgid "Import once"
|
||||
msgstr "Importar uma vez"
|
||||
msgid "Keep synchronized"
|
||||
msgstr "Manter sincronizado"
|
||||
msgid "Choose an ICS file or enter a public HTTPS calendar URL."
|
||||
msgstr "Escolha um arquivo ICS ou informe uma URL HTTPS pública de calendário."
|
||||
msgid "Calendar imported."
|
||||
msgstr "Calendário importado."
|
||||
msgid "Calendar import failed."
|
||||
msgstr "Falha ao importar o calendário."
|
||||
msgid "Austria public holidays"
|
||||
msgstr "Feriados da Áustria"
|
||||
|
||||
@@ -50,6 +50,10 @@ if(BUILD_TESTING)
|
||||
add_executable(msgapi-time64-test tests/msgdate-time64-test.c)
|
||||
target_link_libraries(msgapi-time64-test PRIVATE bongomsgapi)
|
||||
add_test(NAME msgapi-time64 COMMAND msgapi-time64-test)
|
||||
|
||||
add_executable(msgapi-http-address-test tests/msghttp-address-test.c)
|
||||
target_link_libraries(msgapi-http-address-test PRIVATE bongomsgapi)
|
||||
add_test(NAME msgapi-http-address COMMAND msgapi-http-address-test)
|
||||
endif()
|
||||
|
||||
add_subdirectory(auth-backends)
|
||||
|
||||
@@ -38,11 +38,31 @@
|
||||
#include <ical-wrapper.h>
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <sys/socket.h>
|
||||
#include "msghttp.h"
|
||||
|
||||
static size_t write_data(void *buffer, size_t size, size_t nmemb, void *stream);
|
||||
BongoList *GetSubscribedCals(Connection *conn);
|
||||
|
||||
#define CALENDAR_DOWNLOAD_LIMIT (8U * 1024U * 1024U)
|
||||
|
||||
typedef struct {
|
||||
FILE *file;
|
||||
size_t received;
|
||||
} CalendarDownload;
|
||||
|
||||
static curl_socket_t
|
||||
CalendarOpenSocket(void *client, curlsocktype purpose,
|
||||
struct curl_sockaddr *address)
|
||||
{
|
||||
(void)client;
|
||||
if (purpose != CURLSOCKTYPE_IPCXN ||
|
||||
!MsgHttpPublicAddress(&address->addr)) {
|
||||
return CURL_SOCKET_BAD;
|
||||
}
|
||||
return socket(address->family, address->socktype, address->protocol);
|
||||
}
|
||||
|
||||
typedef struct _SubscribedCal {
|
||||
int64_t guid;
|
||||
char *name;
|
||||
@@ -672,8 +692,15 @@ MsgCollectAllUsers(void)
|
||||
size_t
|
||||
write_data(void *buffer, size_t size, size_t nmemb, void *stream)
|
||||
{
|
||||
fwrite(buffer, size, nmemb, stream);
|
||||
return nmemb*size;
|
||||
CalendarDownload *download = stream;
|
||||
size_t bytes;
|
||||
|
||||
if (size != 0 && nmemb > SIZE_MAX / size) return 0;
|
||||
bytes = size * nmemb;
|
||||
if (bytes > CALENDAR_DOWNLOAD_LIMIT - download->received) return 0;
|
||||
if (fwrite(buffer, size, nmemb, download->file) != nmemb) return 0;
|
||||
download->received += bytes;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
int
|
||||
@@ -689,7 +716,7 @@ MsgImportIcsUrl(const char *user,
|
||||
CURLcode res;
|
||||
char filename[XPL_MAX_PATH];
|
||||
char *aliasedUrl = NULL;
|
||||
int ret = 0;
|
||||
int ret = MSG_COLLECT_ERROR_DOWNLOAD;
|
||||
|
||||
/* TODO: fix up url.
|
||||
* Protocol could be webcal://, feed://, dav://, ical://, etc.
|
||||
@@ -700,9 +727,33 @@ MsgImportIcsUrl(const char *user,
|
||||
if (curl) {
|
||||
int ccode;
|
||||
FILE *fh = XplOpenTemp("/tmp", "w+", filename);
|
||||
CalendarDownload download;
|
||||
char cred[1024];
|
||||
|
||||
if (!fh) {
|
||||
curl_easy_cleanup(curl);
|
||||
return MSG_COLLECT_ERROR_DOWNLOAD;
|
||||
}
|
||||
memset(&download, 0, sizeof(download));
|
||||
download.file = fh;
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
|
||||
#if LIBCURL_VERSION_NUM >= 0x075500
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
|
||||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
||||
#else
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
|
||||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS);
|
||||
#endif
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L);
|
||||
curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXFILESIZE_LARGE,
|
||||
(curl_off_t)CALENDAR_DOWNLOAD_LIMIT);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT,
|
||||
"Bongo/" BONGO_VERSION " calendar subscriber");
|
||||
curl_easy_setopt(curl, CURLOPT_OPENSOCKETFUNCTION,
|
||||
CalendarOpenSocket);
|
||||
if (aliasedUrl) {
|
||||
curl_easy_setopt(curl, CURLOPT_URL, aliasedUrl);
|
||||
} else {
|
||||
@@ -712,7 +763,7 @@ MsgImportIcsUrl(const char *user,
|
||||
curl_easy_setopt(curl, CURLOPT_VERBOSE, TRUE);
|
||||
#endif
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, *write_data);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fh);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &download);
|
||||
|
||||
if (urlUsername && urlPassword) {
|
||||
snprintf(cred, 1024, "%s:%s", urlUsername, urlPassword);
|
||||
|
||||
@@ -23,7 +23,56 @@
|
||||
|
||||
#include <xpl.h>
|
||||
#include <logger.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <curl/curl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
#include "msghttp.h"
|
||||
|
||||
int
|
||||
MsgHttpPublicAddress(const struct sockaddr *address)
|
||||
{
|
||||
if (address->sa_family == AF_INET) {
|
||||
const struct sockaddr_in *ipv4 = (const struct sockaddr_in *)address;
|
||||
uint32_t value = ntohl(ipv4->sin_addr.s_addr);
|
||||
|
||||
if ((value >> 24) == 0 || (value >> 24) == 10 ||
|
||||
(value >> 24) == 127 || (value >> 16) == 0xa9fe ||
|
||||
(value >> 20) == 0xac1 || (value >> 16) == 0xc0a8 ||
|
||||
(value >> 22) == 0x0191 ||
|
||||
(value >> 8) == 0xc00000 || (value >> 8) == 0xc00002 ||
|
||||
(value & 0xfffe0000U) == 0xc6120000U ||
|
||||
(value >> 8) == 0xc63364 || (value >> 8) == 0xcb0071 ||
|
||||
(value >> 28) >= 0x0e) {
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
if (address->sa_family == AF_INET6) {
|
||||
const struct sockaddr_in6 *ipv6 =
|
||||
(const struct sockaddr_in6 *)address;
|
||||
const unsigned char *bytes = ipv6->sin6_addr.s6_addr;
|
||||
|
||||
if (IN6_IS_ADDR_UNSPECIFIED(&ipv6->sin6_addr) ||
|
||||
IN6_IS_ADDR_LOOPBACK(&ipv6->sin6_addr) ||
|
||||
IN6_IS_ADDR_LINKLOCAL(&ipv6->sin6_addr) ||
|
||||
IN6_IS_ADDR_MULTICAST(&ipv6->sin6_addr) ||
|
||||
(bytes[0] & 0xfe) == 0xfc) {
|
||||
return FALSE;
|
||||
}
|
||||
if (IN6_IS_ADDR_V4MAPPED(&ipv6->sin6_addr)) {
|
||||
struct sockaddr_in mapped;
|
||||
memset(&mapped, 0, sizeof(mapped));
|
||||
mapped.sin_family = AF_INET;
|
||||
memcpy(&mapped.sin_addr, bytes + 12, sizeof(mapped.sin_addr));
|
||||
return MsgHttpPublicAddress((const struct sockaddr *)&mapped);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
CURLcode
|
||||
msg_curl_easy_perform_cached(CURL *handle)
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#define MSGAPIHTTP_H
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -30,6 +31,7 @@ extern "C" {
|
||||
|
||||
/* from msghttp.c */
|
||||
CURLcode msg_curl_easy_perform_cached(CURL *handle);
|
||||
int MsgHttpPublicAddress(const struct sockaddr *address);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/****************************************************************************
|
||||
* <Novell-copyright>
|
||||
* Copyright (c) 2001 Novell, Inc. All Rights Reserved.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of version 2 of the GNU General Public License
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* 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, contact Novell, Inc.
|
||||
*
|
||||
* To contact Novell about this file by physical or electronic mail, you
|
||||
* may find current contact information at www.novell.com.
|
||||
* </Novell-copyright>
|
||||
****************************************************************************/
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "../msghttp.h"
|
||||
|
||||
static int
|
||||
CheckAddress(int family, const char *text, int expected)
|
||||
{
|
||||
struct sockaddr_storage storage;
|
||||
void *destination;
|
||||
|
||||
memset(&storage, 0, sizeof(storage));
|
||||
storage.ss_family = family;
|
||||
destination = family == AF_INET
|
||||
? (void *)&((struct sockaddr_in *)&storage)->sin_addr
|
||||
: (void *)&((struct sockaddr_in6 *)&storage)->sin6_addr;
|
||||
if (inet_pton(family, text, destination) != 1 ||
|
||||
MsgHttpPublicAddress((const struct sockaddr *)&storage) != expected) {
|
||||
fprintf(stderr, "unexpected calendar download classification: %s\n",
|
||||
text);
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
int
|
||||
main(void)
|
||||
{
|
||||
return !(CheckAddress(AF_INET, "127.0.0.1", 0) &&
|
||||
CheckAddress(AF_INET, "10.0.0.1", 0) &&
|
||||
CheckAddress(AF_INET, "100.64.0.1", 0) &&
|
||||
CheckAddress(AF_INET, "192.0.2.1", 0) &&
|
||||
CheckAddress(AF_INET, "93.184.216.34", 1) &&
|
||||
CheckAddress(AF_INET6, "::1", 0) &&
|
||||
CheckAddress(AF_INET6, "fd00::1", 0) &&
|
||||
CheckAddress(AF_INET6, "2606:2800:220:1:248:1893:25c8:1946", 1));
|
||||
}
|
||||
+98
-14
@@ -49,11 +49,11 @@ from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from bongo_web.auth import (LoginLimiter, authenticate, renew, revoke,
|
||||
valid_csrf)
|
||||
from bongo_web.calendar_store import (EVENT_NAME, CalendarNotFound, CalendarPathError,
|
||||
PreconditionFailed, delete_event,
|
||||
event_to_dict, list_events, make_event,
|
||||
parse_calendar_path, read_event,
|
||||
write_event)
|
||||
from bongo_web.calendar_import import fetch_ics, import_ics
|
||||
from bongo_web.calendar_store import (
|
||||
CALENDAR_NAME, EVENT_NAME, CalendarNotFound, CalendarPathError,
|
||||
PreconditionFailed, delete_event, event_to_dict, list_calendar_events,
|
||||
list_calendars, make_event, parse_calendar_path, read_event, write_event)
|
||||
from bongo_web.card_store import (CARD_NAME, CardNotFound, CardPathError,
|
||||
CardPreconditionFailed, delete_card,
|
||||
card_to_dict, list_cards, make_card,
|
||||
@@ -95,6 +95,7 @@ PROVIDER_PRESETS = "@DATA_INSTALL_DIR@/providers/provider-presets.json"
|
||||
ACME_CHALLENGE_DIR = "@XPL_DEFAULT_ACME_CHALLENGE_DIR@"
|
||||
RUN_USER = "@BONGO_USER@"
|
||||
SERVER_NAME = "Bongo"
|
||||
SERVER_VERSION = "@BONGO_V_STR@"
|
||||
STATIC_FILES = {
|
||||
"/assets/app.css": ("app.css", "text/css; charset=utf-8"),
|
||||
"/assets/app.js": ("app.js", "text/javascript; charset=utf-8"),
|
||||
@@ -173,6 +174,12 @@ UI_MESSAGES = ("Email address", "Password", "Sign in", "Sign out", "Mail",
|
||||
"Attendee email addresses (one per line)", "Recurrence rule",
|
||||
"Reminder (minutes before)",
|
||||
"Attendees are stored in the iCalendar event; invitation delivery is not enabled yet.",
|
||||
"Calendar source", "Import calendar", "Calendar",
|
||||
"Personal calendar", "New calendar name", "ICS file",
|
||||
"Calendar URL", "Import once", "Keep synchronized",
|
||||
"Choose an ICS file or enter a public HTTPS calendar URL.",
|
||||
"Calendar imported.", "Calendar import failed.",
|
||||
"Austria public holidays",
|
||||
"Filters could not be loaded.", "Filter name", "Enabled",
|
||||
"Condition", "Value", "Action", "Sender contains",
|
||||
"Recipient contains", "Subject contains", "Sender domain",
|
||||
@@ -441,9 +448,18 @@ class BongoWebHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
try:
|
||||
if kind == "calendar":
|
||||
values = [event_to_dict(item) for item in list_events(
|
||||
session["user"], session["store_cookie"])]
|
||||
result = {"events": values}
|
||||
query = parse_qs(urlsplit(self.path).query)
|
||||
calendar = query.get("calendar", ["personal"])[-1]
|
||||
if not CALENDAR_NAME.fullmatch(calendar):
|
||||
raise ValueError("invalid calendar name")
|
||||
values = [event_to_dict(item) for item in list_calendar_events(
|
||||
session["user"], session["store_cookie"], calendar)]
|
||||
result = {
|
||||
"calendar": calendar,
|
||||
"calendars": list_calendars(
|
||||
session["user"], session["store_cookie"]),
|
||||
"events": values,
|
||||
}
|
||||
else:
|
||||
values = [card_to_dict(item) for item in list_cards(
|
||||
session["user"], session["store_cookie"])]
|
||||
@@ -471,6 +487,9 @@ class BongoWebHandler(BaseHTTPRequestHandler):
|
||||
resource = str(data.get("resource", "")).strip()
|
||||
etag = str(data.get("etag", "")).strip() or None
|
||||
if kind == "calendar":
|
||||
calendar = str(data.get("calendar", "personal")).strip()
|
||||
if not CALENDAR_NAME.fullmatch(calendar):
|
||||
raise ValueError("invalid calendar name")
|
||||
if resource and not EVENT_NAME.fullmatch(resource):
|
||||
raise ValueError("invalid event resource")
|
||||
resource = resource or os.urandom(16).hex() + ".ics"
|
||||
@@ -481,7 +500,7 @@ class BongoWebHandler(BaseHTTPRequestHandler):
|
||||
created, current = write_event(
|
||||
session["user"], session["store_cookie"], resource,
|
||||
make_event(data, existing), if_match=etag,
|
||||
if_none_match=None if etag else "*")
|
||||
if_none_match=None if etag else "*", calendar=calendar)
|
||||
else:
|
||||
if resource and not CARD_NAME.fullmatch(resource):
|
||||
raise ValueError("invalid contact resource")
|
||||
@@ -681,7 +700,7 @@ class BongoWebHandler(BaseHTTPRequestHandler):
|
||||
"user": session["user"],
|
||||
"host": urlsplit(base).hostname or socket.getfqdn(),
|
||||
"domains": self.server.config.get("login_domains", []),
|
||||
"version": "@BONGO_V_STR@",
|
||||
"version": SERVER_VERSION,
|
||||
**details,
|
||||
}, {"Vary": "Cookie"})
|
||||
elif path.startswith("/api/mail/"):
|
||||
@@ -879,6 +898,9 @@ class BongoWebHandler(BaseHTTPRequestHandler):
|
||||
if path == "/api/calendar/events":
|
||||
self._organizer_api_save("calendar")
|
||||
return
|
||||
if path == "/api/calendar/import":
|
||||
self._calendar_import()
|
||||
return
|
||||
if path == "/api/contacts":
|
||||
self._organizer_api_save("contacts")
|
||||
return
|
||||
@@ -1327,6 +1349,55 @@ class BongoWebHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self._empty(HTTPStatus.NO_CONTENT, {"Vary": "Cookie"})
|
||||
|
||||
def _calendar_import(self):
|
||||
session = self._session()
|
||||
if session is None:
|
||||
self._json(HTTPStatus.UNAUTHORIZED,
|
||||
{"error": "authentication required"})
|
||||
return
|
||||
if not valid_csrf(session, self.headers.get("X-Bongo-CSRF", "")):
|
||||
self._json(HTTPStatus.FORBIDDEN, {"error": "invalid CSRF token"})
|
||||
return
|
||||
try:
|
||||
data = self._request_data()
|
||||
calendar = str(data.get("calendar", "")).strip()
|
||||
source_type = str(data.get("source_type", "")).strip()
|
||||
subscribe = bool(data.get("subscribe", False))
|
||||
source_url = ""
|
||||
etag = ""
|
||||
last_modified = ""
|
||||
if source_type == "file":
|
||||
if subscribe:
|
||||
raise ValueError("uploaded calendars cannot be subscribed")
|
||||
content = data.get("content")
|
||||
if not isinstance(content, str):
|
||||
raise ValueError("calendar content is required")
|
||||
payload = content.encode("utf-8")
|
||||
elif source_type == "url":
|
||||
source = fetch_ics(
|
||||
str(data.get("url", "")),
|
||||
"Bongo/%s calendar subscriber" % SERVER_VERSION)
|
||||
payload = source["payload"]
|
||||
source_url = source["url"]
|
||||
etag = source["etag"]
|
||||
last_modified = source["last_modified"]
|
||||
else:
|
||||
raise ValueError("invalid calendar source")
|
||||
result = import_ics(
|
||||
session["user"], session["store_cookie"], calendar, payload,
|
||||
source_url=source_url, subscribe=subscribe, etag=etag,
|
||||
last_modified=last_modified,
|
||||
product_id="-//Bongo Project//Bongo %s//EN" % SERVER_VERSION)
|
||||
except (TypeError, ValueError, OverflowError, UnicodeError,
|
||||
json.JSONDecodeError) as error:
|
||||
self._json(HTTPStatus.BAD_REQUEST, {"error": str(error)})
|
||||
return
|
||||
except RuntimeError:
|
||||
logging.exception("Could not import calendar")
|
||||
self._empty(HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
return
|
||||
self._json(HTTPStatus.CREATED, result, {"Vary": "Cookie"})
|
||||
|
||||
def do_DELETE(self):
|
||||
path = self._path()
|
||||
if path == "/api/calendar/events":
|
||||
@@ -1444,7 +1515,8 @@ class BongoWebHandler(BaseHTTPRequestHandler):
|
||||
created, etag = write_event(
|
||||
session["user"], session["store_cookie"], resource["event"],
|
||||
payload, self.headers.get("If-Match"),
|
||||
self.headers.get("If-None-Match"))
|
||||
self.headers.get("If-None-Match"),
|
||||
calendar=resource.get("calendar", "personal"))
|
||||
except OverflowError:
|
||||
self._empty(HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
|
||||
return
|
||||
@@ -1598,8 +1670,18 @@ class BongoWebHandler(BaseHTTPRequestHandler):
|
||||
except (UnicodeError, ValueError):
|
||||
self._empty(HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
calendars = None
|
||||
if self._path().startswith("/calendars/"):
|
||||
try:
|
||||
calendars = list_calendars(
|
||||
session["user"], session["store_cookie"])
|
||||
except RuntimeError:
|
||||
logging.exception("Could not discover CalDAV calendars")
|
||||
self._empty(HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
return
|
||||
payload = multistatus(self._path(), session["user"],
|
||||
int(depth_value), requested)
|
||||
int(depth_value), requested,
|
||||
calendars=calendars)
|
||||
if payload is None:
|
||||
self._empty(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
@@ -1634,8 +1716,10 @@ class BongoWebHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
try:
|
||||
report = parse_report(self._request_body())
|
||||
events = list_events(session["user"], session["store_cookie"],
|
||||
report["start"], report["end"])
|
||||
events = list_calendar_events(
|
||||
session["user"], session["store_cookie"],
|
||||
resource.get("calendar", "personal"),
|
||||
report["start"], report["end"])
|
||||
except OverflowError:
|
||||
self._empty(HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# /****************************************************************************
|
||||
# * <Novell-copyright>
|
||||
# * Copyright (c) 2001 Novell, Inc. All Rights Reserved.
|
||||
# *
|
||||
# * This program is free software; you can redistribute it and/or
|
||||
# * modify it under the terms of version 2 of the GNU General Public License
|
||||
# * as published by the Free Software Foundation.
|
||||
# *
|
||||
# * 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, contact Novell, Inc.
|
||||
# *
|
||||
# * To contact Novell about this file by physical or electronic mail, you
|
||||
# * may find current contact information at www.novell.com.
|
||||
# * </Novell-copyright>
|
||||
# ****************************************************************************/
|
||||
|
||||
"""Safe iCalendar import helpers for the Bongo web interface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import http.client
|
||||
import ipaddress
|
||||
import socket
|
||||
import ssl
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
from bongo_web.calendar_store import (
|
||||
CALENDAR_NAME, EVENT_DOCUMENT_TYPE, ical_to_json, open_store)
|
||||
|
||||
|
||||
MAX_ICS_SIZE = 8 * 1024 * 1024
|
||||
MAX_REDIRECTS = 5
|
||||
FETCH_TIMEOUT = 20
|
||||
|
||||
|
||||
def _public_addresses(hostname, port):
|
||||
addresses = []
|
||||
for family, socktype, protocol, unused_name, address in socket.getaddrinfo(
|
||||
hostname, port, type=socket.SOCK_STREAM):
|
||||
value = ipaddress.ip_address(address[0])
|
||||
if not value.is_global:
|
||||
raise ValueError("calendar URL resolves to a non-public address")
|
||||
addresses.append((family, socktype, protocol, address))
|
||||
if not addresses:
|
||||
raise ValueError("calendar URL has no usable address")
|
||||
return addresses
|
||||
|
||||
|
||||
class _PinnedHttpsConnection(http.client.HTTPSConnection):
|
||||
def __init__(self, hostname, port, address, timeout):
|
||||
super().__init__(hostname, port, timeout=timeout,
|
||||
context=ssl.create_default_context())
|
||||
self._address = address
|
||||
|
||||
def connect(self):
|
||||
self.sock = socket.create_connection(
|
||||
(self._address[0], self._address[1]), self.timeout)
|
||||
if self._tunnel_host:
|
||||
self._tunnel()
|
||||
self.sock = self._context.wrap_socket(
|
||||
self.sock, server_hostname=self.host)
|
||||
|
||||
|
||||
def fetch_ics(url, user_agent="Bongo calendar subscriber"):
|
||||
"""Fetch a public HTTPS calendar without allowing SSRF redirects."""
|
||||
current = str(url).strip()
|
||||
for redirect in range(MAX_REDIRECTS + 1):
|
||||
parsed = urlsplit(current)
|
||||
if (parsed.scheme != "https" or not parsed.hostname
|
||||
or parsed.username is not None or parsed.password is not None
|
||||
or parsed.fragment):
|
||||
raise ValueError("calendar URL must be a public HTTPS URL")
|
||||
port = parsed.port or 443
|
||||
address = _public_addresses(parsed.hostname, port)[0][3]
|
||||
path = parsed.path or "/"
|
||||
if parsed.query:
|
||||
path += "?" + parsed.query
|
||||
connection = _PinnedHttpsConnection(
|
||||
parsed.hostname, port, address, FETCH_TIMEOUT)
|
||||
try:
|
||||
connection.request(
|
||||
"GET", path,
|
||||
headers={"Accept": "text/calendar, application/ics;q=0.9",
|
||||
"User-Agent": user_agent})
|
||||
response = connection.getresponse()
|
||||
if response.status in (301, 302, 303, 307, 308):
|
||||
location = response.getheader("Location")
|
||||
response.read()
|
||||
if not location or redirect == MAX_REDIRECTS:
|
||||
raise ValueError("calendar URL has too many redirects")
|
||||
current = urljoin(current, location)
|
||||
continue
|
||||
if response.status != 200:
|
||||
raise ValueError(
|
||||
"calendar server returned HTTP %d" % response.status)
|
||||
payload = response.read(MAX_ICS_SIZE + 1)
|
||||
if len(payload) > MAX_ICS_SIZE:
|
||||
raise ValueError("calendar source is larger than 8 MiB")
|
||||
return {
|
||||
"payload": payload,
|
||||
"url": current,
|
||||
"etag": response.getheader("ETag", ""),
|
||||
"last_modified": response.getheader("Last-Modified", ""),
|
||||
}
|
||||
finally:
|
||||
connection.close()
|
||||
raise ValueError("calendar URL redirect failed")
|
||||
|
||||
|
||||
def _components(payload, product_id):
|
||||
if len(payload) > MAX_ICS_SIZE:
|
||||
raise ValueError("calendar source is larger than 8 MiB")
|
||||
text = payload.decode("utf-8-sig", "strict")
|
||||
try:
|
||||
import vobject
|
||||
calendars = list(vobject.readComponents(text))
|
||||
except Exception as error:
|
||||
raise ValueError("invalid iCalendar source") from error
|
||||
events = []
|
||||
display_name = ""
|
||||
for calendar in calendars:
|
||||
if not display_name and hasattr(calendar, "x_wr_calname"):
|
||||
display_name = str(calendar.x_wr_calname.value)
|
||||
timezones = calendar.contents.get("vtimezone", [])
|
||||
for event in calendar.contents.get("vevent", []):
|
||||
if not hasattr(event, "uid") or not str(event.uid.value).strip():
|
||||
raise ValueError("calendar event has no UID")
|
||||
lines = [
|
||||
"BEGIN:VCALENDAR\r\n",
|
||||
"VERSION:2.0\r\n",
|
||||
"PRODID:" + product_id + "\r\n",
|
||||
]
|
||||
lines.extend(timezone.serialize() for timezone in timezones)
|
||||
lines.append(event.serialize())
|
||||
lines.append("END:VCALENDAR\r\n")
|
||||
events.append((str(event.uid.value), "".join(lines).encode("utf-8")))
|
||||
if not events:
|
||||
raise ValueError("calendar source contains no events")
|
||||
return display_name, events
|
||||
|
||||
|
||||
def import_ics(user, store_cookie, calendar_name, payload, *,
|
||||
source_url="", subscribe=False, etag="", last_modified="",
|
||||
client_factory=open_store, to_json=ical_to_json,
|
||||
product_id="-//Bongo Project//Bongo//EN"):
|
||||
"""Import all VEVENTs into one existing or newly-created calendar."""
|
||||
calendar_name = str(calendar_name).strip()
|
||||
if not CALENDAR_NAME.fullmatch(calendar_name):
|
||||
raise ValueError("invalid calendar name")
|
||||
display_name, events = _components(payload, product_id)
|
||||
client = None
|
||||
imported = 0
|
||||
try:
|
||||
client = client_factory(user, store_cookie)
|
||||
path = "/calendars/" + calendar_name
|
||||
client.Create(path, existingOk=True)
|
||||
for uid, event in events:
|
||||
filename = "ics-" + hashlib.sha256(
|
||||
(calendar_name + "\0" + uid).encode("utf-8")).hexdigest()
|
||||
event_path = "/events/" + filename
|
||||
document = to_json(event)
|
||||
if client.Info(event_path) is None:
|
||||
client.Write("/events", EVENT_DOCUMENT_TYPE, document,
|
||||
filename=filename, link=path)
|
||||
else:
|
||||
client.Replace(event_path, document)
|
||||
links = {item.filename for item in client.Links(event_path)}
|
||||
if path not in links:
|
||||
client.Link(path, event_path)
|
||||
imported += 1
|
||||
if display_name:
|
||||
client.PropSet(path, "bongo.calendar.icsname", display_name)
|
||||
if subscribe:
|
||||
if not source_url:
|
||||
raise ValueError("subscription URL is required")
|
||||
client.PropSet(path, "bongo.calendar.url", source_url)
|
||||
client.PropSet(path, "bongo.calendar.etag", etag)
|
||||
client.PropSet(path, "bongo.calendar.lastmodified", last_modified)
|
||||
client.PropSet(path, "bongo.calendar.type", "web")
|
||||
return {"calendar": calendar_name, "events": imported,
|
||||
"subscribed": bool(subscribe)}
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise RuntimeError("could not import calendar") from error
|
||||
finally:
|
||||
if client is not None:
|
||||
try:
|
||||
client.Quit()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -29,6 +29,7 @@ from urllib.parse import quote, unquote
|
||||
|
||||
|
||||
EVENT_NAME = re.compile(r"^[A-Za-z0-9._~-]{1,240}\.ics$")
|
||||
CALENDAR_NAME = re.compile(r"^[A-Za-z0-9._~-]{1,128}$")
|
||||
EVENT_DOCUMENT_TYPE = 0x0003
|
||||
|
||||
|
||||
@@ -51,20 +52,22 @@ def parse_calendar_path(path, authenticated_user):
|
||||
user = unquote(parts[1])
|
||||
if user != authenticated_user:
|
||||
raise PermissionError("calendar owner differs from authenticated user")
|
||||
if unquote(parts[2]) != "personal":
|
||||
raise CalendarNotFound("calendar does not exist")
|
||||
calendar = unquote(parts[2])
|
||||
if not CALENDAR_NAME.fullmatch(calendar):
|
||||
raise CalendarPathError("invalid calendar name")
|
||||
if len(parts) == 3:
|
||||
return {"kind": "calendar", "user": user, "calendar": "personal"}
|
||||
return {"kind": "calendar", "user": user, "calendar": calendar}
|
||||
event = unquote(parts[3])
|
||||
if not EVENT_NAME.fullmatch(event):
|
||||
raise CalendarPathError("invalid event resource name")
|
||||
return {"kind": "event", "user": user, "calendar": "personal",
|
||||
return {"kind": "event", "user": user, "calendar": calendar,
|
||||
"event": event, "store_path": "/events/" + event[:-4]}
|
||||
|
||||
|
||||
def event_href(user, event):
|
||||
return "/calendars/{}/personal/{}".format(
|
||||
quote(user, safe="@.-_~"), quote(event, safe="._~-"))
|
||||
def event_href(user, event, calendar="personal"):
|
||||
return "/calendars/{}/{}/{}".format(
|
||||
quote(user, safe="@.-_~"), quote(calendar, safe="._~-"),
|
||||
quote(event, safe="._~-"))
|
||||
|
||||
|
||||
def open_store(user, store_cookie):
|
||||
@@ -178,6 +181,7 @@ def event_to_dict(item):
|
||||
|
||||
return {
|
||||
"resource": item["href"].rsplit("/", 1)[-1],
|
||||
"calendar": item.get("calendar", "personal"),
|
||||
"etag": item["etag"],
|
||||
"uid": _component_value(event, "uid"),
|
||||
"summary": _component_value(event, "summary", "(no title)"),
|
||||
@@ -312,8 +316,11 @@ def make_event(data, existing=None):
|
||||
|
||||
def write_event(user, store_cookie, event, payload, if_match=None,
|
||||
if_none_match=None, client_factory=open_store,
|
||||
to_json=ical_to_json, to_ical=json_to_ical):
|
||||
to_json=ical_to_json, to_ical=json_to_ical,
|
||||
calendar="personal"):
|
||||
"""Create or replace one event and return ``(created, etag)``."""
|
||||
if not CALENDAR_NAME.fullmatch(calendar):
|
||||
raise ValueError("invalid calendar name")
|
||||
document = to_json(payload)
|
||||
path = "/events/" + event[:-4]
|
||||
client = None
|
||||
@@ -332,7 +339,8 @@ def write_event(user, store_cookie, event, payload, if_match=None,
|
||||
client.Replace(path, document)
|
||||
else:
|
||||
client.Write("/events", EVENT_DOCUMENT_TYPE, document,
|
||||
filename=event[:-4], link="/calendars/personal")
|
||||
filename=event[:-4],
|
||||
link="/calendars/" + calendar)
|
||||
return not exists, make_etag(to_ical(document))
|
||||
except (PreconditionFailed, ValueError):
|
||||
raise
|
||||
@@ -363,14 +371,51 @@ def delete_event(user, store_cookie, event, if_match=None,
|
||||
_close(client)
|
||||
|
||||
|
||||
def list_events(user, store_cookie, start=None, end=None,
|
||||
client_factory=open_store, to_ical=json_to_ical):
|
||||
def list_calendars(user, store_cookie, client_factory=open_store):
|
||||
client = None
|
||||
try:
|
||||
client = client_factory(user, store_cookie)
|
||||
calendar = client.Info("/calendars/personal")
|
||||
output = []
|
||||
for item in client.List(
|
||||
"/calendars",
|
||||
props=["bongo.calendar.url", "bongo.calendar.icsname"]):
|
||||
name = item.filename.rstrip("/").rsplit("/", 1)[-1]
|
||||
if not CALENDAR_NAME.fullmatch(name):
|
||||
continue
|
||||
output.append({
|
||||
"name": name,
|
||||
"display_name": item.props.get(
|
||||
"bongo.calendar.icsname", name),
|
||||
"subscribed": bool(item.props.get("bongo.calendar.url")),
|
||||
"source_url": item.props.get("bongo.calendar.url", ""),
|
||||
})
|
||||
output.sort(key=lambda item: (
|
||||
item["name"] != "personal", item["display_name"].casefold()))
|
||||
return output
|
||||
except Exception as error:
|
||||
raise RuntimeError("could not list calendars") from error
|
||||
finally:
|
||||
_close(client)
|
||||
|
||||
|
||||
def list_events(user, store_cookie, start=None, end=None,
|
||||
client_factory=open_store, to_ical=json_to_ical):
|
||||
return list_calendar_events(
|
||||
user, store_cookie, "personal", start=start, end=end,
|
||||
client_factory=client_factory, to_ical=to_ical)
|
||||
|
||||
|
||||
def list_calendar_events(user, store_cookie, calendar_name,
|
||||
start=None, end=None, client_factory=open_store,
|
||||
to_ical=json_to_ical):
|
||||
if not CALENDAR_NAME.fullmatch(calendar_name):
|
||||
raise ValueError("invalid calendar name")
|
||||
client = None
|
||||
try:
|
||||
client = client_factory(user, store_cookie)
|
||||
calendar = client.Info("/calendars/" + calendar_name)
|
||||
if calendar is None:
|
||||
raise CalendarNotFound("personal calendar not found")
|
||||
raise CalendarNotFound("calendar not found")
|
||||
output = []
|
||||
for item in client.Events(
|
||||
calendar.uid, ["nmap.document", "nmap.event.calendars"],
|
||||
@@ -384,8 +429,9 @@ def list_events(user, store_cookie, start=None, end=None,
|
||||
filename += ".ics"
|
||||
if not EVENT_NAME.fullmatch(filename):
|
||||
continue
|
||||
output.append({"href": event_href(user, filename),
|
||||
"etag": make_etag(payload), "data": payload})
|
||||
output.append({"href": event_href(user, filename, calendar_name),
|
||||
"etag": make_etag(payload), "data": payload,
|
||||
"calendar": calendar_name})
|
||||
return output
|
||||
except CalendarNotFound:
|
||||
raise
|
||||
|
||||
@@ -181,14 +181,14 @@ def _href_property(name, href, namespace=DAV):
|
||||
return element
|
||||
|
||||
|
||||
def discovery_resources(user):
|
||||
def discovery_resources(user, calendars=None):
|
||||
encoded = quote(user, safe="@.-_~")
|
||||
principal = f"/principals/{encoded}/"
|
||||
home = f"/calendars/{encoded}/"
|
||||
calendar = f"{home}personal/"
|
||||
address_home = f"/addressbooks/{encoded}/"
|
||||
addressbook = f"{address_home}personal/"
|
||||
return {
|
||||
resources = {
|
||||
"/calendars/": {
|
||||
qname(DAV, "resourcetype"): _resource_type((DAV, "collection")),
|
||||
qname(DAV, "displayname"): "Bongo calendars",
|
||||
@@ -244,6 +244,24 @@ def discovery_resources(user):
|
||||
qname(CS, "getctag"): "0",
|
||||
},
|
||||
}
|
||||
for item in calendars or ():
|
||||
name = str(item.get("name", ""))
|
||||
if not name or any(char not in
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
"0123456789._~-" for char in name):
|
||||
continue
|
||||
href = f"{home}{quote(name, safe='._~-')}/"
|
||||
resources[href] = {
|
||||
qname(DAV, "resourcetype"): _resource_type(
|
||||
(DAV, "collection"), (CALDAV, "calendar")),
|
||||
qname(DAV, "displayname"): str(
|
||||
item.get("display_name") or name),
|
||||
qname(DAV, "owner"): _href_property("owner", principal),
|
||||
qname(CALDAV, "supported-calendar-component-set"):
|
||||
_component_set(),
|
||||
qname(CS, "getctag"): "0",
|
||||
}
|
||||
return resources
|
||||
|
||||
|
||||
def _component_set():
|
||||
@@ -259,8 +277,8 @@ def _append_property(parent, name, value):
|
||||
ET.SubElement(parent, name).text = value
|
||||
|
||||
|
||||
def multistatus(path, user, depth, requested=None):
|
||||
resources = discovery_resources(user)
|
||||
def multistatus(path, user, depth, requested=None, calendars=None):
|
||||
resources = discovery_resources(user, calendars)
|
||||
if path not in resources:
|
||||
return None
|
||||
selected = [path]
|
||||
|
||||
+105
-2
@@ -189,9 +189,26 @@ function setAllDayInputTypes(allDay) {
|
||||
}
|
||||
|
||||
async function loadCalendar() {
|
||||
const response = await fetch("/api/calendar/events", {credentials: "same-origin"});
|
||||
const selector = document.querySelector("#calendar-select");
|
||||
const selected = selector.value || "personal";
|
||||
const response = await fetch(`/api/calendar/events?calendar=${encodeURIComponent(selected)}`,
|
||||
{credentials: "same-origin"});
|
||||
if (!response.ok) return;
|
||||
const events = (await response.json()).events;
|
||||
const result = await response.json();
|
||||
const events = result.events;
|
||||
const choices = result.calendars || [];
|
||||
const makeOptions = () => choices.map(item => {
|
||||
const option = document.createElement("option");
|
||||
option.value = item.name;
|
||||
option.textContent = item.display_name || item.name;
|
||||
if (item.subscribed) option.textContent += " ↻";
|
||||
return option;
|
||||
});
|
||||
selector.replaceChildren(...makeOptions());
|
||||
selector.value = choices.some(item => item.name === selected) ? selected : "personal";
|
||||
const importTarget = document.querySelector("#calendar-import-target");
|
||||
importTarget.replaceChildren(...makeOptions(), Object.assign(
|
||||
document.createElement("option"), {value: "__new__", textContent: tr("New calendar name")}));
|
||||
document.querySelector("#calendar-list").replaceChildren(...events.map(item => {
|
||||
const row = document.createElement("li");
|
||||
const button = document.createElement("button");
|
||||
@@ -206,9 +223,12 @@ async function loadCalendar() {
|
||||
|
||||
function editCalendarEvent(item = {}) {
|
||||
const form = document.querySelector("#calendar-form");
|
||||
document.querySelector("#calendar-import-form").hidden = true;
|
||||
document.querySelector("#calendar-resource").value = item.resource || "";
|
||||
document.querySelector("#calendar-etag").value = item.etag || "";
|
||||
document.querySelector("#calendar-uid").value = item.uid || "";
|
||||
document.querySelector("#calendar-event-calendar").value =
|
||||
item.calendar || document.querySelector("#calendar-select").value || "personal";
|
||||
document.querySelector("#calendar-summary").value = item.summary || "";
|
||||
document.querySelector("#calendar-all-day").checked = Boolean(item.all_day);
|
||||
setAllDayInputTypes(Boolean(item.all_day));
|
||||
@@ -231,6 +251,7 @@ async function saveCalendarEvent(event) {
|
||||
const payload = {resource: document.querySelector("#calendar-resource").value,
|
||||
etag: document.querySelector("#calendar-etag").value,
|
||||
uid: document.querySelector("#calendar-uid").value,
|
||||
calendar: document.querySelector("#calendar-event-calendar").value,
|
||||
summary: document.querySelector("#calendar-summary").value,
|
||||
all_day: document.querySelector("#calendar-all-day").checked,
|
||||
start: document.querySelector("#calendar-start").value,
|
||||
@@ -247,6 +268,69 @@ async function saveCalendarEvent(event) {
|
||||
if (response.ok) { event.currentTarget.hidden = true; await loadCalendar(); }
|
||||
}
|
||||
|
||||
function calendarImportTarget() {
|
||||
const selected = document.querySelector("#calendar-import-target").value;
|
||||
return selected === "__new__" ?
|
||||
document.querySelector("#calendar-import-name").value.trim() : selected;
|
||||
}
|
||||
|
||||
async function importCalendar(event) {
|
||||
event.preventDefault();
|
||||
const file = document.querySelector("#calendar-import-file").files[0];
|
||||
const url = document.querySelector("#calendar-import-url").value.trim();
|
||||
const status = document.querySelector("#calendar-import-status");
|
||||
let payload = {calendar: calendarImportTarget(),
|
||||
subscribe: document.querySelector("#calendar-import-mode").value === "subscribe"};
|
||||
if (file && url) {
|
||||
status.textContent = tr("Choose an ICS file or enter a public HTTPS calendar URL.");
|
||||
return;
|
||||
}
|
||||
if (file) {
|
||||
if (payload.subscribe) {
|
||||
status.textContent = tr("Choose an ICS file or enter a public HTTPS calendar URL.");
|
||||
return;
|
||||
}
|
||||
payload.source_type = "file";
|
||||
payload.content = await file.text();
|
||||
} else if (url) {
|
||||
payload.source_type = "url";
|
||||
payload.url = url;
|
||||
} else {
|
||||
status.textContent = tr("Choose an ICS file or enter a public HTTPS calendar URL.");
|
||||
return;
|
||||
}
|
||||
const response = await fetch("/api/calendar/import", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: {"Content-Type": "application/json", "X-Bongo-CSRF": session.csrf_token},
|
||||
body: JSON.stringify(payload)});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
status.textContent = error.error || tr("Calendar import failed.");
|
||||
return;
|
||||
}
|
||||
const result = await response.json();
|
||||
status.textContent = tr("Calendar imported.");
|
||||
document.querySelector("#calendar-select").value = result.calendar;
|
||||
document.querySelector("#calendar-import-form").hidden = true;
|
||||
await loadCalendar();
|
||||
}
|
||||
|
||||
function showCalendarImport() {
|
||||
document.querySelector("#calendar-form").hidden = true;
|
||||
document.querySelector("#calendar-import-form").hidden = false;
|
||||
document.querySelector("#calendar-import-status").textContent = "";
|
||||
}
|
||||
|
||||
function useAustriaHolidayCalendar() {
|
||||
document.querySelector("#calendar-import-target").value = "__new__";
|
||||
document.querySelector("#calendar-import-name").disabled = false;
|
||||
document.querySelector("#calendar-import-name").value = "holidays-at";
|
||||
document.querySelector("#calendar-import-url").value =
|
||||
"https://www.feiertage-oesterreich.at/kalender-download/ics/feiertage-oesterreich.ics";
|
||||
document.querySelector("#calendar-import-file").value = "";
|
||||
document.querySelector("#calendar-import-mode").value = "subscribe";
|
||||
}
|
||||
|
||||
async function deleteCalendarEvent() {
|
||||
const resource = document.querySelector("#calendar-resource").value;
|
||||
const etag = document.querySelector("#calendar-etag").value;
|
||||
@@ -1131,8 +1215,27 @@ document.querySelectorAll("a[data-section]").forEach(link =>
|
||||
document.querySelector("#mail-folder").addEventListener("change", loadConversations);
|
||||
document.querySelector("#mail-refresh").addEventListener("click", loadConversations);
|
||||
document.querySelector("#calendar-new").addEventListener("click", () => editCalendarEvent());
|
||||
document.querySelector("#calendar-import").addEventListener("click", showCalendarImport);
|
||||
document.querySelector("#calendar-refresh").addEventListener("click", loadCalendar);
|
||||
document.querySelector("#calendar-select").addEventListener("change", loadCalendar);
|
||||
document.querySelector("#calendar-form").addEventListener("submit", saveCalendarEvent);
|
||||
document.querySelector("#calendar-import-form").addEventListener("submit", importCalendar);
|
||||
document.querySelector("#calendar-import-target").addEventListener("change", event => {
|
||||
document.querySelector("#calendar-import-name").disabled = event.currentTarget.value !== "__new__";
|
||||
});
|
||||
document.querySelector("#calendar-import-file").addEventListener("change", event => {
|
||||
if (event.currentTarget.files.length) {
|
||||
document.querySelector("#calendar-import-url").value = "";
|
||||
document.querySelector("#calendar-import-mode").value = "once";
|
||||
}
|
||||
});
|
||||
document.querySelector("#calendar-import-url").addEventListener("input", event => {
|
||||
if (event.currentTarget.value) document.querySelector("#calendar-import-file").value = "";
|
||||
});
|
||||
document.querySelector("#calendar-import-austria").addEventListener("click", useAustriaHolidayCalendar);
|
||||
document.querySelector("#calendar-import-cancel").addEventListener("click", () => {
|
||||
document.querySelector("#calendar-import-form").hidden = true;
|
||||
});
|
||||
document.querySelector("#calendar-all-day").addEventListener("change", event => setAllDayInputTypes(event.currentTarget.checked));
|
||||
document.querySelector("#calendar-delete").addEventListener("click", deleteCalendarEvent);
|
||||
document.querySelector("#calendar-cancel").addEventListener("click", () => { document.querySelector("#calendar-form").hidden = true; });
|
||||
|
||||
@@ -109,10 +109,30 @@
|
||||
</table>
|
||||
</form>
|
||||
<section id="calendar-view" class="organizer-view" hidden>
|
||||
<div class="organizer-toolbar"><button id="calendar-new" type="button" data-i18n="New event">New event</button><button id="calendar-refresh" type="button" data-i18n="Refresh">Refresh</button></div>
|
||||
<div class="organizer-toolbar">
|
||||
<label><span data-i18n="Calendar">Calendar</span><select id="calendar-select"><option value="personal" data-i18n="Personal calendar">Personal calendar</option></select></label>
|
||||
<button id="calendar-new" type="button" data-i18n="New event">New event</button>
|
||||
<button id="calendar-import" type="button" data-i18n="Import calendar">Import calendar</button>
|
||||
<button id="calendar-refresh" type="button" data-i18n="Refresh">Refresh</button>
|
||||
</div>
|
||||
<ol id="calendar-list" class="organizer-list" aria-live="polite"></ol>
|
||||
<form id="calendar-import-form" class="organizer-form" hidden>
|
||||
<h3 class="wide" data-i18n="Calendar source">Calendar source</h3>
|
||||
<label><span data-i18n="Calendar">Calendar</span><select id="calendar-import-target"><option value="personal" data-i18n="Personal calendar">Personal calendar</option><option value="__new__" data-i18n="New calendar name">New calendar name</option></select></label>
|
||||
<label><span data-i18n="New calendar name">New calendar name</span><input id="calendar-import-name" maxlength="128" value="holidays" disabled></label>
|
||||
<label class="wide"><span data-i18n="ICS file">ICS file</span><input id="calendar-import-file" type="file" accept=".ics,text/calendar"></label>
|
||||
<label class="wide"><span data-i18n="Calendar URL">Calendar URL</span><input id="calendar-import-url" type="url" maxlength="4096" placeholder="https://example.test/calendar.ics"></label>
|
||||
<label><span data-i18n="Calendar source">Calendar source</span><select id="calendar-import-mode"><option value="once" data-i18n="Import once">Import once</option><option value="subscribe" data-i18n="Keep synchronized">Keep synchronized</option></select></label>
|
||||
<div class="wide organizer-actions">
|
||||
<button id="calendar-import-austria" type="button" data-i18n="Austria public holidays">Austria public holidays</button>
|
||||
<button type="submit" data-i18n="Import calendar">Import calendar</button>
|
||||
<button id="calendar-import-cancel" type="button" data-i18n="Cancel">Cancel</button>
|
||||
<span id="calendar-import-status" role="status"></span>
|
||||
</div>
|
||||
<p class="wide muted" data-i18n="Choose an ICS file or enter a public HTTPS calendar URL.">Choose an ICS file or enter a public HTTPS calendar URL.</p>
|
||||
</form>
|
||||
<form id="calendar-form" class="organizer-form" hidden>
|
||||
<input id="calendar-resource" type="hidden"><input id="calendar-etag" type="hidden"><input id="calendar-uid" type="hidden">
|
||||
<input id="calendar-resource" type="hidden"><input id="calendar-etag" type="hidden"><input id="calendar-uid" type="hidden"><input id="calendar-event-calendar" type="hidden">
|
||||
<label><span data-i18n="Title">Title</span><input id="calendar-summary" required maxlength="512"></label>
|
||||
<label class="check"><input id="calendar-all-day" type="checkbox"><span data-i18n="All-day event">All-day event</span></label>
|
||||
<label><span data-i18n="Start">Start</span><input id="calendar-start" type="datetime-local" required></label>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# /****************************************************************************
|
||||
# * <Novell-copyright>
|
||||
# * Copyright (c) 2001 Novell, Inc. All Rights Reserved.
|
||||
# *
|
||||
# * This program is free software; you can redistribute it and/or
|
||||
# * modify it under the terms of version 2 of the GNU General Public License
|
||||
# * as published by the Free Software Foundation.
|
||||
# *
|
||||
# * 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, contact Novell, Inc.
|
||||
# *
|
||||
# * To contact Novell about this file by physical or electronic mail, you
|
||||
# * may find current contact information at www.novell.com.
|
||||
# * </Novell-copyright>
|
||||
# ****************************************************************************/
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from bongo_web.calendar_import import (
|
||||
_public_addresses, fetch_ics, import_ics)
|
||||
|
||||
|
||||
ICS = b"""BEGIN:VCALENDAR\r
|
||||
VERSION:2.0\r
|
||||
X-WR-CALNAME:Feiertage\r
|
||||
BEGIN:VEVENT\r
|
||||
UID:holiday-1@example.test\r
|
||||
DTSTART;VALUE=DATE:20261225\r
|
||||
DTEND;VALUE=DATE:20261226\r
|
||||
SUMMARY:Feiertag\r
|
||||
END:VEVENT\r
|
||||
END:VCALENDAR\r
|
||||
"""
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self):
|
||||
self.documents = {}
|
||||
self.properties = {}
|
||||
self.created = []
|
||||
self.closed = False
|
||||
|
||||
def Create(self, path, existingOk=False):
|
||||
self.created.append(path)
|
||||
|
||||
def Info(self, path):
|
||||
return self.documents.get(path)
|
||||
|
||||
def Write(self, collection, doc_type, document, filename=None, link=None):
|
||||
self.documents["/events/" + filename] = document
|
||||
|
||||
def Replace(self, path, document):
|
||||
self.documents[path] = document
|
||||
|
||||
def Links(self, path):
|
||||
return []
|
||||
|
||||
def Link(self, calendar, path):
|
||||
raise AssertionError("new imports are already linked")
|
||||
|
||||
def PropSet(self, path, name, value=None):
|
||||
self.properties[(path, name)] = value
|
||||
|
||||
def Quit(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class CalendarImportTests(unittest.TestCase):
|
||||
def test_imports_into_named_calendar_and_records_subscription(self):
|
||||
store = FakeStore()
|
||||
result = import_ics(
|
||||
"u", "cookie", "holidays", ICS,
|
||||
source_url="https://example.test/holidays.ics", subscribe=True,
|
||||
etag='"one"', last_modified="yesterday",
|
||||
client_factory=lambda user, cookie: store,
|
||||
to_json=lambda payload: payload.decode("utf-8"))
|
||||
self.assertEqual(result, {"calendar": "holidays", "events": 1,
|
||||
"subscribed": True})
|
||||
self.assertEqual(store.created, ["/calendars/holidays"])
|
||||
self.assertEqual(len(store.documents), 1)
|
||||
self.assertEqual(store.properties[
|
||||
("/calendars/holidays", "bongo.calendar.icsname")], "Feiertage")
|
||||
self.assertEqual(store.properties[
|
||||
("/calendars/holidays", "bongo.calendar.url")],
|
||||
"https://example.test/holidays.ics")
|
||||
self.assertTrue(store.closed)
|
||||
|
||||
def test_rejects_missing_events_and_invalid_names(self):
|
||||
with self.assertRaises(ValueError):
|
||||
import_ics("u", "c", "bad name", ICS)
|
||||
with self.assertRaises(ValueError):
|
||||
import_ics("u", "c", "personal",
|
||||
b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n")
|
||||
|
||||
def test_private_source_addresses_are_rejected(self):
|
||||
answer = [(2, 1, 6, "", ("127.0.0.1", 443))]
|
||||
with mock.patch("socket.getaddrinfo", return_value=answer):
|
||||
with self.assertRaises(ValueError):
|
||||
_public_addresses("calendar.example", 443)
|
||||
|
||||
def test_fetch_rejects_plain_http_before_network_access(self):
|
||||
with self.assertRaises(ValueError):
|
||||
fetch_ics("http://example.test/calendar.ics")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -25,8 +25,9 @@ from bongo_web.calendar_store import (CalendarNotFound, CalendarPathError,
|
||||
PreconditionFailed, delete_event,
|
||||
etag_matches, event_href, event_to_dict,
|
||||
ical_to_json, make_event,
|
||||
list_events, make_etag, parse_calendar_path,
|
||||
write_event)
|
||||
list_calendar_events, list_calendars,
|
||||
list_events, make_etag,
|
||||
parse_calendar_path, write_event)
|
||||
|
||||
|
||||
class FakeStore:
|
||||
@@ -55,6 +56,9 @@ class FakeStore:
|
||||
def Events(self, calendar, props, start=None, end=None):
|
||||
return []
|
||||
|
||||
def List(self, path, props=None):
|
||||
return []
|
||||
|
||||
|
||||
class CalendarPathTests(unittest.TestCase):
|
||||
user = "mario@example.test"
|
||||
@@ -70,10 +74,10 @@ class CalendarPathTests(unittest.TestCase):
|
||||
parse_calendar_path(
|
||||
"/calendars/other@example.test/personal/event.ics", self.user)
|
||||
|
||||
def test_unknown_calendar(self):
|
||||
with self.assertRaises(CalendarNotFound):
|
||||
parse_calendar_path(
|
||||
"/calendars/mario@example.test/work/event.ics", self.user)
|
||||
def test_named_calendar(self):
|
||||
result = parse_calendar_path(
|
||||
"/calendars/mario@example.test/work/event.ics", self.user)
|
||||
self.assertEqual(result["calendar"], "work")
|
||||
|
||||
def test_path_tricks_are_rejected(self):
|
||||
for event in ("bad%2Fevent.ics", "bad event.ics", ".ics"):
|
||||
@@ -85,6 +89,12 @@ class CalendarPathTests(unittest.TestCase):
|
||||
self.assertEqual(event_href(self.user, "event~1.ics"),
|
||||
"/calendars/mario@example.test/personal/event~1.ics")
|
||||
|
||||
def test_invalid_calendar_name_is_rejected(self):
|
||||
with self.assertRaises(CalendarPathError):
|
||||
parse_calendar_path(
|
||||
"/calendars/mario@example.test/bad%20name/event.ics",
|
||||
self.user)
|
||||
|
||||
def test_calendar_shape_checked_before_native_converter(self):
|
||||
for data in (b"not a calendar",
|
||||
b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
|
||||
@@ -117,6 +127,13 @@ class CalendarMutationTests(unittest.TestCase):
|
||||
to_json=lambda value: "new",
|
||||
to_ical=lambda value: b"old canonical")
|
||||
|
||||
def test_create_rejects_invalid_calendar_name(self):
|
||||
with self.assertRaises(ValueError):
|
||||
write_event("u", "c", "one.ics", b"ical",
|
||||
calendar="bad name",
|
||||
client_factory=self.factory(FakeStore()),
|
||||
to_json=lambda value: "json")
|
||||
|
||||
def test_if_match_controls_update_and_delete(self):
|
||||
store = FakeStore({"/events/one": "old"})
|
||||
current = make_etag(b"old canonical")
|
||||
@@ -139,6 +156,26 @@ class CalendarMutationTests(unittest.TestCase):
|
||||
self.assertEqual(list_events("u", "c",
|
||||
client_factory=self.factory(store)), [])
|
||||
|
||||
def test_named_calendar_list(self):
|
||||
store = FakeStore({"/calendars/holidays": "calendar"})
|
||||
calendar = type("Calendar", (), {"uid": "456"})()
|
||||
store.Info = lambda path: calendar
|
||||
self.assertEqual(list_calendar_events(
|
||||
"u", "c", "holidays", client_factory=self.factory(store)), [])
|
||||
|
||||
def test_calendar_metadata_list(self):
|
||||
store = FakeStore()
|
||||
calendar = type("Calendar", (), {
|
||||
"filename": "/calendars/holidays",
|
||||
"props": {"bongo.calendar.icsname": "Feiertage",
|
||||
"bongo.calendar.url": "https://example.test/a.ics"}})()
|
||||
store.List = lambda path, props=None: [calendar]
|
||||
self.assertEqual(list_calendars(
|
||||
"u", "c", client_factory=self.factory(store)), [{
|
||||
"name": "holidays", "display_name": "Feiertage",
|
||||
"subscribed": True,
|
||||
"source_url": "https://example.test/a.ics"}])
|
||||
|
||||
def test_web_event_round_trip(self):
|
||||
payload = make_event({"summary": "Werkstatt", "start": "2026-07-22T09:30",
|
||||
"end": "2026-07-22T10:30", "location": "Wien",
|
||||
|
||||
@@ -38,6 +38,21 @@ class DavDiscoveryTests(unittest.TestCase):
|
||||
hrefs = [item.find(f"{{{DAV}}}href").text for item in responses]
|
||||
self.assertIn(self.home + "personal/", hrefs)
|
||||
|
||||
def test_depth_one_lists_named_subscription(self):
|
||||
root = ET.fromstring(multistatus(
|
||||
self.home, self.user, 1,
|
||||
calendars=[{"name": "personal", "display_name": "Personal"},
|
||||
{"name": "holidays-at",
|
||||
"display_name": "Feiertage Österreich"}]))
|
||||
responses = root.findall(f"{{{DAV}}}response")
|
||||
hrefs = [item.find(f"{{{DAV}}}href").text for item in responses]
|
||||
self.assertIn(self.home + "holidays-at/", hrefs)
|
||||
display_names = [
|
||||
element.text
|
||||
for element in root.findall(".//{DAV:}displayname")
|
||||
]
|
||||
self.assertIn("Feiertage Österreich", display_names)
|
||||
|
||||
def test_calendar_root_points_to_authenticated_principal(self):
|
||||
output = multistatus("/calendars/", self.user, 0)
|
||||
self.assertIn(b"current-user-principal", output)
|
||||
|
||||
@@ -190,6 +190,7 @@ class WebApiTests(unittest.TestCase):
|
||||
("GET", "/api/calendar/events"),
|
||||
("POST", "/api/calendar/events"),
|
||||
("DELETE", "/api/calendar/events"),
|
||||
("POST", "/api/calendar/import"),
|
||||
("GET", "/api/contacts"), ("POST", "/api/contacts"),
|
||||
("DELETE", "/api/contacts"),
|
||||
("GET", "/api/filters"), ("POST", "/api/filters"),
|
||||
@@ -414,7 +415,10 @@ class WebApiTests(unittest.TestCase):
|
||||
'"two"')
|
||||
|
||||
def test_calendar_create_list_update_delete(self):
|
||||
with mock.patch.object(web, "list_events", return_value=[b"event"]), \
|
||||
with mock.patch.object(web, "list_calendar_events",
|
||||
return_value=[b"event"]), \
|
||||
mock.patch.object(web, "list_calendars",
|
||||
return_value=[{"name": "personal"}]), \
|
||||
mock.patch.object(web, "event_to_dict",
|
||||
return_value={"resource": "event.ics",
|
||||
"title": "Meeting"}):
|
||||
@@ -449,6 +453,37 @@ class WebApiTests(unittest.TestCase):
|
||||
delete.assert_called_once_with("admin", "store-cookie", created["resource"],
|
||||
'"two"')
|
||||
|
||||
def test_calendar_file_and_url_import(self):
|
||||
request = {"calendar": "holidays", "source_type": "file",
|
||||
"content": "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
|
||||
"subscribe": False}
|
||||
with mock.patch.object(web, "import_ics",
|
||||
return_value={"calendar": "holidays",
|
||||
"events": 2,
|
||||
"subscribed": False}) as importer:
|
||||
status, _, payload = self.request(
|
||||
"POST", "/api/calendar/import", request)
|
||||
self.assertEqual(status, 201)
|
||||
self.assertEqual(self.json(payload)["events"], 2)
|
||||
self.assertEqual(importer.call_args.args[:3],
|
||||
("admin", "store-cookie", "holidays"))
|
||||
|
||||
fetched = {"payload": b"ICS", "url": "https://example.test/a.ics",
|
||||
"etag": '"one"', "last_modified": "today"}
|
||||
with mock.patch.object(web, "fetch_ics", return_value=fetched), \
|
||||
mock.patch.object(web, "import_ics",
|
||||
return_value={"calendar": "holidays",
|
||||
"events": 1,
|
||||
"subscribed": True}) as importer:
|
||||
status, _, _ = self.request(
|
||||
"POST", "/api/calendar/import",
|
||||
{"calendar": "holidays", "source_type": "url",
|
||||
"url": fetched["url"], "subscribe": True})
|
||||
self.assertEqual(status, 201)
|
||||
self.assertEqual(importer.call_args.kwargs["source_url"],
|
||||
fetched["url"])
|
||||
self.assertTrue(importer.call_args.kwargs["subscribe"])
|
||||
|
||||
def test_filter_create_list_update_delete(self):
|
||||
with mock.patch.object(web, "list_filters", return_value=[{"id": "rule-1"}]):
|
||||
status, _, payload = self.request("GET", "/api/filters")
|
||||
@@ -567,6 +602,8 @@ class WebApiTests(unittest.TestCase):
|
||||
"/addressbooks/admin/person.vcf")[0], 204)
|
||||
|
||||
with mock.patch.object(web, "requested_properties", return_value=[]), \
|
||||
mock.patch.object(web, "list_calendars",
|
||||
return_value=[{"name": "personal"}]), \
|
||||
mock.patch.object(web, "multistatus", return_value=b"<multistatus/>"):
|
||||
self.assertEqual(self.request("PROPFIND", "/calendars/admin/",
|
||||
raw=b"")[0], 207)
|
||||
@@ -575,7 +612,8 @@ class WebApiTests(unittest.TestCase):
|
||||
with mock.patch.object(web, "parse_calendar_path",
|
||||
return_value={"kind": "calendar"}), \
|
||||
mock.patch.object(web, "parse_report", return_value=calendar_report), \
|
||||
mock.patch.object(web, "list_events", return_value=[]), \
|
||||
mock.patch.object(web, "list_calendar_events",
|
||||
return_value=[]), \
|
||||
mock.patch.object(web, "event_multistatus",
|
||||
return_value=b"<multistatus/>"):
|
||||
self.assertEqual(self.request("REPORT", "/calendars/admin/",
|
||||
|
||||
Reference in New Issue
Block a user