Add secure ICS calendar imports and subscriptions

This commit is contained in:
Mario Fetka
2026-07-23 15:55:42 +02:00
parent 9ce62c2358
commit 65c4b3e8bc
21 changed files with 978 additions and 51 deletions
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+24
View File
@@ -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"
+24
View File
@@ -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 dexé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 limportation du calendrier."
msgid "Austria public holidays"
msgstr "Jours fériés autrichiens"
+24
View File
@@ -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"
+4
View File
@@ -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)
+55 -4
View File
@@ -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);
+49
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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
+197
View File
@@ -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
+61 -15
View File
@@ -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
+22 -4
View File
@@ -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
View File
@@ -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; });
+22 -2
View File
@@ -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>
+113
View File
@@ -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()
+43 -6
View File
@@ -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",
+15
View File
@@ -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)
+40 -2
View File
@@ -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/",