Files
SyncCalendar/entry/src/main/ets/common/DavClient.ets
T
laoyang 8126c058fa diag(同步): 抽出 reportRaw 通用执行器,排查 Inbox 待办拉不到 (#53)
## 排查过程与结论

用户反馈:在 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
2026-11-05 11:26:37 +08:00

628 lines
26 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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() : '';
}
}