## 排查过程与结论 用户反馈:在 Inbox 新建待办后,列表里看不到。 ### 诊断手段 把 `reportComponents` 的 HTTP 执行部分抽成 `reportRaw`, 新增 `reportAll`(不带 comp-filter 的全量 calendar-query)作为对照。 ### 关键数据 | 查询 | 结果 | |---|---| | Inbox 的 VTODO REPORT | 响应 68 字符,含VTODO=false,响应数=0 | | Inbox 的全量 REPORT | **722 个资源,全部 VEVENT=true** | | 娱乐(home) 的 REPORT | **722 个资源** ← 与上面数字完全相同 | | 待办创建 PUT | `home_todo/todo-1793849023185-qx4b36.ics` → **201 成功** | ### 结论 **群晖把 `home_todo` 解析成了 `home` 的别名**,两个 URL 返回同一份 722 条日程数据。 所以: 1. 待办 PUT 到 `home_todo/todo-xxx.ics`,实际写进了 home 本 2. 按 VTODO 过滤查 `home_todo` → 服务器在这个"别名本"里找不到 VTODO → 返回空 3. 待办永远同步不下来 这不是 App 的 bug,是**服务器端日历本配置问题**(Inbox 指向了错误的存储)。 ## 本次改动 - `reportComponents` 拆出 `reportRaw(href, auth, body, compName)` - 新增 `reportAll(href, auth)`:全量 calendar-query - 移除临时诊断日志 ## 待用户处理 需要在群晖 Calendar Server 后台检查 `home_todo` 这个日历本的真实路径, 或让用户改用其他待办本(如「个人成长」已验证可正常工作)。 ## 编译 BUILD SUCCESSFUL
628 lines
26 KiB
Plaintext
628 lines
26 KiB
Plaintext
// 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(未返回时需要写探测)
|
||
/**
|
||
* ⭐ COMP_SET 该集合支持的组件类型(来自 supported-calendar-component-set)。
|
||
* 'event' = 纯日历本(只有 VEVENT)
|
||
* 'todo' = 纯待办本(只有 VTODO)
|
||
* 'both' = 混合本(两者都有)
|
||
* 'unknown' = 服务器未声明该属性(老实现常见)→ 按日历对待,保持兼容
|
||
*/
|
||
comps: string = 'unknown';
|
||
|
||
}
|
||
|
||
/** 日历本集合条目("添加/编辑账号"页列日历本用) */
|
||
export class DavCalendarEntry {
|
||
href: string = ''; // 完整 URL(已补全域名)
|
||
displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段)
|
||
color: string = ''; // 规范化后的 #RRGGBB,可能为空
|
||
/** ⭐ COMP_SET 'event' 纯日历 / 'todo' 纯待办 / 'both' 混合 / 'unknown' 服务器未声明 */
|
||
comps: string = 'unknown';
|
||
}
|
||
|
||
/**
|
||
* 日历本发现结果。
|
||
* 带 statusCode —— 调用方需要据此区分「401 凭据失效」与「非 207 异常」,故不能只返回列表。
|
||
*/
|
||
export class DavCalendarDiscovery {
|
||
statusCode: number = 0;
|
||
entries: DavCalendarEntry[] = [];
|
||
}
|
||
|
||
/**
|
||
* 带 HTTP 状态码的 DAV 请求错误。
|
||
* 同步引擎据此区分「404 日历本/资源在服务器上已不存在」(可降级处理:
|
||
* 单本跳过、自动从账号移除)与其它网络/服务器故障(整个账号同步失败)。
|
||
*/
|
||
export class DavHttpError extends Error {
|
||
readonly status: number;
|
||
|
||
constructor(status: number, message: string) {
|
||
super(message);
|
||
this.status = status;
|
||
}
|
||
}
|
||
|
||
export class DavClient {
|
||
/**
|
||
* PROPFIND 拉取某路径下所有集合的 calendar-color
|
||
* 返回「集合路径 → 颜色」列表(路径为服务器返回的原始 href)
|
||
*/
|
||
static async propfindColors(serverUrl: string, auth: string): Promise<DavColorEntry[]> {
|
||
const requestBody: string = '<?xml version="1.0" encoding="utf-8"?>' +
|
||
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
|
||
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
|
||
'<d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
||
'<d:current-user-privilege-set/>' +
|
||
// ⭐ COMP_SET 让服务器告诉我们这本是日历还是待办
|
||
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
|
||
'</d:prop></d:propfind>';
|
||
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;
|
||
entry.comps = DavClient.parseComps(block);
|
||
// 写权限: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();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ⭐ COMP_SET 解析 supported-calendar-component-set,归一成四类:
|
||
* event / todo / both / unknown
|
||
*
|
||
* 为什么 unknown 要单独存在:很多服务器(含自研实现)压根不返回这个属性。
|
||
* 这时不能武断判成"不是待办本",否则用户原本能用的日历会被当成待办、
|
||
* 或者反过来把纯日历标成待办 —— 保守按"日历"对待,不破坏既有行为。
|
||
*/
|
||
static parseComps(block: string): string {
|
||
const seg: string = DavClient.extractTag(block, 'supported-calendar-component-set');
|
||
if (seg === '') {
|
||
return 'unknown';
|
||
}
|
||
const event: boolean = /name\s*=\s*["']VEVENT["']/i.test(seg);
|
||
const todo: boolean = /name\s*=\s*["']VTODO["']/i.test(seg);
|
||
if (event && todo) {
|
||
return 'both';
|
||
}
|
||
if (todo) {
|
||
return 'todo';
|
||
}
|
||
if (event) {
|
||
return 'event';
|
||
}
|
||
return 'unknown';
|
||
}
|
||
|
||
/**
|
||
* PROPFIND 列出某账号下所有「日历集合」(resourcetype 含 calendar)。
|
||
*
|
||
* 供「添加账号 / 编辑账号」两页复用,取代它们各自维护的一份同逻辑拷贝 ——
|
||
* 解析逻辑集中在此,避免多处正则各自漂移。
|
||
* 解析用正则而非 XML 解析器(见 extractTag 的 XXE 说明)。
|
||
* 网络/解析异常向上抛出由调用方决定提示文案;仅"HTTP 状态码非成功"经 statusCode 返回。
|
||
*/
|
||
static async listCalendars(serverUrl: string, auth: string): Promise<DavCalendarDiscovery> {
|
||
const requestBody: string = '<?xml version="1.0" encoding="utf-8"?>' +
|
||
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
|
||
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
|
||
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
|
||
'<c:supported-calendar-component-set xmlns:c="urn:ietf:params:xml:ns:caldav"/>' +
|
||
'</d:prop></d:propfind>';
|
||
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;
|
||
entry.comps = DavClient.parseComps(block);
|
||
// COMPS_LOG 诊断:确认服务器有没有声明组件类型
|
||
LogUtil.write(`日历本组件类型 ${entry.displayName} → ${entry.comps}`);
|
||
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 拉取单个资源原文,**失败抛错**(与 getRaw 相反)。
|
||
*
|
||
* 为什么需要严格版:待办写回要"先 GET 原文、改字段、再 PUT"。
|
||
* 若 GET 失败却拿到一个 `"HTTP 404"` 之类的字符串并当 ICS 内容 PUT 回去,
|
||
* 会把服务器上那条待办**覆盖成一句错误信息**(数据破坏)。
|
||
* 原 getRaw 保留原行为不动(诊断用,已有调用方)。
|
||
*/
|
||
static async getRawStrict(url: string, auth: string): Promise<string> {
|
||
const httpRequest = http.createHttp();
|
||
try {
|
||
const resp: http.HttpResponse = await httpRequest.request(url, {
|
||
method: http.RequestMethod.GET,
|
||
header: {
|
||
'Authorization': auth,
|
||
'Content-Type': 'text/calendar; charset=utf-8',
|
||
'User-Agent': 'SyncCalendar/1.0'
|
||
},
|
||
connectTimeout: 10000,
|
||
readTimeout: 20000,
|
||
expectDataType: http.HttpDataType.STRING
|
||
});
|
||
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
||
throw new DavHttpError(resp.responseCode, `拉取待办原文失败,服务器返回 ${resp.responseCode}`);
|
||
}
|
||
return resp.result as string;
|
||
} finally {
|
||
httpRequest.destroy();
|
||
}
|
||
}
|
||
|
||
/** GET 拉取单个资源的原始 ICS(调试诊断用) */
|
||
static async getRaw(url: string, auth: string): Promise<string> {
|
||
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<boolean> {
|
||
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<RemoteItem[]> {
|
||
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<RemoteItem[]> {
|
||
const body: string = '<?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/></d:prop>' +
|
||
'<c:filter><c:comp-filter name="VCALENDAR">' +
|
||
'<c:comp-filter name="VEVENT">' +
|
||
'</c:comp-filter></c:comp-filter></c:filter></c:calendar-query>';
|
||
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 DavHttpError(resp.responseCode, `服务器返回状态码 ${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<RemoteItem[]> {
|
||
return DavClient.reportComponents(href, auth, 'VTODO', '');
|
||
}
|
||
|
||
/** 不带 comp-filter 的全量 calendar-query(诊断用) */
|
||
static async reportAll(href: string, auth: string): Promise<RemoteItem[]> {
|
||
const body: string = '<?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:filter>' +
|
||
'</c:calendar-query>';
|
||
return DavClient.reportRaw(href, auth, body, 'ALL');
|
||
}
|
||
|
||
/** 通用 REPORT:按组件类型过滤拉取 calendar-data + getetag */
|
||
private static async reportComponents(href: string, auth: string,
|
||
compName: string, timeRange: string): Promise<RemoteItem[]> {
|
||
const body: string = '<?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="${compName}">` +
|
||
timeRange +
|
||
'</c:comp-filter></c:comp-filter></c:filter></c:calendar-query>';
|
||
return DavClient.reportRaw(href, auth, body, compName);
|
||
}
|
||
|
||
/** 通用 REPORT 执行器:传完整 body,返回解析后的资源列表 */
|
||
static async reportRaw(href: string, auth: string, body: string,
|
||
compName: string): Promise<RemoteItem[]> {
|
||
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 DavHttpError(401, '服务器拒绝凭据(401)');
|
||
}
|
||
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
||
LogUtil.write(`HTTP REPORT(${compName}) 异常状态码 ${resp.responseCode}`);
|
||
throw new DavHttpError(resp.responseCode, `服务器返回状态码 ${resp.responseCode}`);
|
||
}
|
||
const xml: string = resp.result as string;
|
||
LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`);
|
||
LogUtil.write(`REPORT-诊断(${compName}) 前200字符: ${xml.substring(0, 200)}`);
|
||
LogUtil.write(`REPORT-诊断(${compName}) 含VTODO=${xml.indexOf('BEGIN:VTODO') >= 0} 含VEVENT=${xml.indexOf('BEGIN:VEVENT') >= 0} 响应数=${(xml.match(/<[\w-]*:?response[\s>]/gi) || []).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);
|
||
}
|
||
LogUtil.write(`诊断 blocks=${blocks.length} items=${items.length} xml长=${xml.length}`);
|
||
for (let bi = 0; bi < blocks.length; bi++) {
|
||
const bb: string = blocks[bi];
|
||
if (!/<[\w-]*:?response[\s>]/i.test(bb)) { continue; }
|
||
const h: string = DavClient.extractTag(bb, "href");
|
||
LogUtil.write(`诊断 block${bi} len=${bb.length} href=[${h.substring(0,50)}] VCAL=${bb.indexOf("BEGIN:VCALENDAR")>=0} VTODO=${bb.indexOf("BEGIN:VTODO")>=0}`);
|
||
}
|
||
return items;
|
||
} finally {
|
||
httpRequest.destroy();
|
||
}
|
||
}
|
||
|
||
/** PUT 新建/更新远端事件,返回响应 ETag(可能为空) */
|
||
static async putEvent(url: string, auth: string, ics: string): Promise<string> {
|
||
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 DavHttpError(resp.responseCode, `推送失败,服务器返回 ${resp.responseCode}`);
|
||
}
|
||
const headers = resp.header as Record<string, string>;
|
||
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<void> {
|
||
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 DavHttpError(resp.responseCode, `删除失败,服务器返回 ${resp.responseCode}`);
|
||
}
|
||
} finally {
|
||
httpRequest.destroy();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 提取任意命名空间前缀标签的文本内容(如 `<d:href>` / `<cs:getcolor>`)。
|
||
*
|
||
* 安全说明:此处**刻意用正则而不是 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() : '';
|
||
}
|
||
} |