Last active
September 3, 2025 16:26
-
-
Save freehuntx/c9e68186203a0fc3faa85df4601ba456 to your computer and use it in GitHub Desktop.
WebDAV proxy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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