// entry/src/main/ets/common/DavClient.ets // CalDAV HTTP 客户端:REPORT / PUT / DELETE import { http } from '@kit.NetworkKit'; import { buffer } from '@kit.ArkTS'; import { BusinessError } from '@kit.BasicServicesKit'; import { LogUtil } from './LogUtil'; /** REPORT 返回的单个远端资源 */ export class RemoteItem { href: string = ''; // 资源完整 URL etag: string = ''; ics: string = ''; // VCALENDAR 文本 } /** PROPFIND 返回的集合颜色与写权限 */ export class DavColorEntry { href: string = ''; // 集合路径(服务器返回的是路径,不带域名) color: string = ''; // 规范化后的 #RRGGBB,可能为空 writable: boolean = true; // current-user-privilege 是否含 write 权限(默认可写) privilegeKnown: boolean = false; // 服务器是否返回了 current-user-privilege-set(未返回时需要写探测) } /** 日历本集合条目("添加/编辑账号"页列日历本用) */ export class DavCalendarEntry { href: string = ''; // 完整 URL(已补全域名) displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段) color: string = ''; // 规范化后的 #RRGGBB,可能为空 } /** * 日历本发现结果。 * 带 statusCode —— 调用方需要据此区分「401 凭据失效」与「非 207 异常」,故不能只返回列表。 */ export class DavCalendarDiscovery { statusCode: number = 0; entries: DavCalendarEntry[] = []; } export class DavClient { /** * PROPFIND 拉取某路径下所有集合的 calendar-color * 返回「集合路径 → 颜色」列表(路径为服务器返回的原始 href) */ static async propfindColors(serverUrl: string, auth: string): Promise { const requestBody: string = '' + '' + '' + '' + ''; const httpRequest = http.createHttp(); try { const resp: http.HttpResponse = await httpRequest.request(serverUrl, { method: 'PROPFIND' as http.RequestMethod, header: { 'Authorization': auth, 'Content-Type': 'application/xml; charset=utf-8', 'Depth': '1', 'User-Agent': 'SyncCalendar/1.0' }, extraData: requestBody, connectTimeout: 10000, readTimeout: 20000 }); console.info(`PROPFIND(颜色) 响应码: ${resp.responseCode}`); LogUtil.write(`HTTP PROPFIND(颜色) ${serverUrl} → ${resp.responseCode}`); if (resp.responseCode !== 207 && resp.responseCode !== 200) { return []; } const xml: string = resp.result as string; const result: DavColorEntry[] = []; const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i); for (const block of blocks) { if (!/<[\w-]*:?response[\s>]/i.test(block)) { continue; } const href: string = DavClient.extractTag(block, 'href'); if (href === '') { continue; } const resourcetype: string = DavClient.extractTag(block, 'resourcetype'); if (!/calendar/i.test(resourcetype)) { continue; } const entry = new DavColorEntry(); entry.href = href; // 两个命名空间都试:cs:getcolor(CalendarServer)/ ical:calendar-color(Apple) let color: string = DavClient.normalizeHex(DavClient.extractTag(block, 'getcolor')); if (color === '') { color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color')); } entry.color = color; // 写权限:current-user-privilege-set 存在且不含任何 write 权限 → 只读 const privSet: string = DavClient.extractTag(block, 'current-user-privilege-set'); if (privSet !== '') { entry.privilegeKnown = true; entry.writable = /write/i.test(privSet); LogUtil.write(`PROPFIND ${href} 权限声明:${privSet.replace(/\s+/g, '').substring(0, 150)}`); } result.push(entry); } if (result.length > 0 && result.every((e: DavColorEntry): boolean => e.color === '')) { // 全部没拿到颜色时打印原始响应片段,便于诊断命名空间 console.info(`PROPFIND(颜色) 未取到颜色,响应片段: ${xml.substring(0, 600)}`); } return result; } finally { httpRequest.destroy(); } } /** * PROPFIND 列出某账号下所有「日历集合」(resourcetype 含 calendar)。 * * 供「添加账号 / 编辑账号」两页复用,取代它们各自维护的一份同逻辑拷贝 —— * 解析逻辑集中在此,避免多处正则各自漂移。 * 解析用正则而非 XML 解析器(见 extractTag 的 XXE 说明)。 * 网络/解析异常向上抛出由调用方决定提示文案;仅"HTTP 状态码非成功"经 statusCode 返回。 */ static async listCalendars(serverUrl: string, auth: string): Promise { const requestBody: string = '' + '' + '' + ''; const httpRequest = http.createHttp(); const out = new DavCalendarDiscovery(); try { const resp: http.HttpResponse = await httpRequest.request(serverUrl, { method: 'PROPFIND' as http.RequestMethod, header: { 'Authorization': auth, 'Content-Type': 'application/xml; charset=utf-8', 'Depth': '1', 'User-Agent': 'SyncCalendar/1.0' }, extraData: requestBody, connectTimeout: 10000, readTimeout: 15000 }); out.statusCode = resp.responseCode; LogUtil.write(`HTTP PROPFIND(日历本) ${serverUrl} → ${resp.responseCode}`); if (resp.responseCode !== 207 && resp.responseCode !== 200) { return out; } const xml: string = resp.result as string; const originMatch = /https?:\/\/[^/]+/i.exec(serverUrl); const origin: string = originMatch !== null ? originMatch[0] : ''; const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i); for (const block of blocks) { if (!/<[\w-]*:?response[\s>]/i.test(block)) { continue; } const href: string = DavClient.extractTag(block, 'href'); if (href === '') { continue; } const resourcetype: string = DavClient.extractTag(block, 'resourcetype'); if (!/calendar/i.test(resourcetype)) { continue; } let name: string = DavClient.extractTag(block, 'displayname'); if (name === '') { // 服务器没给 displayname → 回退为 href 路径末段 const segs: string[] = href.split('/').filter((s: string) => s !== ''); if (segs.length > 0) { const last: string = segs[segs.length - 1]; try { name = decodeURIComponent(last); } catch (err) { name = last; } } else { name = href; } } // 两个命名空间都试:cs:getcolor(CalendarServer)/ ical:calendar-color(Apple) let color: string = DavClient.normalizeHex(DavClient.extractTag(block, 'getcolor')); if (color === '') { color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color')); } const entry = new DavCalendarEntry(); entry.href = href.startsWith('http') ? href : origin + href; entry.displayName = name; entry.color = color; out.entries.push(entry); } return out; } finally { httpRequest.destroy(); } } /** * 规范化集合 href,用于「本地保存的 calendarHrefs」与「PROPFIND 返回的 href」比对。 * * 为什么需要:同一集合在不同 PROPFIND 请求/不同服务器上的写法可能不同—— * ① 绝对 URL(https://nas/calendars/A/) vs 纯路径(/calendars/A/) * ② URL 编码差异(/calendars/%E6%96%B0/ vs /calendars/新/) * ③ 末尾斜杠有无、重复斜杠 * 直接用字符串相等比对会匹配失败 → 服务器上的颜色取不回来,界面只能退回调色板色。 * 这里统一成「解码后的路径、无尾斜杠」再比较(路径大小写保持敏感,不转小写)。 */ static normalizeHref(href: string): string { let s: string = href.trim(); const m = /^[a-zA-Z]+:\/\/[^/]+/i.exec(s); if (m !== null) { s = s.substring(m[0].length); } try { s = decodeURIComponent(s); } catch (err) { // 解码失败(如含非法 % 序列)保持原样 } s = s.replace(/\/{2,}/g, '/'); if (s.length > 1 && s.endsWith('/')) { s = s.substring(0, s.length - 1); } return s; } /** 颜色规范化:#RRGGBBAA → #RRGGBB */ static normalizeHex(raw: string): string { const v: string = raw.trim(); if (/^#[0-9A-Fa-f]{8}$/.test(v)) { return '#' + v.substring(3, 9).toUpperCase(); } if (/^#[0-9A-Fa-f]{6}$/.test(v)) { return v.toUpperCase(); } return ''; } /** Basic Auth 头 */ static authHeader(username: string, password: string): string { return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64'); } /** GET 拉取单个资源的原始 ICS(调试诊断用) */ static async getRaw(url: string, auth: string): Promise { const httpRequest = http.createHttp(); try { const resp: http.HttpResponse = await httpRequest.request(url, { method: http.RequestMethod.GET, header: { 'Authorization': auth, 'User-Agent': 'SyncCalendar/1.0' }, connectTimeout: 10000, readTimeout: 15000 }); if (resp.responseCode >= 200 && resp.responseCode < 300) { return resp.result as string; } return `HTTP ${resp.responseCode}`; } finally { httpRequest.destroy(); } } /** * 写权限探测:向日历本 PUT 一个探测资源—— * 2xx → 可写(随后删除探测资源);403/401 等 → 只读;网络异常 → 乐观按可写。 * 用于服务器不返回 current-user-privilege-set 的情况(如部分 Synology 配置)。 */ static async probeWritable(href: string, auth: string): Promise { const url: string = href.endsWith('/') ? `${href}synccalendar-probe.ics` : `${href}/synccalendar-probe.ics`; const ics: string = 'BEGIN:VCALENDAR\r\n' + 'VERSION:2.0\r\n' + 'PRODID:-//SyncCalendar//Probe//CN\r\n' + 'BEGIN:VEVENT\r\n' + 'UID:syncprobe\r\n' + 'DTSTAMP:20000101T000000Z\r\n' + 'DTSTART:20000101T000000Z\r\n' + 'DTEND:20000101T010000Z\r\n' + 'SUMMARY:probe\r\n' + 'END:VEVENT\r\n' + 'END:VCALENDAR\r\n'; const httpRequest = http.createHttp(); try { const resp: http.HttpResponse = await httpRequest.request(url, { method: http.RequestMethod.PUT, header: { 'Authorization': auth, 'Content-Type': 'text/calendar; charset=utf-8', 'User-Agent': 'SyncCalendar/1.0' }, extraData: ics, connectTimeout: 10000, readTimeout: 15000 }); LogUtil.write(`HTTP PUT(探测) ${url} → ${resp.responseCode}`); if (resp.responseCode >= 200 && resp.responseCode < 300) { // 写入成功:删除探测资源,避免污染日历本 try { await DavClient.deleteRemote(url, auth); } catch (err) { // 删除失败不影响权限判定 } return true; } if (resp.responseCode === 412 || resp.responseCode === 405 || resp.responseCode === 409) { // 资源已存在/方法冲突等:说明有写权限 return true; } return false; // 403/401 等 → 只读 } catch (err) { // 网络异常无法判定:乐观按可写,避免误标只读 return true; } finally { httpRequest.destroy(); } } /** REPORT calendar-query:全量拉取某日历本内所有 VEVENT(不加时间范围,保证数据完整) */ static async reportCalendar(href: string, auth: string): Promise { return DavClient.reportComponents(href, auth, 'VEVENT', ''); } /** * REPORT calendar-query:仅拉取 href + getetag(不含 calendar-data)。 * 部分服务器(如群晖)的 REPORT calendar-data 会剥离 VALARM 导致提醒丢失, * 因此数据改为对变更资源逐个 GET 补拉(GET 返回完整 ICS)。 */ static async reportEtags(href: string, auth: string): Promise { const body: string = '' + '' + '' + '' + '' + ''; const httpRequest = http.createHttp(); try { const resp: http.HttpResponse = await httpRequest.request(href, { method: 'REPORT' as http.RequestMethod, header: { 'Authorization': auth, 'Content-Type': 'application/xml; charset=utf-8', 'Depth': '1', 'User-Agent': 'SyncCalendar/1.0' }, extraData: body, connectTimeout: 10000, readTimeout: 30000 }); LogUtil.write(`HTTP REPORT(etag) ${href} → ${resp.responseCode}`); if (resp.responseCode < 200 || resp.responseCode >= 300) { throw new Error(`服务器返回状态码 ${resp.responseCode}`); } const xml: string = resp.result as string; const items: RemoteItem[] = []; const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i); for (const block of blocks) { if (!/<[\w-]*:?response[\s>]/i.test(block)) { continue; } const resHref: string = DavClient.extractTag(block, 'href'); if (resHref === '') { continue; } const item = new RemoteItem(); item.href = resHref; item.etag = DavClient.extractTag(block, 'getetag').replace(/"/g, ''); items.push(item); } return items; } finally { httpRequest.destroy(); } } /** REPORT calendar-query:拉取某日历本内所有 VTODO 待办(不限时间范围,量小) */ static async reportTodos(href: string, auth: string): Promise { return DavClient.reportComponents(href, auth, 'VTODO', ''); } /** 通用 REPORT:按组件类型过滤拉取 calendar-data + getetag */ private static async reportComponents(href: string, auth: string, compName: string, timeRange: string): Promise { const body: string = '' + '' + '' + '' + `` + timeRange + ''; const httpRequest = http.createHttp(); try { const resp: http.HttpResponse = await httpRequest.request(href, { method: 'REPORT' as http.RequestMethod, header: { 'Authorization': auth, 'Content-Type': 'application/xml; charset=utf-8', 'Depth': '1', 'User-Agent': 'SyncCalendar/1.0' }, extraData: body, connectTimeout: 10000, readTimeout: 60000 }); console.info(`REPORT(${compName}) ${href} 响应码: ${resp.responseCode}`); LogUtil.write(`HTTP REPORT(${compName}) ${href} → ${resp.responseCode}`); if (resp.responseCode === 401) { LogUtil.write(`HTTP REPORT(${compName}) 401 拒绝凭据`); throw new Error('服务器拒绝凭据(401)'); } if (resp.responseCode < 200 || resp.responseCode >= 300) { LogUtil.write(`HTTP REPORT(${compName}) 异常状态码 ${resp.responseCode}`); throw new Error(`服务器返回状态码 ${resp.responseCode}`); } const xml: string = resp.result as string; LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`); const originMatch = /https?:\/\/[^/]+/i.exec(href); const origin: string = originMatch !== null ? originMatch[0] : ''; const items: RemoteItem[] = []; const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i); for (const block of blocks) { if (!/<[\w-]*:?response[\s>]/i.test(block)) { continue; } const resHref: string = DavClient.extractTag(block, 'href'); if (resHref === '') { continue; } const etag: string = DavClient.extractTag(block, 'getetag').replace(/"/g, ''); const start: number = block.indexOf('BEGIN:VCALENDAR'); const end: number = block.indexOf('END:VCALENDAR'); if (start < 0 || end < 0) { continue; } const item = new RemoteItem(); item.href = resHref.startsWith('http') ? resHref : origin + resHref; item.etag = etag; item.ics = block.substring(start, end + 'END:VCALENDAR'.length); items.push(item); } return items; } finally { httpRequest.destroy(); } } /** PUT 新建/更新远端事件,返回响应 ETag(可能为空) */ static async putEvent(url: string, auth: string, ics: string): Promise { const httpRequest = http.createHttp(); try { const resp: http.HttpResponse = await httpRequest.request(url, { method: http.RequestMethod.PUT, header: { 'Authorization': auth, 'Content-Type': 'text/calendar; charset=utf-8', 'User-Agent': 'SyncCalendar/1.0' }, extraData: ics, connectTimeout: 10000, readTimeout: 30000 }); console.info(`PUT ${url} 响应码: ${resp.responseCode}`); LogUtil.write(`HTTP PUT ${url} → ${resp.responseCode}`); if (resp.responseCode < 200 || resp.responseCode >= 300) { throw new Error(`推送失败,服务器返回 ${resp.responseCode}`); } const headers = resp.header as Record; if (headers !== undefined && headers !== null) { const etag = headers['ETag'] ?? headers['etag'] ?? ''; return typeof etag === 'string' ? etag.replace(/"/g, '') : ''; } return ''; } finally { httpRequest.destroy(); } } /** DELETE 远端事件 */ static async deleteRemote(url: string, auth: string): Promise { const httpRequest = http.createHttp(); try { const resp: http.HttpResponse = await httpRequest.request(url, { method: http.RequestMethod.DELETE, header: { 'Authorization': auth, 'User-Agent': 'SyncCalendar/1.0' }, connectTimeout: 10000, readTimeout: 30000 }); console.info(`DELETE ${url} 响应码: ${resp.responseCode}`); LogUtil.write(`HTTP DELETE ${url} → ${resp.responseCode}`); // 404 视为已删除,成功 if ((resp.responseCode < 200 || resp.responseCode >= 300) && resp.responseCode !== 404) { throw new Error(`删除失败,服务器返回 ${resp.responseCode}`); } } finally { httpRequest.destroy(); } } /** * 提取任意命名空间前缀标签的文本内容(如 `` / ``)。 * * 安全说明:此处**刻意用正则而不是 XML 解析器**——DAV 响应来自用户自填的远端服务器, * 属不可信输入;正则提取不构建 DOM、不解析实体,从根上规避了 XXE(外部实体扩展) * 与「十亿笑声」实体炸弹这类解析器层面的攻击面。 * 另外 `tag` 只由本文件内的代码常量传入,不来自远端数据,故无需防注入。 */ static extractTag(xml: string, tag: string): string { const pattern: string = '<([\\w-]+:)?' + tag + '(?:\\s[^>]*)?>([\\s\\S]*?)<\\/([\\w-]+:)?' + tag + '>'; const regex: RegExp = new RegExp(pattern, 'i'); const match = regex.exec(xml); return match !== null ? match[2].trim() : ''; } }