Skip to content

Instantly share code, notes, and snippets.

@freehuntx
Last active September 3, 2025 16:26
Show Gist options
  • Select an option

  • Save freehuntx/c9e68186203a0fc3faa85df4601ba456 to your computer and use it in GitHub Desktop.

Select an option

Save freehuntx/c9e68186203a0fc3faa85df4601ba456 to your computer and use it in GitHub Desktop.
WebDAV proxy
import { load } from "https://deno.land/[email protected]/dotenv/mod.ts";
import { parse } from "https://deno.land/x/[email protected]/mod.ts";
// Load environment variables from .env file if it exists
await load({ export: true, allowEmptyValues: true });
interface Config {
caldavUrl: string;
username: string;
password: string;
calendarName?: string;
}
type CalendarInfo = { href: string; displayName: string };
async function propfindCalendars(config: Config): Promise<CalendarInfo[]> {
const { caldavUrl, username, password } = config;
const auth = btoa(`${username}:${password}`);
const headers = {
"Authorization": `Basic ${auth}`,
"Depth": "1",
"Content-Type": "application/xml; charset=utf-8",
};
const body = `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d='DAV:' xmlns:cs='http://calendarserver.org/ns/'>
<d:prop>
<d:displayname/>
<cs:getctag/>
<d:resourcetype/>
</d:prop>
</d:propfind>`;
const response = await fetch(caldavUrl, {
method: "PROPFIND",
headers,
body,
});
if (!response.ok) {
throw new Error(`PROPFIND failed: HTTP ${response.status} ${response.statusText}`);
}
const xml = await response.text();
const doc: any = parse(xml);
const calendars: { href: string; displayName: string }[] = [];
// Traverse the parsed XML tree to find calendar URLs
if (doc && doc["D:multistatus"] && Array.isArray(doc["D:multistatus"]["D:response"])) {
for (const responseNode of doc["D:multistatus"]["D:response"]) {
const href = responseNode["D:href"];
let displayName = "";
let isCalendar = false;
// propstat can be an array or object
const propstats = Array.isArray(responseNode["D:propstat"]) ? responseNode["D:propstat"] : [responseNode["D:propstat"]];
for (const propstat of propstats) {
const prop = propstat["D:prop"];
if (prop) {
if (typeof prop["D:displayname"] === "string") {
displayName = prop["D:displayname"];
}
if (prop["D:resourcetype"] && ("CAL:calendar" in prop["D:resourcetype"])) {
isCalendar = true;
}
}
}
if (isCalendar && href) {
calendars.push({ href, displayName });
}
}
}
return calendars;
}
async function fetchCalDAVCalendar(calendarUrl: string, config: Config): Promise<string> {
const { username, password } = config;
const auth = btoa(`${username}:${password}`);
const headers = {
"Authorization": `Basic ${auth}`,
"Content-Type": "application/xml; charset=utf-8",
"Depth": "1",
};
const reportBody = `<?xml version="1.0" encoding="UTF-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag/>
<c:calendar-data/>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT"/>
</c:comp-filter>
</c:filter>
</c:calendar-query>`;
const response = await fetch(calendarUrl, {
method: "REPORT",
headers,
body: reportBody,
});
if (!response.ok) {
throw new Error(`REPORT failed: HTTP ${response.status}: ${response.statusText}`);
}
const xml = await response.text();
// Extract calendar-data from the XML response
const parsed: any = parse(xml);
let icalData = "";
if (parsed && parsed["D:multistatus"] && Array.isArray(parsed["D:multistatus"]["D:response"])) {
for (const resp of parsed["D:multistatus"]["D:response"]) {
const propstats = Array.isArray(resp["D:propstat"]) ? resp["D:propstat"] : [resp["D:propstat"]];
for (const propstat of propstats) {
const prop = propstat["D:prop"];
// Extract string content only
const keys = ["caldav:calendar-data", "c:calendar-data", "calendar-data"];
for (const key of keys) {
if (prop && prop[key]) {
if (typeof prop[key] === "string") {
icalData += prop[key] + "\n";
} else if (typeof prop[key] === "object" && "#text" in prop[key]) {
icalData += prop[key]["#text"] + "\n";
}
}
}
}
}
}
return icalData.trim();
}
function validateICalFormat(content: string): boolean {
const required = ["BEGIN:VCALENDAR", "END:VCALENDAR", "VERSION:", "PRODID:"];
return required.every(pattern => content.includes(pattern));
}
// Ensure each VEVENT has required fields for Google Calendar compatibility
function enrichICSEvents(ics: string): string {
// Split into blocks for easier processing
const blocks = ics.split(/(?=BEGIN:VCALENDAR)/);
let timezoneBlock = '';
const eventBlocks: string[] = [];
for (const block of blocks) {
// Extract VTIMEZONE (first one only)
if (!timezoneBlock) {
const tzMatch = block.match(/BEGIN:VTIMEZONE[\s\S]*?END:VTIMEZONE/);
if (tzMatch) timezoneBlock = tzMatch[0];
}
// Extract all VEVENTs
const eventMatches = block.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g);
if (eventMatches) eventBlocks.push(...eventMatches);
}
// Enrich events for Google compatibility
const requiredFields = ["UID", "DTSTAMP", "DTSTART", "DTEND", "SUMMARY"];
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*$/, 'Z');
const enrichedEvents = eventBlocks.map(evBlock => {
const lines = evBlock.split(/\r?\n/);
const present = new Set(lines.map(l => requiredFields.find(f => l.startsWith(f + ':'))).filter(Boolean));
const missing = requiredFields.filter(f => !present.has(f));
let dtstart = lines.find(l => l.startsWith("DTSTART"));
let dtend = lines.find(l => l.startsWith("DTEND"));
let summary = lines.find(l => l.startsWith("SUMMARY"));
let insertIdx = 1; // after BEGIN:VEVENT
for (const field of missing) {
if (field === "UID") {
lines.splice(insertIdx++, 0, `UID:auto-${Math.random().toString(36).slice(2)}@ical`);
} else if (field === "DTSTAMP") {
lines.splice(insertIdx++, 0, `DTSTAMP:${now}`);
} else if (field === "DTSTART") {
lines.splice(insertIdx++, 0, dtstart ? dtstart : `DTSTART:${now}`);
} else if (field === "DTEND") {
lines.splice(insertIdx++, 0, dtend ? dtend : `DTEND:${now}`);
} else if (field === "SUMMARY") {
lines.splice(insertIdx++, 0, summary ? summary : `SUMMARY:Imported Event`);
}
}
return lines.join("\r\n");
});
// Build single VCALENDAR
let result: string[] = [];
result.push("BEGIN:VCALENDAR");
result.push("VERSION:2.0");
result.push("PRODID:-//Open-Xchange//7.10.6-Rev81//EN");
if (timezoneBlock) result.push(timezoneBlock);
result.push(...enrichedEvents);
result.push("END:VCALENDAR");
if (enrichedEvents.length === 0) return ics;
return result.join("\r\n");
}
// Simple in-memory cache
let cachedIcs: string | null = null;
let cacheTimestamp = 0;
const CACHE_DURATION_MS = 30_000;
const config: Config = {
caldavUrl: Deno.env.get("CALDAV_URL") || Deno.args[0],
username: Deno.env.get("CALDAV_USERNAME") || Deno.args[1],
password: Deno.env.get("CALDAV_PASSWORD") || Deno.args[2],
calendarName: Deno.env.get("CALDAV_CALENDAR_NAME") || Deno.args[3],
};
if (!config.caldavUrl || !config.username || !config.password) {
console.error(`
Missing required configuration. Provide via environment variables or arguments:
Environment variables:
CALDAV_URL=https://calendar.example.com/dav/
CALDAV_USERNAME=myusername
CALDAV_PASSWORD=mypassword
CALDAV_CALENDAR_NAME=mycalendar
Or command line:
deno run --allow-net --allow-env --allow-read main.ts <url> <username> <password>
`);
Deno.exit(1);
}
async function getIcs(): Promise<string> {
// Use cache if valid
const now = Date.now();
if (cachedIcs && now - cacheTimestamp < CACHE_DURATION_MS) {
return cachedIcs;
}
const calendars = await propfindCalendars(config);
if (calendars.length === 0) {
throw new Error("No calendars found via PROPFIND");
}
let selectedCalendar = calendars[0];
if (config.calendarName) {
const found = calendars.find(c => c.displayName === config.calendarName);
if (found) {
selectedCalendar = found;
}
}
const calendarUrl = selectedCalendar.href.startsWith("http") ? selectedCalendar.href : new URL(selectedCalendar.href, config.caldavUrl).href;
let calendarData = await fetchCalDAVCalendar(calendarUrl, config);
if (!validateICalFormat(calendarData)) {
console.warn("⚠️ Warning: Content may not be valid iCalendar format");
}
// Enrich events for Google compatibility
calendarData = enrichICSEvents(calendarData);
cachedIcs = calendarData;
cacheTimestamp = now;
return calendarData;
}
if (import.meta.main) {
const port = Number(Deno.env.get("PORT") || 8080);
console.log(`Serving ICS calendar on http://localhost:${port}/calendar.ics`);
Deno.serve({
port,
handler: async (req: Request) => {
const url = new URL(req.url);
if (req.method === "GET" && url.pathname === "/calendar.ics") {
try {
const ics = await getIcs();
return new Response(ics, {
status: 200,
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Cache-Control": `max-age=${CACHE_DURATION_MS / 1000}`,
},
});
} catch (error) {
return new Response(`Error: ${(error as Error).message}`, { status: 500 });
}
}
return new Response("Not Found", { status: 404 });
}
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment